diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a42f1..b0d0357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The report screen now draws a per-day sparkline of the selected range, with idle days rendered as gaps rather than as low bars (#80). +- Command palette (`ctrl+p`): a fuzzy action launcher that floats over the current screen, + offering both that screen's commands and one-step navigation to any other (#71). +- An overlay compositor: `View()` can now layer a floating box over a screen instead of + replacing it (#59). ### Changed diff --git a/README.it.md b/README.it.md index 444041e..9ce51d5 100644 --- a/README.it.md +++ b/README.it.md @@ -28,8 +28,9 @@ Provala senza account ClickUp: **`CLICKUP_DEMO=1 clup`** avvia una modalità demo con dati fittizi — che include anche il modello di fatturazione: split billable/non billable, due valute di fatturazione, voci taggate e un budget per lista. Il GIF è registrato con -[vhs](https://github.com/charmbracelet/vhs) da [`docs/demo.tape`](docs/demo.tape) (lancia -`vhs docs/demo.tape` per rigenerarlo). +[vhs](https://github.com/charmbracelet/vhs) da [`docs/demo.tape`](docs/demo.tape); rigeneralo +con un binario compilato in locale e un `PATH` temporaneo, senza installare nulla — +`go build -o /tmp/clupdemo/clup ./cmd/clup && PATH=/tmp/clupdemo:$PATH vhs docs/demo.tape`. ## Requisiti @@ -125,6 +126,11 @@ prima, così le colonne numeriche e l'importo restano leggibili. Sopra la tabell sparkline giornaliera mostra a colpo d'occhio l'andamento del periodo — un giorno senza ore registrate appare come un vuoto, non come una barra. +Premi `ctrl+p` da qualunque schermata per aprire la **command palette**: un launcher +fuzzy che galleggia sopra quello che stai guardando. Scrivi qualche lettera e premi +`Enter` — offre i comandi della schermata corrente (gli stessi che il footer annuncia, +con i loro tasti) più la navigazione in un passo verso qualunque altra schermata. + ### Comandi nella TUI Ogni schermata mostra un footer con i tasti che accetta in quel momento, generato diff --git a/README.md b/README.md index 075ebd1..583750b 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ Try it yourself without a ClickUp account: **`CLICKUP_DEMO=1 clup`** runs a demo mode with fixture data — including the billing model: a billable/non-billable split, two invoicing currencies, tagged entries, and a per-list budget. The GIF is recorded with -[vhs](https://github.com/charmbracelet/vhs) from [`docs/demo.tape`](docs/demo.tape) (run -`vhs docs/demo.tape` to regenerate). +[vhs](https://github.com/charmbracelet/vhs) from [`docs/demo.tape`](docs/demo.tape); regenerate +it with a locally built binary on a temporary `PATH`, without installing anything — +`go build -o /tmp/clupdemo/clup ./cmd/clup && PATH=/tmp/clupdemo:$PATH vhs docs/demo.tape`. ## Requirements @@ -121,6 +122,11 @@ the label column gives up its space first, so the numeric and amount columns kee their footing. Above the table, a per-day sparkline shows the shape of the period at a glance — a day with no logged hours renders as a gap, not a bar. +Press `ctrl+p` from any screen to open the **command palette**: a fuzzy launcher that +floats over whatever you're looking at. Type a few letters and press `Enter` — it offers +the current screen's own commands (the same ones the footer advertises, with their keys) +plus one-step navigation to any other screen. + ### TUI commands Every screen shows a footer with the keys it currently accepts, generated directly diff --git a/docs/demo.gif b/docs/demo.gif index 485dfa4..7add3cf 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape index ab6de1a..f2b1840 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,11 +1,13 @@ # docs/demo.tape — vhs script for the README demo GIF. # -# Generate: vhs docs/demo.tape (writes docs/demo.gif) # Install vhs: https://github.com/charmbracelet/vhs (brew install vhs) # # Uses the built-in demo mode (CLICKUP_DEMO=1): fixture data, no ClickUp -# account or token needed, so the GIF is fully reproducible. Requires the -# `clup` binary on PATH (go install ./cmd/clup). +# account or token needed, so the GIF is fully reproducible. +# +# Regenerate with a locally built binary, without installing anything: +# go build -o /tmp/clupdemo/clup ./cmd/clup +# PATH=/tmp/clupdemo:$PATH vhs docs/demo.tape # # NOTE: re-run `vhs docs/demo.tape` after ANY change to what a screen # renders. The v1.9 design system makes this easy to forget: the key footer @@ -53,6 +55,16 @@ Sleep 700ms Enter Sleep 2500ms +# Command palette (#71): ctrl+p opens a fuzzy launcher that floats over the +# report; type a few letters to filter, then dismiss without picking anything +# so the report underneath is untouched for the grouping cycle that follows +Ctrl+P +Sleep 1s +Type "exp" +Sleep 1500ms +Escape +Sleep 500ms + # Cycle the grouping: total -> task -> list -> day -> tag (v1.7: the summary # line, billable/non-billable split and per-currency subtotals sit below the # bucket rows on every grouping — the pause after the last "g" lets a viewer diff --git a/docs/superpowers/plans/2026-07-29-command-palette.md b/docs/superpowers/plans/2026-07-29-command-palette.md new file mode 100644 index 0000000..4d07e7d --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-command-palette.md @@ -0,0 +1,3031 @@ +# Command palette + overlay compositor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the TUI a `ctrl+p` fuzzy command palette that floats over the current +screen, and the line compositor that makes floating possible (#71, #59's last checkbox). + +**Architecture:** A pure `internal/fuzzy` package scores the query. A pure `composite()` +splices a rendered box into the body's lines, cell by cell, ANSI-aware. An `overlayKind` +field on `Model` sits orthogonal to `m.screen` and takes the keyboard while open. The +palette's rows come from two sources — the current screen's keymap, derived not +duplicated, and an explicit `"Go to X"` navigation list whose targets share one set of +`openX()` methods with the key handlers. + +**Tech Stack:** Go 1.26.5, bubbletea v1.3.10, bubbles v1.0.0, lipgloss v1.1.0, +`github.com/charmbracelet/x/ansi` v0.11.6 (promoted from indirect to direct). + +**Spec:** `docs/superpowers/specs/2026-07-29-command-palette-overlay-design.md`. When this +plan and the spec disagree, stop and ask — do not pick one. + +## Global Constraints + +- **Everything committed to the repo is in English**, American spelling — code, + identifiers, comments, UI strings, test names and messages, commit messages. The spec + is in Italian; that is the historical exception for `docs/superpowers/`. +- **Never add a `Co-Authored-By` trailer to a commit message.** Not once, not anywhere. +- **Conventional Commits** for every commit message. +- **No new dependency** beyond promoting `github.com/charmbracelet/x/ansi` from indirect + to direct in `go.mod`. Do not add `sahilm/fuzzy` or any other module. +- `internal/report`, `internal/duration` and the new `internal/fuzzy` stay **pure**: no + I/O, nothing outside the standard library, no import of `internal/config`, + `internal/clickup` or `internal/tui`. +- **Never call the real ClickUp API.** No credentials exist here. Use `httptest` and set + `client.BaseURL` (see `internal/tui/app_test.go:86-96` for the pattern). +- **No style function calls `lipgloss.NewStyle()`.** Every style comes from the `theme` + argument, which carries the injected renderer. `th.Cell` exists precisely so a plain + cell has a style to use. +- **Never use `th.Title` inside a box row.** It carries `MarginBottom(1)` and would inject + a blank line into the middle of the overlay. +- **Pre-commit gate, all five, all clean, every commit:** + `gofmt -l .` (must print nothing) · `go vet ./...` · + `go run honnef.co/go/tools/cmd/staticcheck@latest ./...` · `go build ./...` · + `go test ./... -race` +- **Goldens are regenerated with `go test ./internal/tui -update`, never hand-edited.** +- **Goldens are blind to color.** `TestMain` (`golden_test.go:26`) pins the default + renderer to `termenv.Ascii`, which strips backgrounds *and* bold. Every color decision + needs a direct assertion on the returned `lipgloss.Style` or on real escape bytes from a + locally built renderer. +- **Display width ≠ rune count.** They agree only in ASCII. Truncate through + `shaveToWidth` (`internal/tui/report_table.go:250`), never by slicing runes. +- **A test written against a specific bug must be verified failing against that bug.** + Where a step says so, break the implementation the stated way, run the test, paste the + failing output into your report, then restore. Four tests in the previous tranche were + caught passing against the very bug they existed to catch. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fuzzy/fuzzy.go` (new) | `Match` — case-insensitive subsequence scoring with matched rune indices. Pure. | +| `internal/tui/overlay.go` (new) | `composite` — splice a rendered box into a body's lines, ANSI-aware. | +| `internal/tui/open.go` (new) | The six screen-opening `Model` methods shared by the key handlers and the palette. | +| `internal/tui/actions.go` (new) | `action`, `screenActions`, `globalActions`, `paletteActions`, `keyMsgFor`, `capitalize`. | +| `internal/tui/palette.go` (new) | `paletteModel`, its update loop, its geometry and its rendering. | +| `internal/tui/keys.go` | The `Palette`/`PaletteUp`/`PaletteDown` defaults, `paletteKeys`, `paletteBindings`, and the `keysFor`/`screenKeys` split. | +| `internal/tui/app.go` | `overlayKind`, the two `Model` fields, the `Update` key ordering, `View()` composition. | +| `internal/tui/nav.go` | Every navigation function dissolves an open overlay. | + +`openListBrowser` (`app.go:533`), `openEntries` (`entries.go:133`) and `openBudgetView` +(`report.go:135`) already exist in this shape and **stay where they are**. Moving them into +`open.go` would inflate the diff for no behavioural gain; `open.go`'s doc comment names +them instead. `openEntries` is the one exception — its signature changes in Task 3. + +--- + +## Task 1: `internal/fuzzy` + +**Files:** +- Create: `internal/fuzzy/fuzzy.go` +- Create: `internal/fuzzy/fuzzy_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `func Match(query, target string) (score int, idx []int, ok bool)`. `idx` holds + **rune** indices into `target`, ascending, one per query rune. An empty query returns + `(0, nil, true)`. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/fuzzy/fuzzy_test.go`: + +```go +package fuzzy + +import ( + "slices" + "testing" +) + +func TestMatch(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + query string + target string + wantOK bool + wantIdx []int + }{ + {"empty query matches anything", "", "Export report", true, nil}, + {"empty query matches an empty target", "", "", true, nil}, + {"query longer than target", "exports", "exp", false, nil}, + {"no match at all", "zz", "Export report", false, nil}, + {"exact prefix", "exp", "Export report", true, []int{0, 1, 2}}, + {"query upper, target lower", "EXP", "export report", true, []int{0, 1, 2}}, + {"query lower, target upper", "exp", "EXPORT REPORT", true, []int{0, 1, 2}}, + {"subsequence with gaps", "ert", "Export report", true, []int{0, 4, 5}}, + // Greedy matching returns [0, 5] here: r at 0, then the first t, the one + // inside "report" — highlighting repor[t]. The word-start bonus is what + // makes [0, 7] win instead: [r]eport [t]able. This row is the reason + // Match is a dynamic program and not a left-to-right scan. + {"prefers a word start over the nearest rune", "rt", "report table", true, []int{0, 7}}, + // idx is in RUNES. With byte offsets this row would be [0, 5]. + {"multibyte target", "cà", "città", true, []int{0, 4}}, + {"whole target", "abc", "abc", true, []int{0, 1, 2}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, idx, ok := Match(tc.query, tc.target) + if ok != tc.wantOK { + t.Fatalf("Match(%q, %q) ok = %v, want %v", tc.query, tc.target, ok, tc.wantOK) + } + if !ok { + return + } + if !slices.Equal(idx, tc.wantIdx) { + t.Errorf("Match(%q, %q) idx = %v, want %v", tc.query, tc.target, idx, tc.wantIdx) + } + }) + } +} + +// TestMatchScoreIsExact pins the arithmetic on the smallest case that exercises +// both bonuses, so the weights cannot drift unnoticed while the orderings in +// TestMatchRanks still happen to hold. +func TestMatchScoreIsExact(t *testing.T) { + t.Parallel() + // a at 0: word start (+8), no lead penalty. b at 1: consecutive (+10). + score, _, ok := Match("ab", "ab") + if !ok { + t.Fatal(`Match("ab", "ab") did not match`) + } + if score != 18 { + t.Errorf("score = %d, want 18", score) + } +} + +// TestMatchRanks pins the orderings the palette depends on. These survive a +// tweak to the weights; TestMatchScoreIsExact does not. That split is +// deliberate: the constants are tunable, the orderings are not. +func TestMatchRanks(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + query string + better string + worse string + }{ + // "Export", not "Export report": screen actions are labelled + // capitalize(binding.Help().Desc), and Export's description is "export". + {"a screen action beats the navigation row", "exp", "Export", "Go to export"}, + {"a prefix beats a match buried inside", "rep", "Report", "Go to prep"}, + {"consecutive beats scattered", "ab", "ab c", "a x b"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + b, _, ok := Match(tc.query, tc.better) + if !ok { + t.Fatalf("Match(%q, %q) did not match", tc.query, tc.better) + } + w, _, ok := Match(tc.query, tc.worse) + if !ok { + t.Fatalf("Match(%q, %q) did not match", tc.query, tc.worse) + } + if b <= w { + t.Errorf("Match(%q, %q) = %d, want more than Match(%q, %q) = %d", + tc.query, tc.better, b, tc.query, tc.worse, w) + } + }) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fuzzy/` +Expected: FAIL — the package has no non-test file, so `Match` is undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/fuzzy/fuzzy.go`: + +```go +// Package fuzzy scores a query against a candidate string the way a command +// palette needs it: case-insensitive subsequence matching that also reports +// which runes matched, so the caller can highlight them. +// +// The package is pure — no I/O, nothing outside the standard library — for the +// same reason internal/report and internal/duration are: the interesting part +// is an algorithm, and an algorithm is worth testing without a Model. +package fuzzy + +import ( + "math" + "unicode" +) + +// Scoring weights. See the spec's section 7.2. +const ( + // consecutiveBonus rewards a rune that immediately follows the previous match. + consecutiveBonus = 10 + // boundaryBonus rewards a match that starts a word. + boundaryBonus = 8 + // maxLeadPenalty caps "how far into the target the match starts". Without a + // cap, a long label loses to a worse match that merely starts further left. + maxLeadPenalty = 10 +) + +// noMatch marks an unreachable cell of the table. It is halved so that adding a +// bonus to it can never wrap around into a plausible score. +const noMatch = math.MinInt / 2 + +// Match reports whether query matches target as a case-insensitive +// subsequence. When ok, score ranks the match and idx holds the rune indices of +// target that were matched, in ascending order, one per query rune. +// +// An empty query always matches, with score 0 and a nil idx. +// +// The search is exhaustive rather than greedy because idx is not only a ranking +// input: it decides which runes the caller highlights. Greedy matching of "rt" +// against "report table" yields [0, 5] — repor[t] — where the right answer is +// [0, 7], [r]eport [t]able. +func Match(query, target string) (score int, idx []int, ok bool) { + q := lowerRunes(query) + if len(q) == 0 { + return 0, nil, true + } + t := lowerRunes(target) + if len(q) > len(t) { + return 0, nil, false + } + + // best[j][i] is the highest score for matching q[:j+1] with q[j] landing on + // t[i]. from[j][i] is the target index q[j-1] landed on along that path, + // which is what lets idx be reconstructed at the end. + best := make([][]int, len(q)) + from := make([][]int, len(q)) + for j := range best { + best[j] = make([]int, len(t)) + from[j] = make([]int, len(t)) + for i := range best[j] { + best[j][i] = noMatch + from[j][i] = -1 + } + } + + for i := range t { + if t[i] != q[0] { + continue + } + best[0][i] = boundaryScore(t, i) - min(i, maxLeadPenalty) + } + for j := 1; j < len(q); j++ { + for i := j; i < len(t); i++ { + if t[i] != q[j] { + continue + } + for p := j - 1; p < i; p++ { + if best[j-1][p] == noMatch { + continue + } + s := best[j-1][p] + boundaryScore(t, i) + if p == i-1 { + s += consecutiveBonus + } + // Strictly greater keeps the leftmost path on a tie, which is + // what makes the result deterministic. + if s > best[j][i] { + best[j][i], from[j][i] = s, p + } + } + } + } + + last := len(q) - 1 + end := -1 + score = noMatch + for i := range t { + if best[last][i] > score { + end, score = i, best[last][i] + } + } + if end < 0 { + return 0, nil, false + } + idx = make([]int, len(q)) + for j, i := last, end; j >= 0; j-- { + idx[j] = i + i = from[j][i] + } + return score, idx, true +} + +// boundaryScore is the word-start bonus for t[i]: index 0, or a rune that +// follows a separator. +func boundaryScore(t []rune, i int) int { + if i == 0 { + return boundaryBonus + } + switch t[i-1] { + case ' ', '-', '_', '/', '.': + return boundaryBonus + } + return 0 +} + +// lowerRunes decomposes s into lower-cased runes. Indices into the result are +// rune indices, which is what Match reports and what a caller highlighting +// characters needs — byte offsets would be wrong for any accented label. +func lowerRunes(s string) []rune { + r := []rune(s) + for i := range r { + r[i] = unicode.ToLower(r[i]) + } + return r +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fuzzy/ -v` +Expected: PASS, every subtest. + +- [ ] **Step 5: Verify the greedy-discrimination test actually discriminates** + +The `"rt"` / `"report table"` row exists to catch greedy matching. Prove it does. + +Temporarily replace the body of `Match` (keeping the empty-query and length guards) with a +greedy scan: + +```go + idx = make([]int, 0, len(q)) + i := 0 + for _, qr := range q { + for i < len(t) && t[i] != qr { + i++ + } + if i == len(t) { + return 0, nil, false + } + idx = append(idx, i) + i++ + } + return 0, idx, true +``` + +Run: `go test ./internal/fuzzy/ -run 'TestMatch/prefers_a_word_start' -v` +Expected: **FAIL**, reporting `idx = [0 5], want [0 7]`. + +Paste that failing output into your report, then restore the real implementation and +re-run `go test ./internal/fuzzy/` to confirm it is green again. + +- [ ] **Step 6: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/fuzzy +git commit -m "feat(fuzzy): add a pure subsequence matcher with matched-rune indices" +``` + +--- + +## Task 2: The overlay compositor + +**Files:** +- Create: `internal/tui/overlay.go` +- Create: `internal/tui/overlay_test.go` +- Modify: `go.mod` (promote `github.com/charmbracelet/x/ansi` to a direct require) + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `func composite(body, box string, x, y int) string`. `x`/`y` are terminal + cells. Returns the body with the box's lines spliced in, the body preserved verbatim + outside the box's rectangle. + +**Measured behaviour of `ansi.Cut` you must rely on** (probed against x/ansi v0.11.6 — +these are facts, not guesses, and the implementation below depends on all four): + +| Call on `"\x1b[31mHELLO\x1b[0m world"` | Result | +|---|---| +| `Cut(s, 0, 3)` | `"\x1b[31mHEL\x1b[0m"` — closes the style it opened | +| `Cut(s, 3, 11)` | `"\x1b[31mLO\x1b[0m world"` — re-emits the active style at the cut | +| `Cut(s, 8, 3)` | `""` — `left > right` is not a panic | +| `Cut(s, 20, 30)` | `"\x1b[31m\x1b[0m"` — **not empty** past the end: a zero-width escape pair | +| `Cut("\x1b[31mHELLO world", 0, 3)` | `"\x1b[31mHEL"` — on **unterminated** input it does NOT close | + +- [ ] **Step 1: Write the failing tests** + +Create `internal/tui/overlay_test.go`: + +```go +package tui + +import ( + "io" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +func TestCompositeSplicesTheBoxIntoTheBody(t *testing.T) { + t.Parallel() + body := "abcdefghij\nklmnopqrst\nuvwxyz1234" + got := composite(body, "[]\n[]", 3, 1) + want := "abcdefghij\nklm[]pqrst\nuvw[]z1234" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +// A body line shorter than x must gain spaces. Without the padding the box +// slides left on that line alone, which reads as a broken border. +func TestCompositePadsShortBodyLines(t *testing.T) { + t.Parallel() + got := composite("abcdefghij\nkl", "[]", 5, 1) + want := "abcdefghij\nkl []" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +// A body shorter than y+height must gain blank lines, or the box is clipped at +// the bottom — the Home screen's body is three lines and the palette is ten. +func TestCompositeExtendsAShortBody(t *testing.T) { + t.Parallel() + got := composite("abc", "[]\n[]", 0, 2) + want := "abc\n\n[]\n[]" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +func TestCompositeBoxWiderThanBody(t *testing.T) { + t.Parallel() + got := composite("ab", "XXXXXX", 0, 0) + if got != "XXXXXX" { + t.Errorf("composite = %q, want %q", got, "XXXXXX") + } +} + +func TestCompositeKeepsEveryLineWidth(t *testing.T) { + t.Parallel() + body := strings.Repeat("x", 40) + "\n" + strings.Repeat("y", 40) + for _, l := range strings.Split(composite(body, "AAAA\nBBBB", 10, 0), "\n") { + if w := lipgloss.Width(l); w != 40 { + t.Errorf("line %q is %d cells wide, want 40", l, w) + } + } +} + +// The same property with wide glyphs, which is where it actually breaks. +// ansi.Cut treats the two edges differently: on the left it DROPS a cluster +// that straddles the limit, on the right it KEEPS it whole — so the right-hand +// segment starts one column early and the line comes out a cell too wide. +// +// An ASCII-only width test passes straight over this. That is exactly how a +// width test in the previous tranche passed over a 78-column table rendered +// into a 60-column terminal. +func TestCompositeHandlesWideGlyphsOnBothEdges(t *testing.T) { + t.Parallel() + // 20 double-width glyphs: 40 cells, one glyph per two columns, so a box at + // an odd x straddles a glyph at both edges. + body := strings.Repeat("漢", 20) + if w := lipgloss.Width(body); w != 40 { + t.Fatalf("the fixture is %d cells, want 40; this test assumes double-width glyphs", w) + } + got := composite(body, "AAAAAA", 5, 0) + if w := lipgloss.Width(got); w != 40 { + t.Errorf("composited line is %d cells, want 40: %q", w, got) + } + if !strings.Contains(got, "AAAAAA") { + t.Errorf("the box was mangled: %q", got) + } +} + +// The one failure mode a golden can never see. TestMain pins the DEFAULT +// renderer to termenv.Ascii, so golden output carries no escapes at all; this +// builds its own renderer with a real profile and asserts on the bytes. +// +// The trailing reset is stripped on purpose: an unterminated style is the one +// shape ansi.Cut does not close on its own, so it is the only shape whose color +// can bleed into the box. +func TestCompositeDoesNotLeakStyleIntoTheBox(t *testing.T) { + t.Parallel() + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + styled := r.NewStyle().Foreground(lipgloss.Color("1")).Render("HELLO world") + open := strings.TrimSuffix(styled, "\x1b[0m") + if !strings.Contains(open, "\x1b[") { + t.Fatalf("the fixture carries no escape sequence (%q); this test would pass vacuously", open) + } + + got := composite(open, "[]", 3, 0) + i := strings.Index(got, "[]") + if i < 0 { + t.Fatalf("the box is missing from the composited line: %q", got) + } + if !strings.HasSuffix(got[:i], ansi.ResetStyle) { + t.Errorf("the box is not preceded by a style reset, so the body's color bleeds into it: %q", got) + } +} + +// Past the end of a line ansi.Cut returns a zero-width escape pair rather than +// "", so the right-hand segment must be skipped by construction instead of +// being cut and discovered empty. +func TestCompositeAddsNoTrailingEscapes(t *testing.T) { + t.Parallel() + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + body := r.NewStyle().Foreground(lipgloss.Color("2")).Render("abcd") + got := composite(body, "XX", 2, 0) + if !strings.HasSuffix(got, "XX") { + t.Errorf("composite = %q, want it to end with the box, with nothing appended", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run TestComposite` +Expected: FAIL to build — `composite` is undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/tui/overlay.go`: + +```go +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// composite draws box over body with the box's top-left cell at (x, y) and +// returns the combined text. Cells of body outside the box's rectangle survive +// verbatim, styling included — that layering is the whole point of #59, and it +// is what separates an overlay from a screen that replaced another one. +// +// x and y are terminal cells, not bytes and not runes. ansi.Cut measures in +// cells and never splits an escape sequence, which is what makes this safe to +// run over output lipgloss has already styled. +// +// Three shapes need help. A body line shorter than x gains spaces, or the box +// slides left on that line alone. A body shorter than y+height gains blank +// lines, or the box is clipped at the bottom — the Home screen's body is three +// lines and the palette is ten. And a wide glyph straddling either edge gets +// dropped rather than kept, because half a glyph cannot be drawn and staying +// aligned matters more than one character under the box's border. +func composite(body, box string, x, y int) string { + if box == "" { + return body + } + x = max(x, 0) + y = max(y, 0) + + boxLines := strings.Split(box, "\n") + boxW := 0 + for _, l := range boxLines { + boxW = max(boxW, lipgloss.Width(l)) + } + + lines := strings.Split(body, "\n") + for len(lines) < y+len(boxLines) { + lines = append(lines, "") + } + + for i, bl := range boxLines { + row := lines[y+i] + rowW := lipgloss.Width(row) + + left := ansi.Cut(row, 0, x) + if pad := x - lipgloss.Width(left); pad > 0 { + left += strings.Repeat(" ", pad) + } + // ansi.Cut closes a style it had to reopen, but leaves an already-open, + // unterminated one open (measured against x/ansi v0.11.6). Without this + // reset such a line paints the box in its own color. + if strings.ContainsRune(left, '\x1b') { + left += ansi.ResetStyle + } + + // Skipped rather than cut-and-discarded when the box reaches the end of + // the line: past the end ansi.Cut returns a zero-width escape pair, not + // an empty string, and every composited line would collect one. + right := "" + if x+boxW < rowW { + right = ansi.Cut(row, x+boxW, rowW) + // A wide glyph straddling the box's right edge is KEPT WHOLE by Cut + // (the left edge drops it instead), which would start this segment a + // column early and push the line one cell too wide. Half a glyph + // cannot be drawn, so drop it and hand its column back as a space. + // At most one cluster can straddle, so one correction is enough. + if lipgloss.Width(left)+lipgloss.Width(bl)+lipgloss.Width(right) > rowW { + right = " " + ansi.Cut(row, x+boxW+1, rowW) + } + } + + lines[y+i] = left + bl + right + } + return strings.Join(lines, "\n") +} +``` + +- [ ] **Step 4: Promote x/ansi to a direct dependency** + +Run: `go mod tidy` +Expected: `github.com/charmbracelet/x/ansi v0.11.6` moves out of the `// indirect` block in +`go.mod`. No version changes, no new modules, `go.sum` unchanged. If `go mod tidy` wants to +add or bump anything else, stop and report it. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/tui/ -run TestComposite -v` +Expected: PASS, every test. + +- [ ] **Step 6: Verify three tests fail against the bug each one exists to catch** + +Run each break, capture the failing output, restore, and paste all three transcripts into +your report. + +1. Delete the `if pad := ...` block. + Run: `go test ./internal/tui/ -run TestCompositePadsShortBodyLines -v` → must FAIL. +2. Delete the `for len(lines) < y+len(boxLines)` loop. + Run: `go test ./internal/tui/ -run TestCompositeExtendsAShortBody -v` → must FAIL + (panic on the out-of-range index is an acceptable failure here — say so in the report). +3. Delete the `if strings.ContainsRune(left, '\x1b')` block. + Run: `go test ./internal/tui/ -run TestCompositeDoesNotLeakStyleIntoTheBox -v` → must FAIL. +4. Delete the `if lipgloss.Width(left)+...>rowW` straddle correction. + Run: `go test ./internal/tui/ -run TestCompositeHandlesWideGlyphsOnBothEdges -v` → must + FAIL, reporting 41 cells instead of 40. Then also run + `go test ./internal/tui/ -run TestCompositeKeepsEveryLineWidth -v` and confirm it still + PASSES with the bug present — that contrast is the point: the ASCII test cannot see + this, which is why the wide fixture exists. + +Restore the implementation and re-run `go test ./internal/tui/ -run TestComposite`. + +- [ ] **Step 7: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/tui/overlay.go internal/tui/overlay_test.go go.mod +git commit -m "feat(tui): add an ANSI-aware line compositor for overlays (#59)" +``` + +--- + +## Task 3: Extract the screen-opening methods + +**Files:** +- Create: `internal/tui/open.go` +- Create: `internal/tui/open_test.go` +- Modify: `internal/tui/report.go:92-125` (the `Export`, `Rates`, `LogHours`, `Filters`, + `OpenEntries` cases of `updateReport`) +- Modify: `internal/tui/home.go:54-87` (the `Range`, `LogHours`, `Members` cases of + `updateHome`) +- Modify: `internal/tui/entries.go:133-137` (`openEntries`' signature) +- Modify: `internal/tui/app.go:504` (`loadMembersCmd`'s doc comment) + +**Interfaces:** +- Consumes: nothing from Tasks 1-2. +- Produces: + - `func (m Model) openExport() Model` + - `func (m Model) openRates() Model` + - `func (m Model) openLog() Model` + - `func (m Model) openRange() Model` + - `func (m Model) openFilters() (Model, tea.Cmd)` + - `func (m Model) openMembers(origin screen) (Model, tea.Cmd)` + - `func (m Model) openEntries() (Model, tea.Cmd)` — **signature change**, was `Model` + +This is a behaviour-preserving refactor. Task 5's global actions call these methods, and +without the extraction they would have to copy the handler bodies — the second source of +truth the whole design avoids. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/tui/open_test.go`: + +```go +package tui + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/marcoarnulfo/clickup-cli/internal/clickup" + "github.com/marcoarnulfo/clickup-cli/internal/report" +) + +// openBase is a Model on the report screen with entries and a built report — +// the state every one of these methods is reached from in the real app. +func openBase() Model { + m := newTestModelOnReport() + m.report = goldenReport() + return m +} + +func TestOpenMethodsBuildTheirScreen(t *testing.T) { + t.Parallel() + t.Run("export", func(t *testing.T) { + t.Parallel() + m := openBase().openExport() + if m.screen != screenExport { + t.Errorf("screen = %v, want screenExport", m.screen) + } + if reflect.DeepEqual(m.export, exportModel{}) { + t.Error("openExport left the export sub-model at its zero value") + } + if len(m.nav) == 0 || m.nav[len(m.nav)-1] != screenReport { + t.Errorf("nav = %v, want screenReport on top so esc goes back", m.nav) + } + }) + t.Run("rates", func(t *testing.T) { + t.Parallel() + m := openBase().openRates() + if m.screen != screenRates { + t.Errorf("screen = %v, want screenRates", m.screen) + } + if reflect.DeepEqual(m.ratesScreen, ratesModel{}) { + t.Error("openRates left the rates sub-model at its zero value") + } + }) + t.Run("log", func(t *testing.T) { + t.Parallel() + m := openBase().openLog() + if m.screen != screenLog { + t.Errorf("screen = %v, want screenLog", m.screen) + } + if reflect.DeepEqual(m.logScreen, logModel{}) { + t.Error("openLog left the log sub-model at its zero value") + } + }) + t.Run("range", func(t *testing.T) { + t.Parallel() + // PresetLast7d, not the default: newRange sets only rm.idx, and + // this_month is rangePresets[0], so newRange(PresetThisMonth) IS the + // zero rangeModel (range.go:37-45). A zero-value assertion would fail + // against a perfectly correct openRange. + m := openBase() + m.preset = report.PresetLast7d + m = m.openRange() + if m.screen != screenRange { + t.Errorf("screen = %v, want screenRange", m.screen) + } + if m.rangeScreen.idx == 0 { + t.Error("openRange did not seed the cursor from m.preset") + } + }) + t.Run("entries", func(t *testing.T) { + t.Parallel() + m, _ := openBase().openEntries() + if m.screen != screenEntries { + t.Errorf("screen = %v, want screenEntries", m.screen) + } + }) +} + +// openEntries absorbed the lazy user fetch that used to live in updateReport's +// OpenEntries case. Without it here, the palette's "Go to entries" would have +// to copy that branch. +func TestOpenEntriesFetchesTheUserWhenUnknown(t *testing.T) { + t.Parallel() + m := openBase() + m.userID = 0 + if _, cmd := m.openEntries(); cmd == nil { + t.Error("openEntries returned no command with an unknown user; ownership gating stays off forever") + } + m.userID = 42 + if _, cmd := m.openEntries(); cmd != nil { + t.Error("openEntries re-fetched a user it already knows") + } +} + +func TestOpenFiltersOpensImmediatelyWhenStatusesAreCached(t *testing.T) { + t.Parallel() + m := openBase() + m.taskStatus = map[string]string{} + for _, e := range m.entries { + m.taskStatus[e.TaskID] = "open" + } + got, cmd := m.openFilters() + if got.screen != screenFilters { + t.Errorf("screen = %v, want screenFilters", got.screen) + } + if cmd != nil { + t.Error("openFilters issued an enrichment command although every status was cached") + } +} + +// openMembers takes an origin because that value decides where a load failure +// lands. Pinned to screenHome, a failure raised from the palette on another +// screen would teleport the user Home and blame a screen they were not on. +func TestOpenMembersRoutesFailuresBackToItsOrigin(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + m := openBase() + m.client = clickup.New("tok") + m.client.BaseURL = srv.URL + m.teamMembers = nil // force the fetch + + _, cmd := m.openMembers(screenRates) + if cmd == nil { + t.Fatal("openMembers issued no command with no members cached") + } + msg, ok := cmd().(retryableErrMsg) + if !ok { + t.Fatalf("command produced %T, want retryableErrMsg", cmd()) + } + if msg.origin != screenRates { + t.Errorf("origin = %v, want screenRates — the caller's screen, not a hardcoded Home", msg.origin) + } +} + +// The key handlers must go through the same methods, or the palette and the +// keyboard drift apart. +func TestReportAndHomeKeysReachTheSameScreens(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + key string + from func() Model + want screen + }{ + {"report export", "e", openBase, screenExport}, + {"report rates", "p", openBase, screenRates}, + {"report log hours", "n", openBase, screenLog}, + {"home range", "d", func() Model { m := newTestModel(); m.screen = screenHome; return m }, screenRange}, + {"home log hours", "n", func() Model { m := newTestModel(); m.screen = screenHome; return m }, screenLog}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := tc.from() + var got tea.Model + if m.screen == screenReport { + got, _ = m.updateReport(keyMsg(tc.key)) + } else { + got, _ = m.updateHome(keyMsg(tc.key)) + } + if s := got.(Model).screen; s != tc.want { + t.Errorf("screen = %v, want %v", s, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run 'TestOpen|TestReportAndHome'` +Expected: FAIL to build — `openExport`, `openRates`, `openLog`, `openRange`, `openFilters` +and `openMembers` are undefined, and `openEntries` returns one value. + +- [ ] **Step 3: Create `open.go`** + +```go +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// The screen-opening surface: every method here builds a screen's sub-model and +// navigates to it, so a key handler and the command palette (#71) open a screen +// the same way instead of each knowing how. +// +// Three older members of this family live elsewhere and stay there — moving +// them would inflate a diff for no behavioural gain: openListBrowser in app.go, +// openEntries in entries.go, openBudgetView in report.go. + +// openExport builds the export screen for the current report. +func (m Model) openExport() Model { + m.export = newExport(m.report) + return m.goTo(screenExport) +} + +// openRates builds the per-list/per-member rates editor. +func (m Model) openRates() Model { + m.ratesScreen = newRates(m.entries, m.cfg) + return m.goTo(screenRates) +} + +// openLog builds the hour-logging flow at its first step. +func (m Model) openLog() Model { + m.logScreen = newLog(m.entries, m.cfg) + return m.goTo(screenLog) +} + +// openRange builds the range/preset picker. +func (m Model) openRange() Model { + m.rangeScreen = newRange(m.preset) + return m.goTo(screenRange) +} + +// openFilters opens the client-side filter screen, enriching task statuses +// first when any are missing. The demo branch keeps the zero-I/O rule. +func (m Model) openFilters() (Model, tea.Cmd) { + missing := m.tasksMissingStatus() + if len(missing) == 0 { + m.assignStatuses() + m.filtersScreen = newFilters(m.entries, m.filterLists, m.filterTags, m.filterStatuses, m.filterBillable) + return m.goTo(screenFilters), nil + } + m.filtersScreen = filtersModel{loadingStatuses: true} + m = m.goTo(screenFilters) + if m.demo { + return m, demoStatusEnrichCmd(m.entries) + } + return m, statusEnrichCmd(m.client, missing) +} + +// openMembers opens the member selector, fetching the workspace roster when it +// is not cached yet. +// +// origin is where a load failure returns to. It is a parameter rather than a +// constant screenHome because the command palette can open this screen from +// anywhere: attributing a failure to Home while the user was on Rates would +// both lose their place and misname the culprit. +func (m Model) openMembers(origin screen) (Model, tea.Cmd) { + if len(m.teamMembers) > 0 { + m.membersScreen = newMembers(m.teamMembers, m.selectedMembers) + return m.goTo(screenMembers), nil + } + m.membersScreen = membersModel{loading: true} + m = m.goTo(screenMembers) + if m.demo { + return m, demoMembersCmd() + } + return m, loadMembersCmd(m.client, m.cfg.WorkspaceID, origin) +} +``` + +- [ ] **Step 4: Change `openEntries`' signature** + +In `internal/tui/entries.go:133-137`, replace the whole function with: + +```go +// openEntries opens the time-entry browser over the currently visible entries. +// +// It also issues the lazy currentUserCmd retry when the authenticated user is +// still unknown: ownership gating (which entries can be edited or deleted) +// stays off until that lands, and this is the only screen that needs it. That +// retry used to live in updateReport's OpenEntries case; it moved here so the +// command palette's "Go to entries" cannot forget it. +func (m Model) openEntries() (Model, tea.Cmd) { + m.entriesScreen = entriesModel{entries: sortEntriesByStartDesc(m.visibleEntries())} + m = m.goTo(screenEntries) + if m.userID == 0 { + return m, m.currentUserCmd() + } + return m, nil +} +``` + +- [ ] **Step 5: Point the handlers at the new methods** + +In `internal/tui/report.go`, inside `updateReport`, replace these five cases (match by +text, not by line number — earlier edits in this task shift them): + +```go + case key.Matches(msg, k.Export): + m = m.openExport() + case key.Matches(msg, k.Rates): + m = m.openRates() + case key.Matches(msg, k.LogHours): + m = m.openLog() + case key.Matches(msg, k.Filters): + return m.openFilters() + case key.Matches(msg, k.Budget): + if !m.openBudgetView() { + return m, nil + } + case key.Matches(msg, k.OpenEntries): + return m.openEntries() +``` + +`openFilters` and `openEntries` return `(Model, tea.Cmd)` while the enclosing function +returns `(tea.Model, tea.Cmd)`. Both forms compile — Go assigns each result individually +and `Model` satisfies `tea.Model` — so `return m.openFilters()` is fine and shorter. Use it. + +In `internal/tui/home.go`, inside `updateHome`, replace three cases: + +```go + case key.Matches(msg, k.Range): + m = m.openRange() + return m, nil + ... + case key.Matches(msg, k.LogHours): + m = m.openLog() + ... + case key.Matches(msg, k.Members): + mm, cmd := m.openMembers(screenHome) + return mm, cmd +``` + +Leave the `Timer` case alone: it builds a log screen in a *different* step +(`logTimerRunning`), so it is not the same open. + +Update `loadMembersCmd`'s doc comment in `internal/tui/app.go:504`: it says "It's Home-only +today, so origin is always screenHome at the call site", which stops being true here. +Replace that sentence with: "origin is the screen to return a failure to — Home for the +key binding, the caller's own screen when the command palette opens it." + +- [ ] **Step 6: Find every stale call site** + +Run: `go vet ./...` +Expected: the compiler names any remaining single-value use of `openEntries`. `go build +./...` alone will not point at test files; `go vet` does. Fix each one it names. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `go test ./internal/tui/ -run 'TestOpen|TestReportAndHome' -v` +Expected: PASS. + +Run: `go test ./... -race` +Expected: PASS. This refactor is behaviour-preserving, so the pre-existing suite is its +real safety net — a single failure here means the extraction changed something. + +- [ ] **Step 8: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/tui/open.go internal/tui/open_test.go internal/tui/report.go internal/tui/home.go internal/tui/entries.go internal/tui/app.go +git commit -m "refactor(tui): extract the screen-opening methods the palette will share" +``` + +--- + +## Task 4: Keymap plumbing + +**Files:** +- Modify: `internal/tui/app.go` (add `overlayKind` and two `Model` fields) +- Modify: `internal/tui/keys.go` (three new defaults, one new `keyMap` field, + `paletteKeys`, `paletteBindings`, the `keysFor`/`screenKeys` split) +- Modify: `internal/tui/keys_test.go` (extend the existing parity tests) +- Modify: `internal/tui/testdata/footer_*_full.golden` (regenerated) +- Modify — **the label-parity tests in eleven files**, see Step 6: + `keys_test.go`, `report_test.go`, `export_test.go`, `members_test.go`, `filters_test.go`, + `range_test.go`, `budget_test.go`, `log_test.go`, `rates_test.go`, `entries_test.go`, + `listbrowser_test.go` + +**Interfaces:** +- Consumes: nothing from Tasks 1-3. +- Produces: + - `type overlayKind int`, with `overlayNone` and `overlayPalette` + - `Model.overlay overlayKind` and `Model.palette paletteModel` — **`paletteModel` is + defined in Task 6.** For this task declare it as an empty placeholder struct in + `palette.go`; Task 6 fills it in. + - `func screenKeys(m Model) keyMap` — today's `keysFor` body, verbatim + - `func keysFor(m Model) keyMap` — the overlay-aware wrapper + - `func paletteKeys(d keyDefaults) keyMap` + - `func (k keyMap) paletteBindings() []key.Binding` + +At the end of this task `ctrl+p` is **advertised but inert**: nothing opens the palette +until Task 6. That is expected and is not a defect to report. + +- [ ] **Step 1: Add the overlay type and the Model fields** + +In `internal/tui/app.go`, after the `screen` constant block: + +```go +// overlayKind is the floating layer drawn over the current screen, orthogonal +// to m.screen: opening one does not touch m.nav and closing one is not a pop(). +// An overlay is not a place you navigated to. +// +// There are two values because there is one client. The third value arrives +// with the third client, not before. +type overlayKind int + +const ( + overlayNone overlayKind = iota + overlayPalette +) +``` + +Add to the `Model` struct, right after the `helpAll` field: + +```go + // overlay is the floating layer over m.screen, and palette is its state + // when overlay == overlayPalette (#71). While an overlay is open it owns + // the keyboard: see Update's tea.KeyMsg branch. + overlay overlayKind + palette paletteModel +``` + +Create `internal/tui/palette.go` with only the placeholder for now: + +```go +package tui + +// paletteModel is the command palette's state (#71). Task 6 fills it in. +type paletteModel struct{} +``` + +- [ ] **Step 2: Write the failing tests** + +Append to `internal/tui/keys_test.go`: + +```go +// The palette must not inherit d.Up/d.Down: those are "up"/"k" and "down"/"j", +// so typing j or k into the query would move the cursor instead of entering a +// letter. paletteKeys uses the arrow-only PaletteUp/PaletteDown defaults. +func TestPaletteKeysAreArrowOnly(t *testing.T) { + t.Parallel() + k := paletteKeys(defaultKeys()) + for _, tc := range []struct { + name string + binding key.Binding + }{ + {"up", k.Up}, + {"down", k.Down}, + } { + for _, bad := range []string{"j", "k"} { + if slices.Contains(tc.binding.Keys(), bad) { + t.Errorf("palette %s binding accepts %q, which the query needs as a character", tc.name, bad) + } + } + } +} + +// While the palette is open it owns the keyboard, so q must not quit and ? must +// not toggle help. A zero key.Binding has nil keys, which Enabled() reports as +// false and key.Matches never fires on. +func TestPaletteKeysLeaveQuitAndHelpUnassigned(t *testing.T) { + t.Parallel() + k := paletteKeys(defaultKeys()) + if k.Quit.Enabled() { + t.Error("the palette assigned Quit; q would close the program while typing") + } + if k.Help.Enabled() { + t.Error("the palette assigned Help; ? would toggle full help instead of filtering") + } +} + +// keysFor is what the footer reads, so it must switch to the palette's bindings +// while the overlay is open. screenKeys is what screenActions reads, and it must +// keep answering for the screen underneath — see the spec's section 5.2b. +func TestKeysForFollowsTheOverlayAndScreenKeysDoesNot(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + if !keysFor(m).GroupBy.Enabled() { + t.Fatal("the report screen lost its GroupBy binding; the fixture is wrong") + } + m.overlay = overlayPalette + if keysFor(m).GroupBy.Enabled() { + t.Error("keysFor still returns the report's bindings with the palette open") + } + if !screenKeys(m).GroupBy.Enabled() { + t.Error("screenKeys stopped answering for the screen underneath the overlay") + } +} + +// Every binding the palette offers must be replayable through routeKey, which +// means its first key round-trips through keyMsgFor. Anything else would build +// a KeyMsg whose String() does not match the binding, and the action would +// silently do nothing. +func TestEveryPaletteBindingIsReplayable(t *testing.T) { + t.Parallel() + for _, b := range defaultKeys().paletteDefaults() { + keys := b.Keys() + if len(keys) == 0 { + t.Errorf("a palette default has no keys: %+v", b.Help()) + continue + } + if _, ok := keyMsgFor(keys[0]); !ok { + t.Errorf("binding %q (%s) has a first key keyMsgFor cannot rebuild", keys[0], b.Help().Desc) + } + } +} +``` + +`paletteDefaults()` and `keyMsgFor` are named in the fourth test but `keyMsgFor` is built in +Task 5. Add the **first three** tests now — they compile against this task's work — and +leave `TestEveryPaletteBindingIsReplayable` to Task 5, which repeats its code. Note in your +report that you deferred it. + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run 'TestPaletteKeys|TestKeysForFollows'` +Expected: FAIL to build — `paletteKeys`, `screenKeys` and `overlayPalette` are undefined. + +- [ ] **Step 4: Add the three defaults and the keyMap field** + +In `internal/tui/keys.go`, add to `keyDefaults` (after the `Help` field): + +```go + // Palette opens the command palette (#71) and, while it is open, closes it + // again. Unlike Help its enablement varies by screen only in that three + // screens switch it off entirely: see keysFor. + Palette key.Binding + + // PaletteUp/PaletteDown are the palette's cursor keys, arrows ONLY. The + // generic Up/Down also accept k and j, which the palette needs as query + // characters — a filter you cannot type "kanban" into is not a filter. + PaletteUp key.Binding + PaletteDown key.Binding +``` + +In `defaultKeys()`: + +```go + Palette: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "command palette")), + PaletteUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "move up")), + PaletteDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "move down")), +``` + +Add **one** field to `keyMap`, next to `Help`: + +```go + Palette key.Binding +``` + +`PaletteUp`/`PaletteDown` deliberately get **no** `keyMap` field: `paletteKeys` assigns +them to the existing `Up`/`Down` fields. That keeps `keyMap`'s field count — and therefore +`TestAllBindingsCoversEveryField` — moving by exactly one. + +Add `k.Palette` to `allBindings()`, in the first group beside `k.Help`. + +- [ ] **Step 5: Split `keysFor` and add `paletteKeys` + `paletteBindings`** + +Rename today's `keysFor` to `screenKeys` (body unchanged) and add the wrapper above it: + +```go +// keysFor returns the bindings the given Model state accepts right now, and is +// what both Update and the footer read. +// +// An open overlay owns the keyboard, so it answers before the screen does. +// screenActions deliberately calls screenKeys instead: it derives the palette's +// own rows from the screen underneath, and asking keysFor there would hand it +// the palette's four keys and empty the list on the first keystroke. +func keysFor(m Model) keyMap { + if m.overlay == overlayPalette { + return paletteKeys(defaultKeys()) + } + return screenKeys(m) +} +``` + +In `screenKeys`, switch off `Palette` where it must not open — add this to the three +existing branches: + +- `screenSetup`: `setupKeys` never assigns `Palette` (the wizard must finish first). +- `screenLoading`: the inline `keyMap` literal never assigns `Palette` (a navigation while + an `entriesMsg` is in flight is undone when it lands). +- `screenError`: the inline `keyMap` literal never assigns `Palette` (any key returns Home). + +One more exclusion, inside `entriesKeys`: the `entriesConfirmDelete` branch must **not** +assign `Palette`. That mode's footer promises "any key cancel" (`anyKeyHelp`, +`keys.go:481-484`), and a `ctrl+p` that opened the palette would leave the pending delete +confirmation underneath while the footer said otherwise. `Help` is already unassigned there +for the same reason; `Palette` follows it. + +For **every other** per-screen constructor (`homeKeys`, `reportKeys`, `exportKeys`, +`ratesKeys`, `logKeys`, `membersKeys`, `rangeKeys`, `filtersKeys`, `listBrowserKeys`, +`budgetKeys`, and `entriesKeys`'s other branches), assign `Palette: d.Palette` in every +`keyMap` literal it returns, and append `k.Palette` to the **last group of `full` only**. +Do **not** put it in `short`: the `footer_*_short` goldens and +`TestShortFootersFitEightyColumns` must stay untouched. + +Add `paletteKeys` at the end of the file: + +```go +// paletteKeys is the binding set while the command palette is open (#71). It is +// not a screen: m.screen still names what is underneath, and this set simply +// takes precedence for as long as the overlay lives. +// +// Quit and Help stay unassigned, which is the second, independent guard on top +// of Update's ordering: even if the overlay check were moved below them, q and +// ? could not fire while the user is typing a query. +func paletteKeys(d keyDefaults) keyMap { + k := keyMap{ + Up: d.PaletteUp, Down: d.PaletteDown, + Confirm: d.Confirm, Back: d.Back, + Palette: d.Palette, ForceQuit: d.ForceQuit, + } + k.Confirm.SetHelp("enter", "run") + k.Back.SetHelp("esc", "close") + pair := pairHelp(k.Up, k.Down, "↑/↓", "move") + // ctrl+p closes as well as opens, but esc is the close every other screen + // already teaches, so only esc is advertised. + k.short = []key.Binding{pair, k.Confirm, k.Back, k.ForceQuit} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Back, k.ForceQuit}} + return k +} + +// paletteBindings is the subset of a screen's bindings the command palette +// offers as commands. It is allBindings' curated twin: cursor mechanics (move, +// confirm, toggle, switch section, go back) are not commands and would only +// bury the ones that are. +// +// Generate is here despite being Enter rather than a rune: it is the most-used +// action in the program and keyMsgFor rebuilds it exactly. Confirm is not, even +// though it shares that key — the selection is by field, not by keystroke. +func (k keyMap) paletteBindings() []key.Binding { + return []key.Binding{ + k.Generate, k.GroupBy, k.ChangeRange, k.Reload, k.Export, k.Rates, + k.Filters, k.Budget, k.OpenEntries, k.LogHours, k.Timer, k.Members, + k.Range, k.ToggleScope, k.ToggleWeek, k.PrevMonth, k.NextMonth, + k.ListCurrency, k.ListBudget, k.NewOverride, k.ClearValue, k.BrowseList, + k.Save, k.Delete, k.Edit, k.History, k.Tags, k.NewTag, k.StopTimer, + } +} + +// paletteDefaults mirrors paletteBindings over keyDefaults, so a test can check +// every palette-eligible key without having to reach a screen that enables it. +func (d keyDefaults) paletteDefaults() []key.Binding { + return keyMap{ + Generate: d.Generate, GroupBy: d.GroupBy, ChangeRange: d.ChangeRange, + Reload: d.Reload, Export: d.Export, Rates: d.Rates, Filters: d.Filters, + Budget: d.Budget, OpenEntries: d.OpenEntries, LogHours: d.LogHours, + Timer: d.Timer, Members: d.Members, Range: d.Range, + ToggleScope: d.ToggleScope, ToggleWeek: d.ToggleWeek, + PrevMonth: d.PrevMonth, NextMonth: d.NextMonth, + ListCurrency: d.ListCurrency, ListBudget: d.ListBudget, + NewOverride: d.NewOverride, ClearValue: d.ClearValue, + BrowseList: d.BrowseList, Save: d.Save, Delete: d.Delete, Edit: d.Edit, + History: d.History, Tags: d.Tags, NewTag: d.NewTag, StopTimer: d.StopTimer, + }.paletteBindings() +} +``` + +- [ ] **Step 6: Update the label-parity tests in eleven files** + +Adding `k.Palette` to `allBindings()` changes what `enabledLabels` collects +(`keys_test.go:16-26`): every screen where `Palette` is assigned now contributes +`"ctrl+p"`. Those tests compare with `slices.Equal` against exact `want` slices, so **about +twenty of them go red at once**. This is expected, mechanical, and yours to fix in this +task — not a surprise for the gate to discover. + +`enabledLabels` sorts alphabetically, so `"ctrl+p"` always lands **immediately after +`"ctrl+c"`**. Insert it there in every `want` list belonging to a screen or mode that now +assigns `Palette`. + +Run: `go test ./internal/tui/ 2>&1 | head -80` +Work through the failures. They live in: `keys_test.go` (`TestHomeKeyLabels`), +`report_test.go`, `export_test.go`, `members_test.go`, `filters_test.go`, `range_test.go` +(two cases), `budget_test.go`, `log_test.go` (a table over every log step), +`rates_test.go` (five cases), `entries_test.go` (seven cases), `listbrowser_test.go`. + +**Do not** add `"ctrl+p"` to the `want` list for setup, loading, error, or the +`entriesConfirmDelete` case. If one of those goes red, `Palette` reached a screen it must +not be on — fix `keys.go`, not the test. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `go test ./internal/tui/ -run 'TestPaletteKeys|TestKeysForFollows|TestAllBindings' -v` +Expected: PASS. + +Run: `go test ./internal/tui/` +Expected: PASS — every label-parity test included. + +- [ ] **Step 8: Verify the arrow-only test catches the bug it exists for** + +Temporarily change `paletteKeys` to `Up: d.Up, Down: d.Down`. +Run: `go test ./internal/tui/ -run TestPaletteKeysAreArrowOnly -v` +Expected: **FAIL**, naming both `j` and `k`. Paste the output into your report, then +restore `d.PaletteUp`/`d.PaletteDown`. + +- [ ] **Step 9: Regenerate the full-help footer goldens** + +Run: `go test ./internal/tui -update` +Then: `git diff --stat internal/tui/testdata/` + +Expected: **only** `footer_*_full.golden` files changed, each gaining `ctrl+p command +palette`. If any `footer_*_short.golden` changed, `Palette` reached a `short` slice — +remove it and regenerate. If `footer_setup_*`, `footer_loading_*` or `footer_error_*` +changed, `Palette` reached a screen it must not be on. + +Run: `go test ./internal/tui/ -run 'TestGoldenFooters|TestShortFootersFitEightyColumns'` +Expected: PASS. + +- [ ] **Step 10: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/tui/keys.go internal/tui/app.go internal/tui/palette.go internal/tui/testdata +git add internal/tui/keys_test.go internal/tui/report_test.go internal/tui/export_test.go \ + internal/tui/members_test.go internal/tui/filters_test.go internal/tui/range_test.go \ + internal/tui/budget_test.go internal/tui/log_test.go internal/tui/rates_test.go \ + internal/tui/entries_test.go internal/tui/listbrowser_test.go +git commit -m "feat(tui): add the palette keymap and split keysFor from screenKeys (#71)" +``` + +--- + +## Task 5: The action registry + +**Files:** +- Create: `internal/tui/actions.go` +- Create: `internal/tui/actions_test.go` +- Modify: `internal/tui/keys_test.go` (add `TestEveryPaletteBindingIsReplayable`, deferred + from Task 4 Step 2) + +**Interfaces:** +- Consumes: `screenKeys`, `paletteBindings`, `paletteDefaults` (Task 4); the `openX()` + methods (Task 3). +- Produces: + - `type action struct { label, hint string; run func(Model) (tea.Model, tea.Cmd) }` + - `func paletteActions(m Model) []action` + - `func keyMsgFor(s string) (tea.KeyMsg, bool)` + +- [ ] **Step 1: Write the failing tests** + +Create `internal/tui/actions_test.go`: + +```go +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/marcoarnulfo/clickup-cli/internal/clickup" +) + +func labels(as []action) []string { + out := make([]string, len(as)) + for i, a := range as { + out[i] = a.label + } + return out +} + +func hasLabel(as []action, want string) bool { + for _, a := range as { + if a.label == want { + return true + } + } + return false +} + +func TestKeyMsgFor(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + wantOK bool + want string + }{ + {"g", true, "g"}, + {"enter", true, "enter"}, + // PrevMonth and NextMonth are "left"/"h" and "right"/"l", so their + // first key is not a rune. Without these two the palette would silently + // drop both month-navigation actions on Home. + {"left", true, "left"}, + {"right", true, "right"}, + {"tab", false, ""}, + {"shift+tab", false, ""}, + {"up", false, ""}, + {"", false, ""}, + } { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + msg, ok := keyMsgFor(tc.in) + if ok != tc.wantOK { + t.Fatalf("keyMsgFor(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) + } + // The round trip is the whole point: key.Matches compares + // msg.String() against the binding's key strings. + if ok && msg.String() != tc.want { + t.Errorf("keyMsgFor(%q).String() = %q, want %q", tc.in, msg.String(), tc.want) + } + }) + } +} + +func TestScreenActionsComeFromTheScreenKeymap(t *testing.T) { + t.Parallel() + got := labels(screenActions(newTestModelOnReport())) + for _, want := range []string{"Grouping", "Export", "Budgets", "Filters", "Entries"} { + if !hasLabel(screenActions(newTestModelOnReport()), want) { + t.Errorf("the report screen offers no %q action; got %v", want, got) + } + } + // Cursor mechanics are not commands. + for _, unwanted := range []string{"Move up", "Move down", "Confirm", "Back", "Help", "Quit"} { + if hasLabel(screenActions(newTestModelOnReport()), unwanted) { + t.Errorf("%q leaked into the palette; it is cursor mechanics, not a command", unwanted) + } + } +} + +// A disabled binding must not appear. This is what makes the palette's gating +// free: the same Enabled() that decides the footer decides the palette. +func TestScreenActionsRespectEnablement(t *testing.T) { + t.Parallel() + m := newTestModel() + m.screen = screenHome + m.scope = "me" + if hasLabel(screenActions(m), "Select members") { + t.Error("the members action appears in the me scope, where its binding is disabled") + } + m.scope = "team" + if !hasLabel(screenActions(m), "Select members") { + t.Error("the members action is missing in the team scope, where its binding is enabled") + } +} + +// Labels are the footer's own words with the first rune upper-cased, so the two +// surfaces cannot drift. ToUpper on the first BYTE would corrupt any label that +// starts with a multibyte rune. +func TestCapitalizeHandlesMultibyte(t *testing.T) { + t.Parallel() + if got := capitalize("èxport"); got != "Èxport" { + t.Errorf("capitalize(%q) = %q, want %q", "èxport", got, "Èxport") + } + if got := capitalize(""); got != "" { + t.Errorf("capitalize(%q) = %q, want %q", "", got, "") + } +} + +func TestGlobalActionsAreGated(t *testing.T) { + t.Parallel() + m := newTestModel() + m.screen = screenHome + m.entries = nil + for _, unwanted := range []string{"Go to report", "Go to budgets", "Go to filters", "Go to entries", "Go to export"} { + if hasLabel(globalActions(m), unwanted) { + t.Errorf("%q is offered with no entries loaded; there is no report to show", unwanted) + } + } + m.entries = goldenEntries() + for _, want := range []string{"Go to report", "Go to budgets", "Go to filters", "Go to entries", "Go to export"} { + if !hasLabel(globalActions(m), want) { + t.Errorf("%q is missing although entries are loaded", want) + } + } +} + +func TestGlobalActionsSkipTheCurrentScreen(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + if hasLabel(globalActions(m), "Go to report") { + t.Error(`"Go to report" is offered while already on the report screen`) + } + if !hasLabel(globalActions(m), "Go to rates") { + t.Error(`"Go to rates" is missing from the report screen`) + } +} + +func TestGlobalMembersActionIsTeamOnly(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.scope = "me" + if hasLabel(globalActions(m), "Go to members") { + t.Error(`"Go to members" is offered in the me scope`) + } + m.scope = "team" + if !hasLabel(globalActions(m), "Go to members") { + t.Error(`"Go to members" is missing in the team scope`) + } +} + +// Screen actions come first: "what can I do here" before "take me elsewhere". +func TestPaletteActionsPutScreenActionsFirst(t *testing.T) { + t.Parallel() + got := labels(paletteActions(newTestModelOnReport())) + firstGlobal := -1 + for i, l := range got { + if strings.HasPrefix(l, "Go to ") { + firstGlobal = i + break + } + } + if firstGlobal <= 0 { + t.Fatalf("expected screen actions before the first navigation row; got %v", got) + } + // Every global row is a "Go to …" except Quit, which is appended last. + for _, l := range got[firstGlobal:] { + if !strings.HasPrefix(l, "Go to ") && l != "Quit" { + t.Errorf("screen action %q appears after the navigation block: %v", l, got) + } + } +} + +// A screen action must do exactly what pressing its key does. This is the whole +// justification for deriving them instead of writing a second registry. +func TestScreenActionRunMatchesTheKeypress(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.report = goldenReport() + + var run func(Model) (tea.Model, tea.Cmd) + for _, a := range screenActions(m) { + if a.label == "Export" { + run = a.run + } + } + if run == nil { + t.Fatal(`no "Export" action on the report screen`) + } + viaAction, _ := run(m) + viaKey, _ := m.updateReport(keyMsg("e")) + if viaAction.(Model).screen != viaKey.(Model).screen { + t.Errorf("action landed on %v, the keypress on %v", + viaAction.(Model).screen, viaKey.(Model).screen) + } + if viaAction.(Model).screen != screenExport { + t.Errorf("screen = %v, want screenExport", viaAction.(Model).screen) + } +} + +// Quit is the one global action that is not navigation, so it is appended +// outside the target-skipping loop rather than given a fake target screen. +func TestQuitActionIsAlwaysOffered(t *testing.T) { + t.Parallel() + for _, s := range []screen{screenHome, screenReport, screenRates} { + m := newTestModelOnReport() + m.screen = s + if !hasLabel(globalActions(m), "Quit") { + t.Errorf("Quit is missing on %v", s) + } + } +} + +func TestGlobalMembersActionUsesTheCallersScreenAsOrigin(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.scope = "team" + m.screen = screenRates + m.teamMembers = nil + m.client = clickup.New("tok") + m.client.BaseURL = "http://127.0.0.1:1" // refused instantly, no test server needed + + var run func(Model) (tea.Model, tea.Cmd) + for _, a := range globalActions(m) { + if a.label == "Go to members" { + run = a.run + } + } + if run == nil { + t.Fatal(`no "Go to members" action`) + } + _, cmd := run(m) + if cmd == nil { + t.Fatal("no command issued with no members cached") + } + msg, ok := cmd().(retryableErrMsg) + if !ok { + t.Fatalf("command produced %T, want retryableErrMsg", cmd()) + } + if msg.origin != screenRates { + t.Errorf("origin = %v, want screenRates", msg.origin) + } +} +``` + +Also add `TestEveryPaletteBindingIsReplayable` to `internal/tui/keys_test.go` — the exact +code is in Task 4, Step 2. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run 'TestKeyMsgFor|TestScreenActions|TestGlobal|TestPaletteActions|TestCapitalize|TestQuitAction|TestEveryPaletteBinding'` +Expected: FAIL to build — nothing in `actions.go` exists yet. + +- [ ] **Step 3: Write the implementation** + +Create `internal/tui/actions.go`: + +```go +package tui + +import ( + "unicode" + + tea "github.com/charmbracelet/bubbletea" +) + +// action is one row of the command palette (#71). +// +// run returns tea.Model rather than Model on purpose: that is routeKey's own +// signature, so a screen action is a direct forward with no type assertion in +// between. +type action struct { + label string // "Export", "Go to rates" + hint string // the key that also does this, "" for a navigation row + run func(Model) (tea.Model, tea.Cmd) +} + +// paletteActions is everything ctrl+p offers right now: the current screen's +// commands first, then the cross-screen navigation. With an empty query every +// score is 0, so this order survives into the rendered list — "what can I do +// here" above "take me elsewhere". +func paletteActions(m Model) []action { + screen := screenActions(m) + global := globalActions(m) + out := make([]action, 0, len(screen)+len(global)) + out = append(out, screen...) + return append(out, global...) +} + +// screenActions derives the current screen's commands from its keymap rather +// than from a second list that would have to be kept in step with it. +// +// Three things fall out of that for free: the gating (a disabled binding simply +// is not here), the wording (the label is the footer's own description, so the +// two surfaces cannot drift), and the behaviour (run replays the key, so there +// is no duplicated handler to diverge). +// +// screenKeys, not keysFor: with the palette open keysFor answers for the +// palette, and this list would empty itself on the first keystroke. +func screenActions(m Model) []action { + var out []action + for _, b := range screenKeys(m).paletteBindings() { + if !b.Enabled() { + continue + } + keys := b.Keys() + if len(keys) == 0 { + continue + } + msg, ok := keyMsgFor(keys[0]) + if !ok { + // Unreachable today, pinned by TestEveryPaletteBindingIsReplayable. + // Dropping beats firing a KeyMsg that matches nothing. + continue + } + out = append(out, action{ + label: capitalize(b.Help().Desc), + hint: b.Help().Key, + run: func(m Model) (tea.Model, tea.Cmd) { return m.routeKey(msg) }, + }) + } + return out +} + +// globalActions is the cross-screen navigation ctrl+p offers from anywhere. +// +// The rule this list obeys, and the reason it is short: global actions are +// navigation only. Anything that changes state stays a screen action, where the +// footer teaches it and the palette repeats it. +// +// Each row opens its screen through the same openX method the key handler uses +// (see open.go), so the two can never build a screen differently. +func globalActions(m Model) []action { + hasReport := len(m.entries) > 0 + rows := []struct { + label string + target screen + enabled bool + run func(Model) (tea.Model, tea.Cmd) + }{ + {"Go to home", screenHome, true, func(m Model) (tea.Model, tea.Cmd) { + return m.resetTo(screenHome), nil + }}, + {"Go to report", screenReport, hasReport, func(m Model) (tea.Model, tea.Cmd) { + return m.goTo(screenReport), nil + }}, + {"Go to rates", screenRates, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openRates(), nil + }}, + {"Go to budgets", screenBudget, hasReport, func(m Model) (tea.Model, tea.Cmd) { + // A false return means the config's pricing or timezone failed to + // parse, and openBudgetView has already routed to screenError — + // which is the right landing either way, so there is nothing to add. + m.openBudgetView() + return m, nil + }}, + {"Go to filters", screenFilters, hasReport, func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openFilters() + return mm, cmd + }}, + {"Go to entries", screenEntries, hasReport, func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openEntries() + return mm, cmd + }}, + {"Go to export", screenExport, hasReport, func(m Model) (tea.Model, tea.Cmd) { + return m.openExport(), nil + }}, + {"Go to range", screenRange, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openRange(), nil + }}, + {"Go to members", screenMembers, m.scope == "team", func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openMembers(m.screen) + return mm, cmd + }}, + // "Go to log hours", not "Log hours": on Home and Report the screen + // keymap already yields an action labelled exactly "Log hours", and two + // identical rows in one list read as a bug rather than as the legible + // do-it-here / take-me-there pair the other duplicates form. + {"Go to log hours", screenLog, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openLog(), nil + }}, + } + + var out []action + for _, r := range rows { + if !r.enabled || r.target == m.screen { + continue + } + out = append(out, action{label: r.label, run: r.run}) + } + // Quit is the one row that is not navigation, so it has no target screen to + // compare against and is appended rather than given a fake one. + return append(out, action{label: "Quit", run: func(m Model) (tea.Model, tea.Cmd) { + return m, tea.Quit + }}) +} + +// keyMsgFor rebuilds the tea.KeyMsg a binding's first key would produce. +// +// The set is closed on purpose. key.Matches compares msg.String() against the +// binding's key strings, and these four round-trip exactly. A KeyRunes message +// carrying "tab" would round-trip too, but every handler that reads msg.Type +// rather than key.Matches would then see the wrong thing — so the palette +// leaves those bindings out instead of forging their messages. ok is false for +// anything else, so an action that cannot be executed faithfully is dropped +// rather than mis-fired. +func keyMsgFor(s string) (tea.KeyMsg, bool) { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter}, true + // PrevMonth and NextMonth lead with "left" and "right" (keys.go:124-125), + // and they are the only two palette bindings whose first key is not a rune. + case "left": + return tea.KeyMsg{Type: tea.KeyLeft}, true + case "right": + return tea.KeyMsg{Type: tea.KeyRight}, true + } + if r := []rune(s); len(r) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: r}, true + } + return tea.KeyMsg{}, false +} + +// capitalize upper-cases the first rune. strings.ToUpper on s[:1] would corrupt +// any label whose first character is multibyte. +func capitalize(s string) string { + if s == "" { + return "" + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/tui/ -run 'TestKeyMsgFor|TestScreenActions|TestGlobal|TestPaletteActions|TestCapitalize|TestQuitAction|TestEveryPaletteBinding' -v` +Expected: PASS. + +If `TestScreenActionsComeFromTheScreenKeymap` fails on a label, read the failure before +changing the test: the expected strings are `capitalize()` applied to the help +descriptions in `defaultKeys()` (`"grouping"` → `"Grouping"`, `"export"` → `"Export"`, +`"budgets"` → `"Budgets"`, `"filters"` → `"Filters"`, `"entries"` → `"Entries"`, +`"select members"` → `"Select members"`). If a description has changed since this plan was +written, fix the test to the real string and say so in your report. + +- [ ] **Step 5: Verify the screenKeys choice is load-bearing** + +Temporarily change `screenActions` to call `keysFor(m)` instead of `screenKeys(m)`, and add +this temporary check to confirm the difference is real: + +```go + m := newTestModelOnReport() + m.overlay = overlayPalette + if len(screenActions(m)) == 0 { + t.Fatal("screenActions is empty with the overlay open") + } +``` + +Run it: with `keysFor` it must FAIL (empty list); with `screenKeys` it must PASS. Paste +both outputs into your report, then remove the temporary check — Task 6 covers this +permanently with `TestPaletteKeepsScreenActionsWhileTyping`. + +- [ ] **Step 6: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/tui/actions.go internal/tui/actions_test.go internal/tui/keys_test.go +git commit -m "feat(tui): derive the palette's actions from the keymap and the openX methods (#71)" +``` + +--- + +## Task 6: The palette itself + +**Files:** +- Modify: `internal/tui/palette.go` (replace the Task 4 placeholder with the real model) +- Create: `internal/tui/palette_test.go` +- Modify: `internal/tui/app.go` (the `tea.KeyMsg` branch of `Update`, and `View()`) +- Modify: `internal/tui/nav.go` (every navigation dissolves an open overlay) +- Create: `internal/tui/testdata/palette_report.golden`, `palette_filtered.golden`, + `palette_no_match.golden`, `palette_narrow.golden` + +**Interfaces:** +- Consumes: `fuzzy.Match` (Task 1); `composite` (Task 2); `paletteActions` (Task 5); + `paletteKeys`, `overlayPalette` (Task 4). +- Produces: `func (m Model) openPalette() Model`, + `func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd)`, + `func (p paletteModel) layout(th theme, width, height, bodyLines int) (box string, x, y int)`. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/tui/palette_test.go`: + +```go +package tui + +import ( + "errors" + "io" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/marcoarnulfo/clickup-cli/internal/clickup" + "github.com/marcoarnulfo/clickup-cli/internal/fuzzy" +) + +func openPaletteOn(m Model) Model { return m.openPalette() } + +func typeInto(m Model, s string) Model { + for _, r := range s { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + if r == ' ' { + msg = tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}} + } + got, _ := m.updateOverlay(msg) + m = got.(Model) + } + return m +} + +func TestPaletteOpensAndClosesWithoutTouchingNav(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + before := append([]screen(nil), m.nav...) + + m = openPaletteOn(m) + if m.overlay != overlayPalette { + t.Fatal("ctrl+p did not open the palette") + } + if m.screen != screenReport { + t.Errorf("screen = %v, want screenReport — an overlay is not a place you navigated to", m.screen) + } + if len(m.nav) != len(before) { + t.Errorf("nav = %v, want %v unchanged", m.nav, before) + } + + got, _ := m.updateOverlay(keyMsg("esc")) + m = got.(Model) + if m.overlay != overlayNone { + t.Error("esc did not close the palette") + } + if m.screen != screenReport || len(m.nav) != len(before) { + t.Errorf("closing changed the screen or nav: screen=%v nav=%v", m.screen, m.nav) + } +} + +// A regression test, NOT a red-green one. Moving the overlay check below the +// Quit check does not break it: with the palette open, keysFor answers with +// paletteKeys, where Quit is unassigned, so q reaches the query either way. +// The ordering's real teeth are in TestPaletteCtrlPClosesRatherThanReopening. +func TestPaletteQueryAcceptsQ(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd != nil { + t.Fatal("typing q issued a command; the quit path fired inside the palette") + } + if q := got.(Model).palette.query; q != "q" { + t.Errorf("query = %q, want %q", q, "q") + } +} + +// Same shape for ?: inside the palette it is a character, not the help toggle. +func TestPaletteQueryAcceptsQuestionMark(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + if got.(Model).helpAll { + t.Error("? toggled full help while the palette was open") + } + if q := got.(Model).palette.query; q != "?" { + t.Errorf("query = %q, want %q", q, "?") + } +} + +// THIS is what makes Update's ordering load-bearing. paletteKeys assigns +// Palette (ctrl+p has to close), so with the overlay check below the Palette +// check, a ctrl+p would call openPalette a second time and wipe the query +// instead of closing. +func TestPaletteCtrlPClosesRatherThanReopening(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "exp") + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + after := got.(Model) + if after.overlay != overlayNone { + t.Errorf("ctrl+p left the overlay open with query %q; it reopened instead of closing", after.palette.query) + } +} + +// An overlay belongs to the screen it was raised over. A 401 relaunches the +// setup wizard, and a palette that survived would be drawn over — and would own +// the keyboard on — the one screen ctrl+p is not allowed on at all. +func TestPaletteDissolvesWhenAnErrorSwapsTheScreen(t *testing.T) { + t.Parallel() + t.Run("401 relaunches setup", func(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(errMsg{err: clickup.ErrUnauthorized}) + after := got.(Model) + if after.screen != screenSetup { + t.Fatalf("screen = %v, want screenSetup", after.screen) + } + if after.overlay != overlayNone { + t.Error("the palette survived onto the setup wizard") + } + }) + t.Run("a retryable error lands on the error screen", func(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(retryableErrMsg{origin: screenRates, err: errors.New("boom")}) + after := got.(Model) + if after.screen != screenError { + t.Fatalf("screen = %v, want screenError", after.screen) + } + // View returns early for screenError, so a surviving palette would be + // invisible AND would swallow the "press a key to return home" keypress. + if after.overlay != overlayNone { + t.Error("the palette survived onto the error screen, where it is invisible but still eats keys") + } + }) +} + +// The box is positioned in body coordinates, but bubbletea keeps the LAST +// height lines of an overflowing view. Without the correction the box scrolls +// off the top of a long report. +func TestPaletteBoxStaysVisibleUnderALongBody(t *testing.T) { + t.Parallel() + p := openPaletteOn(newTestModelOnReport()).palette + _, _, shortY := p.layout(testTheme(true), 90, 30, 10) + if shortY != paletteTopY { + t.Errorf("y = %d on a body that fits, want %d", shortY, paletteTopY) + } + _, _, longY := p.layout(testTheme(true), 90, 30, 60) + if longY <= shortY { + t.Errorf("y = %d on a 60-line body in a 30-row terminal, want more than %d — the box would be scrolled away", longY, shortY) + } + // The box must land inside the window bubbletea will actually show. + if longY < 60+2-30 { + t.Errorf("y = %d is still above the visible window, which starts at body line %d", longY, 60+2-30) + } +} + +// j and k are cursor keys everywhere else in this program. In a query they are +// letters, and a filter you cannot type "kanban" into is not a filter. +func TestPaletteTypesJAndK(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "jk") + if q := m.palette.query; q != "jk" { + t.Errorf("query = %q, want %q — j/k moved the cursor instead of typing", q, "jk") + } +} + +// bubbletea reclassifies a lone space as KeySpace while leaving it in Runes. +// Without a branch for that type the query could never contain a space. +func TestPaletteQueryAcceptsSpace(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "log h") + if q := m.palette.query; q != "log h" { + t.Errorf("query = %q, want %q", q, "log h") + } + if len(m.palette.items) == 0 { + t.Error(`"log h" matched nothing; the space broke the filter`) + } +} + +// The permanent form of Task 5's temporary check: screenActions must keep +// answering for the screen underneath once the overlay owns keysFor. +func TestPaletteKeepsScreenActionsWhileTyping(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "e") + for _, it := range m.palette.items { + if it.a.hint != "" { + return // a screen action survived; that is all this test needs + } + } + t.Errorf("every row lost its key hint, so no screen action survived typing: %v", m.palette.items) +} + +func TestPaletteBackspace(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "exp") + got, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyBackspace}) + m = got.(Model) + if m.palette.query != "ex" { + t.Errorf("query = %q, want %q", m.palette.query, "ex") + } + m.palette.query = "" + got, _ = m.updateOverlay(tea.KeyMsg{Type: tea.KeyBackspace}) + if q := got.(Model).palette.query; q != "" { + t.Errorf("backspace on an empty query produced %q", q) + } +} + +func TestPaletteCursorClampsAndScrolls(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.height = 24 + if len(m.palette.items) <= paletteMaxRows { + t.Fatalf("the fixture has only %d actions; this test needs more than %d", len(m.palette.items), paletteMaxRows) + } + + up, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyUp}) + if i := up.(Model).palette.idx; i != 0 { + t.Errorf("idx = %d after up at the top, want 0", i) + } + + for range len(m.palette.items) + 3 { + got, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyDown}) + m = got.(Model) + } + if i, n := m.palette.idx, len(m.palette.items); i != n-1 { + t.Errorf("idx = %d, want %d — down ran past the last row", i, n-1) + } + rows := paletteRows(m.height) + if m.palette.idx < m.palette.top || m.palette.idx >= m.palette.top+rows { + t.Errorf("cursor %d is outside the visible window [%d, %d)", m.palette.idx, m.palette.top, m.palette.top+rows) + } +} + +func TestPaletteEnterRunsTheSelectedActionAndCloses(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.report = goldenReport() + m = typeInto(openPaletteOn(m), "export") + + got, _ := m.updateOverlay(keyMsg("enter")) + after := got.(Model) + if after.overlay != overlayNone { + t.Error("the palette stayed open after running an action; it would render over the new screen") + } + if after.screen != screenExport { + t.Errorf("screen = %v, want screenExport", after.screen) + } +} + +func TestPaletteEnterOnAnEmptyListDoesNothing(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "zzzzzz") + if len(m.palette.items) != 0 { + t.Fatalf("expected no matches for %q, got %d", "zzzzzz", len(m.palette.items)) + } + got, cmd := m.updateOverlay(keyMsg("enter")) + if cmd != nil { + t.Error("enter on an empty list issued a command") + } + if got.(Model).screen != screenReport { + t.Errorf("screen = %v, want screenReport unchanged", got.(Model).screen) + } +} + +func TestPaletteDoesNotOpenOnSetupLoadingOrError(t *testing.T) { + t.Parallel() + for _, s := range []screen{screenSetup, screenLoading, screenError} { + m := newTestModel() + m.screen = s + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + if got.(Model).overlay != overlayNone { + t.Errorf("ctrl+p opened the palette on %v", s) + } + } +} + +func TestPaletteBoxIsExactlyItsWidth(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.width, m.height = 100, 30 + box, x, y := m.palette.layout(testTheme(true), m.width, m.height, 12) + if y != paletteTopY { + t.Errorf("y = %d, want %d", y, paletteTopY) + } + lines := strings.Split(box, "\n") + want := lipgloss.Width(lines[0]) + for i, l := range lines { + if w := lipgloss.Width(l); w != want { + t.Errorf("box line %d is %d cells, want %d: %q", i, w, want, l) + } + } + if x+want > m.width { + t.Errorf("the box overflows: x=%d width=%d terminal=%d", x, want, m.width) + } +} + +// colorTheme builds a theme on a renderer with a real color profile. testTheme +// pins termenv.Ascii, which strips every escape, so a style assertion made +// through it cannot fail no matter what highlight does. +func colorTheme() theme { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + r.SetHasDarkBackground(true) + return newTheme(r, defaultPalette()) +} + +func TestPaletteHighlightUsesTheAccentStyle(t *testing.T) { + t.Parallel() + th := colorTheme() + if probe := th.Accent.Render("x"); !strings.Contains(probe, "\x1b[") { + t.Fatalf("the accent style renders no escape (%q); this test would pass vacuously", probe) + } + got := highlight(th, "export", []int{0, 1, 2}) + if w := lipgloss.Width(got); w != 6 { + t.Errorf("highlight changed the label's width: %d cells, %q", w, got) + } + if !strings.HasPrefix(got, th.Accent.Render("exp")) { + t.Errorf("the matched prefix is not rendered in the accent style: %q", got) + } + if !strings.HasSuffix(got, th.Cell.Render("ort")) { + t.Errorf("the unmatched tail picked up the accent style: %q", got) + } +} + +// fuzzy.Match indexes the FULL label, so a truncated label must drop the +// indices that fell off — and the ellipsis must never light up. +// +// The query is chosen so EVERY match lands past the cut. A query matching the +// first few runes would leave the drop branch unexercised, and the test would +// pass against an implementation that never drops anything. +func TestPaletteHighlightSurvivesTruncation(t *testing.T) { + t.Parallel() + th := colorTheme() + label := "abcdefghijklmnopqrstuvwxyz0123456789" + _, idx, ok := fuzzy.Match("z9", label) + if !ok { + t.Fatal(`Match("z9", label) did not match`) + } + for _, i := range idx { + if i < 20 { + t.Fatalf("idx = %v, but this test needs every match beyond column 20", idx) + } + } + + short := shaveToWidth(label, 20) + got := highlight(th, short, idx) + if w := lipgloss.Width(got); w != 20 { + t.Errorf("highlighted label is %d cells, want 20: %q", w, got) + } + // Every match fell outside the cut, so nothing may be accented — and that + // includes the ellipsis shaveToWidth appended. + if want := th.Cell.Render(short); got != want { + t.Errorf("highlight kept indices past the truncation:\n got %q\nwant %q", got, want) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/tui/ -run TestPalette` +Expected: FAIL to build — `openPalette`, `updateOverlay`, `layout`, `paletteRows`, +`paletteMaxRows`, `paletteTopY` and `highlight` are undefined. + +- [ ] **Step 3: Write `palette.go`** + +Replace the placeholder file entirely: + +```go +package tui + +import ( + "slices" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/marcoarnulfo/clickup-cli/internal/fuzzy" +) + +// Geometry. See the spec's section 8.2. +const ( + paletteWidth = 52 // preferred box width, borders included + paletteMinWidth = 24 // never narrower, even on a narrow terminal + paletteMaxRows = 8 // action rows visible at once + paletteChrome = 4 // top border + query row + separator + bottom border + paletteTopY = 2 // body row the top border sits on + paletteTitle = "Command palette" +) + +// paletteItem is one filtered row: the action, the rune indices fuzzy.Match +// hit (which drive the highlighting, not just the ranking) and its score. +type paletteItem struct { + a action + idx []int + score int +} + +// paletteModel is the command palette's state (#71). +// +// The query is a plain string with append and backspace rather than a +// textinput.Model: that type derives its styles from lipgloss's default +// renderer — the same reason footer.go refuses help.New() — and would add three +// fields of editing state a twenty-item filter never uses. +// +// There is no cached copy of every action. refreshPalette rebuilds from +// paletteActions on each keystroke: a fuzzy match over twenty short strings +// costs nothing, and a copy that is never kept cannot go stale. +type paletteModel struct { + query string + items []paletteItem + idx int // selected row + top int // first visible row +} + +// openPalette raises the overlay. It does not touch m.nav: an overlay is not a +// place you navigated to, so closing it is not a pop(). +// +// helpAll is cleared because the expanded footer is several lines tall, and +// paletteRows subtracts a fixed two rows for the blank line and the footer. +// Dismissing it is also right on its own terms: the palette and full help +// answer the same question, and showing both at once answers it twice. +func (m Model) openPalette() Model { + m.overlay = overlayPalette + m.helpAll = false + m.palette = paletteModel{} + return m.refreshPalette() +} + +func (m Model) closePalette() Model { + m.overlay = overlayNone + m.palette = paletteModel{} + return m +} + +// refreshPalette rebuilds the filtered, ranked list for the current query. +func (m Model) refreshPalette() Model { + all := paletteActions(m) + items := make([]paletteItem, 0, len(all)) + for _, a := range all { + score, idx, ok := fuzzy.Match(m.palette.query, a.label) + if !ok { + continue + } + items = append(items, paletteItem{a: a, idx: idx, score: score}) + } + // Stable, so an empty query (every score 0) keeps paletteActions' order: + // screen commands above the navigation rows. + slices.SortStableFunc(items, func(a, b paletteItem) int { return b.score - a.score }) + m.palette.items = items + m.palette.idx = 0 + m.palette.top = 0 + return m +} + +// updateOverlay handles every key while an overlay is open. Update routes here +// before it checks Quit, so nothing below can be reached by a query character. +func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + k := keysFor(m) // == paletteKeys: the handler and the footer read one source + switch { + case key.Matches(msg, k.Back), key.Matches(msg, k.Palette): + return m.closePalette(), nil + + case key.Matches(msg, k.Confirm): + if len(m.palette.items) == 0 { + return m, nil + } + run := m.palette.items[m.palette.idx].a.run + // Close FIRST: the action changes screen, and a palette still raised + // would be composited over the screen it just opened. + return run(m.closePalette()) + + case key.Matches(msg, k.Up): + if m.palette.idx > 0 { + m.palette.idx-- + } + m.palette = scrollPalette(m.palette, paletteRows(m.height)) + return m, nil + + case key.Matches(msg, k.Down): + if m.palette.idx < len(m.palette.items)-1 { + m.palette.idx++ + } + m.palette = scrollPalette(m.palette, paletteRows(m.height)) + return m, nil + + // Backspace is text editing, which none of the ten textinput screens + // advertises either, so it is a type check rather than a keymap binding. + case msg.Type == tea.KeyBackspace: + if r := []rune(m.palette.query); len(r) > 0 { + m.palette.query = string(r[:len(r)-1]) + m = m.refreshPalette() + } + return m, nil + } + + // bubbletea reclassifies a lone space as KeySpace while still carrying it in + // Runes (key.go:698-702), so a query could not contain a space without this + // second type. Alt-modified runes are chords, not text. + if (msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) && !msg.Alt { + m.palette.query += string(msg.Runes) + m = m.refreshPalette() + } + return m, nil +} + +// scrollPalette moves the visible window so idx is always inside it. This is the +// scrolling the Filters screen still lacks (#28); the palette gets it right from +// the start. +func scrollPalette(p paletteModel, rows int) paletteModel { + if p.idx < p.top { + p.top = p.idx + } + if p.idx >= p.top+rows { + p.top = p.idx - rows + 1 + } + return p +} + +// paletteRows is how many action rows fit. The subtraction accounts for the box +// chrome, the rows above it, and the blank line plus footer View always appends. +// The floor of 3 says the palette shrinks on a short terminal but never vanishes. +func paletteRows(height int) int { + if height <= 0 { + return paletteMaxRows + } + return max(3, min(paletteMaxRows, height-paletteChrome-paletteTopY-2)) +} + +// paletteBoxWidth is the box's total width, borders included. +func paletteBoxWidth(width int) int { + if width <= 0 { + return paletteWidth + } + // On a terminal too narrow even for the floor, the box keeps its 24 columns + // and overflows to the right: a box that spills is readable, a box squeezed + // below its floor is not. + return max(paletteMinWidth, min(paletteWidth, width-4)) +} + +// layout renders the box and returns the cell its top-left corner goes in. +// +// bodyLines is how many lines the screen underneath occupies, and it is not +// decoration. y is a coordinate in the BODY, but bubbletea's standard renderer +// keeps only the LAST height lines when a view overflows +// (standard_renderer.go:186-188). On a report grouped by task with fifty +// buckets, a box pinned at y=2 scrolls off the top and the user types into a +// palette they cannot see — on exactly the screen where it is most useful. So +// the box moves down by however much the body will be scrolled up. +func (p paletteModel) layout(th theme, width, height, bodyLines int) (string, int, int) { + boxW := paletteBoxWidth(width) + x := 0 + if width > boxW { + x = (width - boxW) / 2 + } + y := paletteTopY + if height > 0 { + if overflow := bodyLines + 2 - height; overflow > 0 { // +2: blank line + footer + y += overflow + } + } + return p.box(th, boxW, paletteRows(height)), x, y +} + +// box builds the frame a line at a time rather than through th.Box. +// +// For an overlay the rendered width is not decoration: it is the number +// composite uses to decide where the body resumes. lipgloss's own border and +// padding arithmetic already cost this package one bug (the report table's +// amputated right border, #66), so here every line is boxW cells wide by +// construction and a test checks it. +func (p paletteModel) box(th theme, boxW, rows int) string { + innerW := boxW - 4 + var b strings.Builder + + // th.Accent, never th.Title: Title carries MarginBottom(1) and would inject + // a blank line into the middle of the overlay. + dashes := boxW - lipgloss.Width(paletteTitle) - 5 + b.WriteString(th.Border.Render("╭─ ") + th.Accent.Render(paletteTitle) + + th.Border.Render(" "+strings.Repeat("─", dashes)+"╮") + "\n") + + q := shaveToWidth("> "+p.query, innerW) + b.WriteString(paletteLine(th, th.Cell.Render(q), innerW-lipgloss.Width(q))) + b.WriteString(th.Border.Render("├"+strings.Repeat("─", boxW-2)+"┤") + "\n") + + if len(p.items) == 0 { + msg := shaveToWidth("no matching action", innerW) + b.WriteString(paletteLine(th, th.Help.Render(msg), innerW-lipgloss.Width(msg))) + } + for i := p.top; i < len(p.items) && i < p.top+rows; i++ { + b.WriteString(paletteLine(th, paletteRow(th, p.items[i], i == p.idx, innerW), 0)) + } + + b.WriteString(th.Border.Render("╰" + strings.Repeat("─", boxW-2) + "╯")) + return b.String() +} + +// paletteLine wraps already-styled content in the side borders, padding it out +// by pad cells so every line is the same width. +func paletteLine(th theme, content string, pad int) string { + if pad > 0 { + content += th.Cell.Render(strings.Repeat(" ", pad)) + } + return th.Border.Render("│ ") + content + th.Border.Render(" │") + "\n" +} + +// paletteRow renders one action's inner content, exactly innerW cells wide. +func paletteRow(th theme, it paletteItem, selected bool, innerW int) string { + cursor := " " + style := th.Cell + if selected { + cursor = "▸ " // the marker members.go, export.go and filters.go already use + style = th.Accent + } + + hintW := lipgloss.Width(it.a.hint) + labelW := innerW - 2 - hintW + if hintW > 0 { + labelW -= 2 // two spaces between the label and the key + } + label := shaveToWidth(it.a.label, max(labelW, 1)) + gap := innerW - 2 - lipgloss.Width(label) - hintW + + out := style.Render(cursor) + highlight(th, label, it.idx) + if gap > 0 { + out += th.Cell.Render(strings.Repeat(" ", gap)) + } + return out + th.Help.Render(it.a.hint) +} + +// highlight renders label with the runes at idx in th.Accent and the rest in +// th.Cell. +// +// idx indexes the FULL label, because that is what fuzzy.Match was given. +// Truncation happens first (highlighting first would leave escape sequences to +// be cut in half), so indices past the shortened label are dropped here, and a +// trailing ellipsis never lights up. +func highlight(th theme, label string, idx []int) string { + r := []rune(label) + limit := len(r) + if limit > 0 && r[limit-1] == '…' { + limit-- + } + hit := make(map[int]bool, len(idx)) + for _, i := range idx { + if i < limit { + hit[i] = true + } + } + + var b strings.Builder + // Runs, not runes: one Render per stretch keeps the escape sequences down to + // a handful per row instead of one pair per character. + for i := 0; i < len(r); { + j := i + for j < len(r) && hit[j] == hit[i] { + j++ + } + seg := string(r[i:j]) + if hit[i] { + b.WriteString(th.Accent.Render(seg)) + } else { + b.WriteString(th.Cell.Render(seg)) + } + i = j + } + return b.String() +} +``` + +- [ ] **Step 4: Make every navigation dissolve the overlay** + +An overlay belongs to the screen it was raised over. Async handlers swap that screen out +from under it — `errMsg` on a 401 does `resetTo(screenSetup)` (`app.go:634-641`), and +`retryableErrMsg` from a non-Home origin does `replace(screenError)` — and both are +reachable, because the palette can be opened on `screenFilters` or `screenMembers` while a +fetch is still in flight. + +Rather than remembering this in eleven handlers, put it in the four functions that change +the screen. In `internal/tui/nav.go`, add to `goTo`, `replace`, `pop` and `resetTo`, before +each `return`: + +```go + m.overlay = overlayNone + m.palette = paletteModel{} +``` + +and give the file this note at the top of its doc comment block: + +```go +// Every function here dissolves an open overlay. An overlay belongs to the +// screen it was raised over, so whatever swaps that screen out — a key handler, +// an async error, a 401 relaunching the setup wizard — takes the overlay with +// it. The palette's own actions are not an exception: they run after +// closePalette(), so the clearing here is a no-op for them. +``` + +Note the two failure modes this prevents, because neither is obvious: over `screenSetup` +the palette stays visible AND keeps the keyboard on a screen `ctrl+p` is not allowed on at +all; over `screenError` it becomes invisible (`View` returns early there) while still +swallowing every keypress, so "press a key to return home" silently does nothing. + +- [ ] **Step 5: Wire the palette into `Update` and `View`** + +In `internal/tui/app.go`, replace the whole `case tea.KeyMsg:` block with: + +```go + case tea.KeyMsg: + // ForceQuit first and unconditionally: with an overlay open it is the + // only way out that nothing else can intercept. + if key.Matches(msg, defaultKeys().ForceQuit) { + return m, tea.Quit + } + // An open overlay owns the keyboard, and this check MUST stay above + // Quit: below it, typing "q" into the palette's query would end the + // program. TestPaletteQueryAcceptsQ pins the ordering. + if m.overlay != overlayNone { + return m.updateOverlay(msg) + } + if key.Matches(msg, keysFor(m).Quit) { + return m, tea.Quit + } + // Checked here, beside Quit/ForceQuit, rather than inside routeKey: that + // makes '?' behave identically on every screen keysFor enables it for, + // including screenLoading, which routeKey has no case for at all. + // keysFor(m).Help is already unassigned (a no-op key.Binding, so + // key.Matches never fires) on every screen where '?' must mean + // something else — the ten textinput-forwarding contexts, screenError + // (any key -> Home), and entriesConfirmDelete (any key but y cancels). + if key.Matches(msg, keysFor(m).Help) { + m.helpAll = !m.helpAll + return m, nil + } + if key.Matches(msg, keysFor(m).Palette) { + return m.openPalette(), nil + } + return m.routeKey(msg) +``` + +Replace `View()` with: + +```go +func (m Model) View() string { + body := m.screenBody() + if m.screen == screenError { + // Every key returns Home here, which is not a binding — the screen + // says so in its own sentence instead. + return body + } + if m.overlay == overlayPalette { + // Composed over the BODY, not over the finished view: the footer stays + // below and visible, and it advertises the palette's own keys because + // keysFor follows the overlay. + box, x, y := m.palette.layout(m.theme, m.width, m.height, strings.Count(body, "\n")+1) + body = composite(body, box, x, y) + } + // Screens differ on whether their body ends with a newline; trimming here + // is what puts the footer the same distance below every one of them. + return strings.TrimRight(body, "\n") + "\n\n" + footerView(m.theme, m.width, m.helpAll, keysFor(m)) +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test ./internal/tui/ -run TestPalette -v` +Expected: PASS. + +Run: `go test ./... -race` +Expected: PASS. + +- [ ] **Step 7: Verify five tests fail against the bugs they exist for** + +Break, run, capture, restore. Paste all five transcripts into your report. + +1. In `Update`, move the `if m.overlay != overlayNone` check **below** the `Palette` check. + Run: `go test ./internal/tui/ -run TestPaletteCtrlPClosesRatherThanReopening -v` → must + FAIL, showing the query still set. Then also run + `go test ./internal/tui/ -run TestPaletteQueryAcceptsQ -v` and confirm it still + **PASSES** with the bug present — `paletteKeys` leaves `Quit` unassigned, so q is safe + whatever the order. That contrast is why the ctrl+p test exists. +2. In `paletteKeys`, use `Up: d.Up, Down: d.Down`. + Run: `go test ./internal/tui/ -run TestPaletteTypesJAndK -v` → must FAIL. +3. In `updateOverlay`, drop `|| msg.Type == tea.KeySpace` from the final condition. + Run: `go test ./internal/tui/ -run TestPaletteQueryAcceptsSpace -v` → must FAIL. +4. Remove the two clearing lines from `resetTo` and `replace` in `nav.go`. + Run: `go test ./internal/tui/ -run TestPaletteDissolvesWhenAnErrorSwapsTheScreen -v` → + must FAIL on both subtests. +5. In `layout`, delete the `if overflow := ...` block. + Run: `go test ./internal/tui/ -run TestPaletteBoxStaysVisibleUnderALongBody -v` → must + FAIL. + +- [ ] **Step 8: Add the goldens** + +Append to `internal/tui/golden_test.go`: + +```go +// goldenPaletteModel is a report screen with the palette open at a fixed size, +// so the box's position and contents are byte-stable. +func goldenPaletteModel() Model { + m := goldenModel() + m.theme = testTheme(true) + m.entries = goldenEntries() + m.report = goldenReport() + m.rep = newReport(m.report, "", goldenDaily()) + m.screen = screenReport + m.nav = []screen{screenHome} + m.width, m.height = 90, 30 + return m.openPalette() +} + +func TestGoldenPalette(t *testing.T) { + t.Parallel() + golden(t, "palette_report", goldenPaletteModel().View()) +} + +func TestGoldenPaletteFiltered(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.palette.query = "exp" + m = m.refreshPalette() + golden(t, "palette_filtered", m.View()) +} + +func TestGoldenPaletteNoMatch(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.palette.query = "zzzzzz" + m = m.refreshPalette() + golden(t, "palette_no_match", m.View()) +} + +// A terminal narrower than the preferred box, to pin the floor and the centring. +func TestGoldenPaletteNarrow(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.width = 40 + golden(t, "palette_narrow", m.View()) +} +``` + +Run: `go test ./internal/tui -update` +Then **read all four new golden files**. Check by eye, and say in your report that you did: +the box's frame is unbroken on every line; the report table is still visible to the left +and right of the box; the footer below reads `↑/↓ move · enter run · esc close · ctrl+c +force quit`; `palette_no_match` shows `no matching action`; `palette_narrow`'s box is not +wider than 40 columns. + +Run: `git diff --stat internal/tui/testdata/` +Expected: only the four new files. **If any pre-existing golden changed, stop and report +it** — nothing in this task should alter another screen. + +- [ ] **Step 9: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add internal/tui/palette.go internal/tui/palette_test.go internal/tui/app.go internal/tui/nav.go internal/tui/golden_test.go internal/tui/testdata +git commit -m "feat(tui): add the ctrl+p command palette as a floating overlay (#71)" +``` + +--- + +## Task 7: Docs, demo parity and the GIF + +**Files:** +- Modify: `README.md`, `README.it.md`, `CHANGELOG.md` +- Modify: `docs/demo.tape`, `docs/demo.gif` +- Create: `internal/tui/palette_demo_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1-6. +- Produces: nothing code-facing. + +- [ ] **Step 1: Write the demo-parity test** + +Every feature in this project has to work identically under `CLICKUP_DEMO=1`, with no I/O. + +Create `internal/tui/palette_demo_test.go`: + +```go +package tui + +import ( + "testing" +) + +// Demo mode must offer the same palette, and open it without any I/O: the demo +// exists so the TUI can be tried without an account, and a palette that reached +// the network there would break that promise. +// +// t.Setenv makes this test non-parallel by construction, which is what keeps it +// from racing TestMain's os.Unsetenv of the same variable. +func TestPaletteWorksInDemoMode(t *testing.T) { + t.Setenv("CLICKUP_DEMO", "1") + m := New(demoConfigForTest()) + m.entries = goldenEntries() + m.screen = screenReport + m.nav = []screen{screenHome} + + m = m.openPalette() + if len(m.palette.items) == 0 { + t.Fatal("the palette is empty in demo mode") + } + if !hasLabel(paletteActions(m), "Go to rates") { + t.Error("demo mode lost the navigation rows") + } + + // The two navigation rows that issue commands must take their demo branch. + for _, label := range []string{"Go to filters", "Go to members"} { + m.scope = "team" + m.teamMembers = nil + for _, a := range paletteActions(m) { + if a.label != label { + continue + } + if _, cmd := a.run(m); cmd != nil { + // Running the command is safe here precisely because the demo + // branch never dials: if this hangs or errors, the branch was + // missed. + if msg := cmd(); msg == nil { + t.Errorf("%s produced a nil message in demo mode", label) + } + } + } + } +} +``` + +`demoConfigForTest()` is a one-liner you add to the same file if `demoConfig()` is not +already reachable from tests — check `internal/tui/demo.go` first and reuse what is there +rather than inventing a second fixture. Say in your report which you used. + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/tui/ -run TestPaletteWorksInDemoMode -v` +Expected: PASS. + +- [ ] **Step 3: Update both READMEs** + +`README.md` — in the feature list, beside the existing entries about the adaptive report +table and the per-day sparkline, add: + +```markdown +- **Command palette** — `ctrl+p` opens a fuzzy launcher that floats over the current + screen: type a few letters, press Enter. It offers the current screen's own commands + (the same ones the footer advertises, with their keys) plus one-step navigation to any + other screen. +``` + +`README.it.md` — the same passage in Italian, matching the surrounding style: + +```markdown +- **Command palette** — `ctrl+p` apre un launcher fuzzy che galleggia sopra la schermata + corrente: scrivi qualche lettera, premi Invio. Offre i comandi della schermata corrente + (gli stessi che il footer annuncia, con i loro tasti) più la navigazione in un passo + verso qualunque altra schermata. +``` + +Check the surrounding lines and match their bullet style and voice rather than pasting +these verbatim if the file has moved on. + +- [ ] **Step 4: Update the CHANGELOG** + +Under `## [Unreleased]`, in `### Added`: + +```markdown +- Command palette (`ctrl+p`): a fuzzy action launcher that floats over the current screen, + offering both that screen's commands and one-step navigation to any other (#71). +- An overlay compositor: `View()` can now layer a floating box over a screen instead of + replacing it (#59). +``` + +- [ ] **Step 5: Add a palette beat to the demo tape** + +`docs/demo.tape` drives `vhs`. Add a beat after the report is on screen — read the file +first and match its existing `Type`/`Sleep`/`Enter` rhythm: + +``` +Ctrl+P +Sleep 1s +Type "exp" +Sleep 1500ms +Escape +Sleep 500ms +``` + +Add this comment at the top of the tape if it is not already there, so nobody installs a +stray binary into their PATH again: + +``` +# Regenerate with a locally built binary, without installing anything: +# go build -o /tmp/clupdemo/clup ./cmd/clup +# PATH=/tmp/clupdemo:$PATH vhs docs/demo.tape +``` + +- [ ] **Step 6: Regenerate the GIF** + +```bash +mkdir -p /tmp/clupdemo +go build -o /tmp/clupdemo/clup ./cmd/clup +PATH=/tmp/clupdemo:$PATH vhs docs/demo.tape +rm -rf /tmp/clupdemo +``` + +Then **look at the result**, do not just check the file changed. Extract three frames from +the palette section and read them: + +```bash +ffmpeg -y -i docs/demo.gif -vf "select='eq(n\,120)+eq(n\,150)+eq(n\,180)'" -vsync 0 /tmp/frame%02d.png +``` + +Adjust the frame numbers until you land inside the palette beat. Confirm in your report: +the box's border is unbroken, the screen underneath is visible around it, and the query +text is legible. If `vhs` or `ffmpeg` is not installed, say so plainly and leave +`docs/demo.gif` untouched rather than committing a GIF you could not inspect. + +- [ ] **Step 7: Gate and commit** + +```bash +gofmt -l . +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@latest ./... +go build ./... +go test ./... -race +git add README.md README.it.md CHANGELOG.md docs/demo.tape docs/demo.gif internal/tui/palette_demo_test.go +git commit -m "docs: document the command palette and refresh the demo GIF (#71)" +``` + +--- + +## Self-review (run by the plan's author, recorded here) + +**Spec coverage.** §2.1 → Tasks 4-5. §2.2 → Task 2. §2.3 → nothing to build, recorded in +the spec. §2.4 → Task 1. §2.5 → Task 6 (`box` builds the frame by hand). §2.6 → Task 6 +(`query string`). §4 → Task 2. §5 → Tasks 4 and 6. §5.2b → Task 4 Step 5, pinned by Task 6. +§6.1 → Task 5. §6.2 → Task 5. §6.3 → Task 3. §6.4 → accepted, no code. §6.5 → Task 6 +(`refreshPalette`'s `SliceStable`). §7 → Task 1. §8 → Task 6. §9.5 → Task 7 Step 1. +§10 → nothing to build. §11 → the Global Constraints above. + +**Two things the spec asks for that no task built, now added:** the demo-parity test +(§9.5) became Task 7 Step 1, and the `TestEveryPaletteBindingIsReplayable` deferral is +called out explicitly in both Task 4 and Task 5 so it cannot fall between them. + +**Type consistency.** `openEntries` returns `(Model, tea.Cmd)` in Tasks 3, 5 and 6 alike. +`action.run` is `func(Model) (tea.Model, tea.Cmd)` in Tasks 5 and 6 alike. `layout` returns +`(string, int, int)` in Tasks 6's implementation and its test. `paletteModel` is declared +empty in Task 4 and replaced in Task 6 — Task 4 Step 1 says so. + +**Known ordering hazard.** Task 4 leaves `ctrl+p` advertised in the footer but inert until +Task 6. Called out in Task 4's preamble so a reviewer does not file it. + +--- + +## What an adversarial review changed (2026-07-29) + +Two independent reviews of the spec and of this plan ran before execution. Both refused it +as first written. What they caught, and where it landed: + +| Finding | Fix | +|---|---| +| `PrevMonth`/`NextMonth` lead with `"left"`/`"right"`, so `TestEveryPaletteBindingIsReplayable` could never pass — and dropping them would silently lose both month actions on Home | `keyMsgFor` gained `"left"` and `"right"`; the round-trip is exact (`bubbletea key.go:301,303`). Task 5 | +| The range subtest asserted a non-zero `rangeModel`, but `newRange(PresetThisMonth)` **is** the zero value — it would have failed against correct code | The subtest uses `PresetLast7d` and asserts on `idx`. Task 3 Step 1 | +| Adding `Palette` to `keyMap` reddens ~20 label-parity tests in 11 files the plan never named | A whole step, with the file list and the exact insertion point (`enabledLabels` sorts, so `"ctrl+p"` follows `"ctrl+c"`). Task 4 Step 6 | +| `TestPaletteQueryAcceptsQ` was prescribed as a red-green test but **cannot** fail against the stated break — `paletteKeys` leaves `Quit` unassigned. The plan's own second guard neutralised its own RED | Demoted to a regression test, with the reason written down; `TestPaletteCtrlPClosesRatherThanReopening` is the one with teeth. Task 6 | +| `TestPaletteHighlightUsesTheAccentStyle` never called `highlight` — it compared two theme fields and would pass whatever the code did | Rewritten on a real color-profile renderer, asserting on the escapes. Task 6 Step 1 | +| `TestPaletteHighlightSurvivesTruncation` used a query whose matches all landed inside the cut, so the drop branch was never exercised | Query changed so every match falls past the cut. Task 6 Step 1 | +| Nothing dissolved the overlay when an async error swapped the screen: the palette survived onto the setup wizard, and onto `screenError` where it was invisible but still ate every keypress | `goTo`/`replace`/`pop`/`resetTo` clear it. Task 6 Step 4 | +| `ansi.Cut` keeps a straddling wide glyph on the right edge and drops it on the left, so a composited line came out a cell too wide — and the ASCII width test passed straight over it | Straddle correction plus a CJK fixture, with the break step asking for the contrast between the two tests. Task 2 | +| `y` is a body coordinate, but bubbletea keeps the **last** `height` lines of an overflowing view — the box scrolled off the top of a long report | `layout` takes `bodyLines` and moves the box down by the overflow. Task 6 Step 3 | +| `"Log hours"` appeared twice with an identical label on Home and Report | The global row is `"Go to log hours"`. Task 5 | +| `ctrl+p` on `entriesConfirmDelete` contradicted that mode's "any key cancel" footer | `Palette` unassigned in that branch. Task 4 Step 5 | +| Opening the palette with `helpAll` set made the footer multi-line, breaking `paletteRows`' fixed −2 | `openPalette` clears `helpAll`. Task 6 Step 3 | +| The label `"Export report"` does not exist — screen labels are `capitalize(Help().Desc)`, so it is `"Export"` | Fixed in Task 1's ranking test and in the spec | +| "Go does not convert a multi-value return" — false | Removed; the shorter form is used. Task 3 Step 5 | +| `sort.SliceStable` where the project prefers `slices` | `slices.SortStableFunc`. Task 6 Step 3 | +| A missing `fuzzy` import, a miscount of "the two that compile" | Fixed | + +What the reviews confirmed and I have not re-derived: every struct field and constructor +name the plan uses exists with that name; `report.go:92-125` and `home.go:54-87` are copied +faithfully into `open.go`; `openEntries`' only caller is `report.go:120`; all five measured +`ansi.Cut` behaviours reproduce; every fuzzy score in Task 1 recomputes by hand; +`newTestModelOnReport` yields 18 actions, comfortably past `paletteMaxRows`; the box +arithmetic holds at both 52 and 24 columns with no negative `strings.Repeat`. diff --git a/docs/superpowers/specs/2026-07-29-command-palette-overlay-design.md b/docs/superpowers/specs/2026-07-29-command-palette-overlay-design.md new file mode 100644 index 0000000..1fb3dc5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-command-palette-overlay-design.md @@ -0,0 +1,848 @@ +# Command palette + overlay compositor — design + +**Data:** 2026-07-29 +**Milestone:** v1.9 — TUI design system, tranche B3 +**Issue:** #71 (command palette), #59 ultima casella (overlay ortogonale a `m.screen` + compositor di righe) + +--- + +## 1. Obiettivo + +Due cose che sono una cosa sola. + +`View()` oggi ritorna **una** schermata: `screenBody()` più il footer. Non esiste modo di +disegnare qualcosa *sopra* quello che c'è già. #59 chiama questa mancanza per nome — +«uno stato di overlay ortogonale a `m.screen` + un compositor di righe» — e la lascia +aperta da due tranche perché un compositor senza clienti è un'astrazione modellata sul +nulla. + +Il cliente è #71: una **command palette** aperta da `ctrl+p`, che filtra in fuzzy le +azioni disponibili e ne esegue una con Enter. È il primo pezzo di UI di questo programma +che ha senso solo se galleggia: una palette che sostituisce la schermata sottostante ha +perso metà del suo scopo, che è sceglierla *guardando* quello su cui stai lavorando. + +Questa tranche chiude entrambe. + +--- + +## 2. Decisioni prese + +Quattro decise con l'utente durante il brainstorming, due prese qui e messe agli atti. + +### 2.1 Contenuto della palette — azioni di schermata + navigazione globale + +Due sorgenti (§6). Le azioni della schermata corrente sono **derivate** dal keymap, non +riscritte; la navigazione globale è un elenco esplicito di sole voci `"Go to X"`. + +Lo **switcher mese/lista/membro** che #71 nomina di sfuggita («doubling as a +month/list/member switcher») **esce da questa tranche** e diventa una issue nuova nella +milestone v1.12 — *Navigation/views/preset*, che esiste esattamente per questo. Motivo: +sono righe che non sono azioni ma selezioni di stato, cioè una seconda modalità della +palette, con un suo modello di dati, una sua resa e una sua superficie di test. + +### 2.2 Il compositor taglia per colonna, ANSI-aware + +`composite(body, box, x, y)` innesta il box dentro le righe del corpo, che resta visibile +tutto intorno. L'alternativa scartata (il box occupa righe intere) è più semplice ma +chiude la casella di #59 solo in senso debole: quella casella dice «gli overlay +richiedono la stratificazione», e sostituire righe intere non è stratificare. + +### 2.3 `?` resta il footer espanso + +#59 nomina «l'overlay `?`» tra i clienti previsti dello stato di overlay, e #69 chiuse +con una nota che diceva che promuoverlo dopo sarebbe stato «una modifica a una funzione». +**Non lo promuoviamo, e non perché costi.** Un pannello d'aiuto che copre proprio la +schermata su cui stai chiedendo aiuto è peggio di un footer che non copre niente. #69 è +chiusa con questa scelta da due release e nessuno l'ha contestata. + +Questa decisione è definitiva, non rimandata: **non apriamo una issue** per riconsiderarla. +Se un giorno servirà davvero, il compositor c'è. + +### 2.4 Il matching fuzzy è scritto in casa + +Nuovo pacchetto puro `internal/fuzzy` (§7). Nessuna dipendenza nuova nella supply chain +di un binario firmato cosign e distribuito su brew/scoop/AUR. Gli indici agganciati non +servono solo a ordinare: decidono **quali lettere si illuminano** nella riga, quindi il +matching deve essere ottimo e non greedy. + +### 2.5 (presa qui) Il box è costruito a mano, non con `th.Box` + +`th.Box` è `Border(RoundedBorder()).Padding(0, 1)`. Per un overlay la larghezza resa non è +un dettaglio estetico: è il numero che il compositor usa per decidere dove riprendere la +riga del corpo. La tranche C ha già pagato una volta il prezzo di fidarsi dell'aritmetica +di bordo di una libreria (`Table.Width()` che contava solo i separatori di colonna e +amputava il bordo destro). + +Quindi il box si costruisce riga per riga: bordo superiore con il titolo innestato, righe +`"│ " + contenuto + " │"`, bordo inferiore. La larghezza è `boxWidth` **per costruzione**, +e un test lo verifica su ogni riga con `lipgloss.Width`. + +### 2.6 (presa qui) La query è una `string`, non un `textinput.Model` + +`textinput.New()` deriva i suoi stili dal renderer di default di lipgloss — la stessa +ragione per cui `footer.go` rifiuta `help.New()`. Aggiungerebbe inoltre tre campi di stato +per affordance di editing (cursore mobile, `ctrl+u`, cancellazione per parola) che un +filtro da venti voci non usa. + +La query è una `string` con append di rune e backspace. Dieci righe, nessun renderer. + +--- + +## 3. Architettura per file + +``` +NUOVO internal/fuzzy/fuzzy.go puro: Match(query, target) (score int, idx []int, ok bool) +NUOVO internal/fuzzy/fuzzy_test.go +NUOVO internal/tui/overlay.go composite(body, box string, x, y int) string +NUOVO internal/tui/overlay_test.go +NUOVO internal/tui/actions.go type action; screenActions(m); globalActions(m); paletteActions(m) +NUOVO internal/tui/actions_test.go +NUOVO internal/tui/palette.go paletteModel, newPalette, updatePalette, view, layout +NUOVO internal/tui/palette_test.go + +MOD internal/tui/app.go overlayKind + campi sul Model; ordine dei tasti in Update; + View() compone; estrazione delle openX() +MOD internal/tui/nav.go goTo/replace/pop/resetTo dissolvono l'overlay (§5.2c) +MOD internal/tui/keys.go keyDefaults.Palette/PaletteUp/PaletteDown; keyMap.Palette; + paletteKeys(); keyMap.paletteBindings(); keysFor si + sdoppia in keysFor (overlay-aware) + screenKeys (§5.2b) +MOD internal/tui/home.go chiama le openX() estratte +MOD internal/tui/report.go chiama le openX() estratte +MOD internal/tui/entries.go openEntries() cambia firma (§6.3) +MOD keys_test.go, report_test.go, export_test.go, members_test.go, filters_test.go, + range_test.go, budget_test.go, log_test.go, rates_test.go, entries_test.go, + listbrowser_test.go "ctrl+p" nelle liste want dei test di etichette (§5.3) +MOD internal/tui/testdata/*.golden footer *_full rigenerati; golden nuovi della palette +MOD go.mod github.com/charmbracelet/x/ansi: indirect -> direct +MOD README.md, README.it.md, CHANGELOG.md, docs/demo.tape, docs/demo.gif +``` + +`internal/report` e `internal/duration` non vengono toccati. `internal/fuzzy` nasce con la +stessa regola: nessun I/O, nessun import di `config`/`clickup`/`tui`. + +--- + +## 4. Il compositor + +### 4.1 Contratto + +```go +// composite draws box over body with its top-left cell at (x, y) and returns +// the combined text. Cells of body outside the box's rectangle are preserved +// verbatim, including their ANSI styling. +func composite(body, box string, x, y int) string +``` + +`x` e `y` sono **celle**, non byte né rune. Per ogni riga `i` del corpo con +`y <= i < y+altezzaBox`: + +``` +sinistra := ansi.Cut(riga, 0, x) // taglio ANSI-aware +sinistra += spazi fino a x // se la riga è più corta di x +destra := ansi.Cut(riga, x+larghezzaBox, ansi.StringWidth(riga)) +risultato := sinistra + rigaBox[i-y] + destra +``` + +`ansi.Cut(s, left, right)` è di `github.com/charmbracelet/x/ansi` v0.11.6, già nel grafo +dei moduli via lipgloss v1.1.0: promuoverlo a dipendenza diretta non scarica niente di +nuovo. È cell-based, consapevole dei caratteri wide e non spezza le sequenze di escape. + +Comportamento **misurato**, non dedotto dalla documentazione (sonda eseguita su +`"\x1b[31mHELLO\x1b[0m world"`): + +| Chiamata | Risultato | +|---|---| +| `Cut(s, 0, 3)` | `"\x1b[31mHEL\x1b[0m"` — **chiude** lo stile aperto | +| `Cut(s, 3, 11)` | `"\x1b[31mLO\x1b[0m world"` — **riemette** lo stile attivo al punto di taglio | +| `Cut(s, 8, 3)` | `""` — `left > right` non è un panic | +| `Cut(s, 20, 30)` | `"\x1b[31m\x1b[0m"` — **non vuoto** oltre la fine: coppia di escape a larghezza zero | +| `Cut("\x1b[31mHELLO world", 0, 3)` | `"\x1b[31mHEL"` — su input **non terminato** NON chiude | + +Da cui tre regole per l'implementazione: + +- Se `x + larghezzaBox >= larghezza(riga)`, la parte destra è `""` per costruzione, senza + chiamare `Cut`. Con `right = larghezza(riga)` la chiamata ritornerebbe comunque `""` + (`truncate.go:31-33`: `right <= left → ""`), quindi la regola non ripara un guasto: fa + vedere nel codice che quel caso è previsto, invece di lasciarlo dipendere da un dettaglio + interno della libreria. +- Dopo la parte sinistra si aggiunge `ansi.ResetStyle` (`"\x1b[m"`) **quando contiene un + `\x1b`**. Sull'input ben formato è ridondante; sull'input con uno stile non terminato è + l'unica cosa che impedisce al colore di colare dentro il box. Sotto `termenv.Ascii` non + ci sono escape, quindi i golden non cambiano. +- **Un glifo wide a cavallo di un bordo va scartato, non tenuto intero.** `Cut` tratta i + due lati in modo asimmetrico: a sinistra **scarta** il cluster che scavalca il limite + (la parte sinistra può rendere `x-1` colonne), a destra lo **tiene intero** + (`truncate.go:208-216`), quindi la parte destra può partire una colonna troppo presto e + la riga composta esce larga una colonna in più. A sinistra il padding a `x` già lo + copre, purché sia calcolato sulla larghezza **misurata** del risultato di `Cut` e non + solo «se la riga è più corta di `x`». A destra bisogna ritagliare una colonna più in là + e restituire la colonna persa come spazio: mezzo glifo non è disegnabile, e allineare + vale più che conservare un carattere sul bordo di un box che lo copre a metà. + + Questo è il caso che i test ASCII non vedono — la stessa cecità che #135 documenta. La + §9.2 vuole una fixture con un carattere wide su entrambi i bordi. + +### 4.2 I quattro casi limite + +Ognuno ha il suo test, e ognuno di quei test va **verificato fallente** contro +l'implementazione che lo ignora prima di essere considerato valido. Questa non è +diligenza generica: nella tranche C quattro test sono stati colti a passare proprio sul +bug che esistevano per prendere. + +1. **Riga del corpo più corta di `x`** → padding a spazi fino a `x`, altrimenti il box si + sposta a sinistra su quella riga sola. +2. **Corpo più corto di `y + altezzaBox`** → righe vuote aggiunte, altrimenti il box viene + troncato in basso. +3. **Stato SGR che sopravvive al taglio.** Se il colore aperto nella metà sinistra della + riga cola dentro il box, il bug è **invisibile ai golden**: `TestMain` fissa + `termenv.Ascii` per il renderer di default, quindi i golden non contengono escape. + Il test costruisce un renderer suo con un profilo a colori (come i test di stile della + tranche C) e asserisce sugli escape veri. +4. **`x + larghezzaBox` oltre la fine della riga** → `destra` è vuota, nessun panic. + +### 4.3 Dove si compone + +`View()` compone sul **corpo**, non sull'output completo: + +```go +func (m Model) View() string { + body := m.screenBody() + if m.screen == screenError { return body } + if m.overlay == overlayPalette { + box, x, y := m.palette.layout(m.theme, m.width, m.height) + body = composite(body, box, x, y) + } + return strings.TrimRight(body, "\n") + "\n\n" + footerView(m.theme, m.width, m.helpAll, keysFor(m)) +} +``` + +Il footer resta sotto e visibile, e mostra **i tasti della palette**, perché `keysFor` +ritorna `paletteKeys` quando l'overlay è aperto (§5). Se il box è più alto del corpo, il +footer scende: accettato e voluto — il box deve essere intero. + +`screenError` non compone: quella schermata ritorna prima, come già fa oggi. + +--- + +## 5. Lo stato di overlay + +### 5.1 I campi + +```go +type overlayKind int + +const ( + overlayNone overlayKind = iota + overlayPalette +) +``` + +Sul `Model`: `overlay overlayKind` e `palette paletteModel`. + +Ortogonale a `m.screen` nel senso letterale: aprire la palette **non** tocca `m.nav`, +chiuderla **non** fa `pop()`. Un overlay non è un posto dove sei andato. + +### 5.2 L'ordine dei tasti in `Update` è load-bearing + +```go +case tea.KeyMsg: + if key.Matches(msg, defaultKeys().ForceQuit) { return m, tea.Quit } // 1 + if m.overlay != overlayNone { return m.updateOverlay(msg) } // 2 + if key.Matches(msg, keysFor(m).Quit) { return m, tea.Quit } // 3 + if key.Matches(msg, keysFor(m).Help) { m.helpAll = !m.helpAll; return m, nil } + if key.Matches(msg, keysFor(m).Palette) { return m.openPalette(), nil } + return m.routeKey(msg) +``` + +`ForceQuit` sale in cima (oggi è dopo `Quit`). I due tasti non si sovrappongono (`ctrl+c` +contro `q`), quindi lo scambio non cambia niente sul comportamento attuale, ma rende +`ctrl+c` l'unica via d'uscita incondizionata anche con un overlay aperto. + +**Seconda garanzia, indipendente:** `paletteKeys` lascia `Quit` e `Help` non assegnati +(binding zero → `keys == nil` → `Enabled() == false` → `key.Matches` non scatta mai; +verificato in `bubbles/key@v1.0.0/key.go:106`). Se un giorno qualcuno riordina i controlli, +la palette non diventa comunque un modo per uscire per sbaglio. + +**Che cosa rende load-bearing l'ordine, esattamente.** La prima stesura di questa sezione +diceva «altrimenti scrivere `q` chiude il programma» e ordinava di verificare +`TestPaletteQueryAcceptsQ` fallente contro l'ordine invertito. **Era falso**, e falso nel +modo peggiore: con il passo 2 sotto il passo 3, il passo 3 legge `keysFor(m)`, che con +l'overlay aperto è `paletteKeys`, dove `Quit` non è assegnato — quindi `q` finisce nella +query comunque. La seconda garanzia neutralizzava proprio il RED che la sezione +prescriveva. È la trappola n.1 della §11 applicata alla spec stessa. + +Il tasto su cui l'ordine è davvero load-bearing è `ctrl+p`: `paletteKeys` **assegna** +`Palette` (serve per chiudere), quindi con il passo 2 sotto il passo 5 un `ctrl+p` a +palette aperta chiamerebbe `openPalette()` una seconda volta, azzerando la query invece di +chiudere. Il test che discrimina è `TestPaletteCtrlPClosesRatherThanReopening`. + +`TestPaletteQueryAcceptsQ` resta, ma come test di regressione, non come RED: la spec dice +qui che non fallisce contro il solo riordino, così nessuno lo usa per «dimostrare» +l'ordine. + +### 5.2c L'overlay si dissolve a ogni navigazione + +Un overlay appartiene alla schermata su cui è stato alzato. Se quella schermata viene +sostituita **sotto** di lui, l'overlay non ha più un posto a cui appartenere: + +- `errMsg` con 401 fa `resetTo(screenSetup)` (`app.go:634-641`). Senza dissoluzione, la + palette resterebbe disegnata e operativa **sopra il wizard di setup**, che la §5.3 + esclude esplicitamente. +- `retryableErrMsg` con origine non-Home fa `replace(screenError)`. `View()` ritorna prima + per quella schermata, quindi la palette diventa **invisibile** — ma il passo 2 continua a + mandarle ogni tasto. La schermata dice «press a key to return home» e mente, finché + l'utente non indovina `esc`. + +Entrambi sono raggiungibili: la palette si apre su `screenFilters` o `screenMembers` mentre +un fetch è in volo, e la risposta arriva dopo. + +La regola sta in **un posto solo**, `nav.go`: `goTo`, `replace`, `pop` e `resetTo` azzerano +`m.overlay` e `m.palette`. Non serve ricordarsene in undici handler, e le azioni della +palette non sono un'eccezione da gestire — eseguono già dopo `closePalette()`, quindi +trovano l'overlay chiuso e l'azzeramento è un no-op. + +### 5.2b `keysFor` si sdoppia — altrimenti la palette si svuota appena scrivi + +`keysFor(m)` deve ritornare `paletteKeys` quando l'overlay è aperto, perché è ciò che il +footer legge. Ma `screenActions(m)` (§6.1) deriva le sue voci **dal keymap della schermata +sottostante**: se chiamasse `keysFor(m)` con l'overlay aperto si ritroverebbe i tasti della +palette e le azioni di schermata sparirebbero. + +Il guasto non si vede all'apertura — `openPalette()` gira quando `m.overlay` vale ancora +`overlayNone` — ma **alla prima rune digitata**, quando la lista si ricalcola e metà delle +voci svanisce. + +Quindi: + +```go +// screenKeys is the binding set for m.screen, ignoring any overlay. +func screenKeys(m Model) keyMap { switch m.screen { /* il corpo di oggi */ } } + +// keysFor is what the footer and Update read: the overlay owns the keyboard. +func keysFor(m Model) keyMap { + if m.overlay == overlayPalette { return paletteKeys(defaultKeys()) } + return screenKeys(m) +} +``` + +`screenActions` chiama `screenKeys`. `TestPaletteKeepsScreenActionsWhileTyping` va scritto +contro la versione che chiama `keysFor` e verificato fallente. + +### 5.3 Dove `ctrl+p` è attivo + +Ovunque **tranne**: + +| Schermata / modo | Perché no | +|---|---| +| `screenSetup` | il wizard va finito: senza token configurato quasi nessuna azione è eseguibile | +| `screenLoading` | una navigazione mentre l'`entriesMsg` è in volo verrebbe annullata quando atterra (il suo handler fa `resetTo(Home).goTo(Report)` incondizionatamente) | +| `screenError` | qualunque tasto torna a Home: `ctrl+p` non può reclamarne uno | +| `entriesConfirmDelete` | il footer di quel modo promette «any key cancel» (`anyKeyHelp`, `keys.go:481-484`). Un `ctrl+p` che apre la palette lascerebbe la conferma di cancellazione pendente sotto e smentirebbe il footer nella stessa riga. `Help` è già non assegnato lì per la stessa ragione: `Palette` la segue | + +Su tutte le altre `Palette` è assegnato nel `keyMap` e compare **solo in `full`**, mai in +`short`: i golden `footer_*_short` restano invariati, i `footer_*_full` si rigenerano. + +**Ricaduta sui test di parità, da non scoprire a metà implementazione.** `keyMap` guadagna +un campo `key.Binding`, quindi `TestAllBindingsCoversEveryField` (`keys_test.go:152`, che +conta i campi per riflessione) obbliga ad aggiungere `k.Palette` ad `allBindings()`. Da lì +`enabledLabels` (`keys_test.go:16-26`) inizia a raccogliere anche `"ctrl+p"`, e siccome +ordina alfabeticamente e confronta con `slices.Equal`, **ogni** lista `want` dei test di +etichette per schermata va aggiornata inserendo `"ctrl+p"` subito dopo `"ctrl+c"`. Sono +una ventina di casi in undici file: `keys_test.go`, `report_test.go`, `export_test.go`, +`members_test.go`, `filters_test.go`, `range_test.go`, `budget_test.go`, `log_test.go`, +`rates_test.go`, `entries_test.go`, `listbrowser_test.go`. + +Il costo va guardato in faccia perché c'è una scorciatoia che lo evita e che sarebbe +sbagliata: controllare `defaultKeys().Palette` globalmente in `Update`, come fa +`ForceQuit`. Costa zero test, e in cambio `ctrl+p` sparisce da ogni footer — una palette +che nessuno può scoprire — **e** le quattro esclusioni qui sopra smettono di essere +espresse dall'enablement e devono essere riscritte a mano da qualche altra parte. + +`ctrl+p` non entra in conflitto con le schermate che hanno un `textinput` focalizzato: non +è un carattere stampabile, e `bubbles/textinput` lo lega a `PrevSuggestion`, che è inerte +finché non si popolano i suggerimenti (cosa che questo programma non fa da nessuna parte). + +--- + +## 6. Il registro delle azioni + +```go +type action struct { + label string // "Export report", "Go to rates" + hint string // "e" per le azioni di schermata, "" per le globali + run func(Model) (tea.Model, tea.Cmd) +} +``` + +`run` ritorna `tea.Model` e non `Model` di proposito: è la firma di `routeKey`, così +un'azione di schermata è un rinvio diretto senza type assertion. + +### 6.1 Sorgente 1 — le azioni della schermata, derivate dal keymap + +Non un elenco nuovo. `keysFor(m)` filtrato da un `paletteBindings()` curato, gemello +dell'`allBindings()` che già esiste in `keys.go`: tiene i binding che sono **comandi** e +scarta la meccanica del cursore. + +Dentro (`Generate`, `GroupBy`, `ChangeRange`, `Reload`, `Export`, `Rates`, `Filters`, +`Budget`, `OpenEntries`, `LogHours`, `Timer`, `Members`, `Range`, `ToggleScope`, +`ToggleWeek`, `PrevMonth`, `NextMonth`, `ListCurrency`, `ListBudget`, `NewOverride`, +`ClearValue`, `BrowseList`, `Save`, `Delete`, `Edit`, `History`, `Tags`, `NewTag`, +`StopTimer`). + +Fuori (`Up`, `Down`, `Confirm`, `ToggleItem`, `SelectAll`, `NextField`, `PrevField`, +`NextSection`, `PrevSection`, `Back`, `Help`, `Quit`, `ForceQuit`, `Palette`, +`ConfirmDelete`, `Yes`, `No`, `PickGuided`, `PickByID`, `PickTimer`). + +`Generate` (Enter su Home, «generate report») sta **dentro** anche se il suo tasto non è +una rune: è l'azione più usata del programma e una palette che non sa generare un report +sarebbe una palette a metà. `Confirm` resta fuori pur avendo lo stesso tasto — l'esclusione +è per campo, non per tasto. + +Tre proprietà cadono gratis da questa derivazione: + +- **Il gating è già fatto.** Un binding disabilitato non compare. La stessa `Enabled()` + che decide cosa il footer annuncia decide cosa la palette offre — non possono divergere. +- **Le parole sono le stesse.** L'etichetta è `b.Help().Desc` con l'iniziale maiuscola + (`unicode.ToUpper` sulla prima rune, non `strings.ToUpper` sul primo byte). Quello che + trovi nella palette è quello che il footer ti ha insegnato. +- **L'esecuzione non duplica niente.** `run` ripropone il tasto attraverso `routeKey`. + +L'ultima passa da un convertitore minuscolo e deliberatamente parziale: + +```go +// keyMsgFor rebuilds the tea.KeyMsg a binding's first key would produce. +// ok is false for any key shape the palette does not replay, so an action +// that cannot be executed faithfully is dropped rather than mis-fired. +func keyMsgFor(s string) (tea.KeyMsg, bool) { + switch s { + case "enter": return tea.KeyMsg{Type: tea.KeyEnter}, true + case "left": return tea.KeyMsg{Type: tea.KeyLeft}, true + case "right": return tea.KeyMsg{Type: tea.KeyRight}, true + } + if r := []rune(s); len(r) == 1 { return tea.KeyMsg{Type: tea.KeyRunes, Runes: r}, true } + return tea.KeyMsg{}, false +} +``` + +`key.Matches` confronta `msg.String()` con le stringhe di `b.Keys()`, e per queste quattro +forme il round-trip è esatto: `{KeyRunes, ['g']}.String() == "g"`, +`{KeyEnter}.String() == "enter"`, e `keyNames[KeyLeft] == "left"` / +`keyNames[KeyRight] == "right"` (`bubbletea@v1.3.10/key.go:301,303`). + +`"left"` e `"right"` sono lì perché **servono**: `PrevMonth` e `NextMonth` hanno +`key.WithKeys("left", "h")` e `key.WithKeys("right", "l")` (`keys.go:124-125`), quindi il +loro primo tasto non è una rune. Sono anche gli unici due binding della lista «Dentro» in +questo caso — controllati uno per uno. Senza questo ramo, +`TestEveryPaletteBindingIsReplayable` fallirebbe su Home, dove entrambi sono abilitati, e +le due voci sparirebbero in silenzio dalla palette. + +Una precisazione perché la motivazione non venga letta più forte di quello che è: +`{KeyRunes, Runes: []rune("tab")}.String()` vale `"tab"`, quindi il round-trip via +`key.Matches` funzionerebbe anche lì. Quello che si romperebbe è ogni handler che guarda +`msg.Type` invece di `key.Matches`. L'elenco resta chiuso per questo, non perché il +confronto fallisca. + +`TestEveryPaletteBindingIsReplayable` cammina la tabella schermata×modo che +`footer_golden_test.go` già possiede e verifica che `keyMsgFor` ritorni `ok` per **ogni** +binding abilitato di `paletteBindings()`. Il `false` è una rete, non una scorciatoia: il +test dice che oggi non si sfrutta mai. + +### 6.2 Sorgente 2 — la navigazione globale + +Solo `"Go to X"`, più Quit. La riga è netta e vale come regola: + +> **Le azioni globali sono solo navigazione. Tutto ciò che cambia stato resta un'azione di +> schermata**, dove il footer la insegna e la palette la ripete. + +| Etichetta | Bersaglio | Abilitata quando | +|---|---|---| +| `Go to home` | `screenHome` | sempre | +| `Go to report` | `screenReport` | `len(m.entries) > 0` | +| `Go to rates` | `screenRates` | sempre | +| `Go to budgets` | `screenBudget` | `len(m.entries) > 0` | +| `Go to filters` | `screenFilters` | `len(m.entries) > 0` | +| `Go to entries` | `screenEntries` | `len(m.entries) > 0` | +| `Go to export` | `screenExport` | `len(m.entries) > 0` | +| `Go to range` | `screenRange` | sempre | +| `Go to members` | `screenMembers` | `m.scope == "team"` | +| `Go to log hours` | `screenLog` | sempre | +| `Quit` | — | sempre | + +`Go to log hours`, non `Log hours`: su Home e su Report la sorgente 1 produce già +un'azione con etichetta `Log hours`, e due righe **identiche** nella stessa lista non sono +la ridondanza leggibile che la §6.4 accetta — sono un bug che sembra un bug. Il prefisso +`Go to` è quello che tutte le altre righe di navigazione portano già. + +`len(m.entries) > 0` è il predicato giusto per report/budgets/filters/entries/export +perché le entries si caricano solo attraverso `entriesMsg`, il cui handler costruisce +sempre `m.report` e `m.rep` prima di atterrare: entries non vuote implica un report +costruito. `Go to members` ricalca il gate di `homeKeys` (`m.scope == "team"`). + +Un'azione globale **sparisce quando il suo bersaglio è già `m.screen`**. + +### 6.3 L'estrazione delle `openX()` — perché la sorgente 2 non duplica gli handler + +Un'azione globale non può fare `m.goTo(screenExport)` e basta: deve anche costruire il +sotto-modello, che oggi è responsabilità del `case` dentro `updateReport`/`updateHome`. +Copiare quel corpo sarebbe la seconda fonte di verità che tutto il resto del design evita. + +Quindi si estraggono, come metodi di `Model`, esattamente nella forma che +**`openListBrowser()` ha già in `app.go:533`** e che `openEntries()` / `openBudgetView()` +hanno già: + +| Metodo | Corpo estratto da | Firma | +|---|---|---| +| `openExport()` | `report.go` case `Export` | `func (m Model) openExport() Model` | +| `openRates()` | `report.go` case `Rates` | `func (m Model) openRates() Model` | +| `openLog()` | `report.go` case `LogHours` + `home.go` case `LogHours` | `func (m Model) openLog() Model` | +| `openRange()` | `home.go` case `Range` | `func (m Model) openRange() Model` | +| `openFilters()` | `report.go` case `Filters` | `func (m Model) openFilters() (Model, tea.Cmd)` | +| `openMembers(origin screen)` | `home.go` case `Members` | `func (m Model) openMembers(origin screen) (Model, tea.Cmd)` | + +Handler e azione globale chiamano lo stesso metodo. `openBudgetView()` esiste già e non si +tocca. Due firme meritano una riga di motivazione: + +- **`openMembers` prende `origin`** invece di passare `screenHome` fisso come fa oggi + `loadMembersCmd`. Quel parametro decide dove atterra un `retryableErrMsg`: con + `screenHome` fisso, un errore di caricamento membri lanciato dalla palette mentre sei su + Rates ti teletrasporterebbe a Home con un errore inline, attribuendo il guasto a una + schermata su cui non eri. Home passa `screenHome`, la palette passa `m.screen`. +- **`openEntries()` diventa `(Model, tea.Cmd)`**, assorbendo il recupero pigro di + `currentUserCmd()` che oggi vive nel `case OpenEntries` di `report.go:119-125`. Senza + questo l'azione globale dovrebbe ricopiarlo, che è esattamente la duplicazione che + l'estrazione elimina. + +Questa estrazione è un miglioramento del codice esistente nell'area che stiamo comunque +modificando, non un refactoring opportunistico: senza di essa la sorgente 2 non è +scrivibile senza duplicare. + +### 6.4 La ridondanza accettata + +Sul Report compaiono sia `Export e` (sorgente 1) sia `Go to export` (sorgente 2). Sono la +stessa cosa e funzionano entrambe. + +Sopprimerne una richiederebbe una mappa binding→schermata scritta a mano, cioè esattamente +la seconda fonte di verità che la derivazione della §6.1 esiste per evitare. Le due righe +si leggono diverse — «fai qui» contro «portami là» — e il costo è una riga in più in una +lista filtrata. + +### 6.5 Ordinamento + +`paletteActions(m)` concatena sorgente 1 poi sorgente 2. Con una query, ordinamento per +punteggio decrescente; a parità di punteggio vince l'ordine di costruzione (sorgente 1 +prima, poi ordine di registro). L'ordinamento è **stabile** (`sort.SliceStable`). + +Con query vuota tutti i punteggi valgono 0, quindi l'ordine è quello di costruzione: le +azioni della schermata corrente in cima. «Cosa posso fare qui» prima di «portami altrove». + +--- + +## 7. `internal/fuzzy` + +```go +// Match reports whether query matches target as a case-insensitive subsequence. +// When ok, score ranks the match and idx holds the rune indices of target that +// were matched, in ascending order. +// +// An empty query always matches with score 0 and a nil idx. +func Match(query, target string) (score int, idx []int, ok bool) +``` + +### 7.1 Perché programmazione dinamica e non greedy + +Greedy prende sempre la prima occorrenza utile. Con query `"rt"` e target `"report table"` +prende `r@0` e `t@5` — cioè evidenzia `[r]epor[t] table`. Il match giusto è `r@0`, `t@7`: +`[r]eport [t]able`. Gli indici non ordinano soltanto: **decidono quali lettere si +illuminano**, quindi un match subottimo è un bug visibile. + +`best[j][i]` = miglior punteggio che aggancia le prime `j+1` rune della query con `q[j]` +su `t[i]`, con puntatori al predecessore per ricostruire `idx`. Costo `O(|q|·|t|²)`, che +su etichette da quaranta rune non è niente. + +### 7.2 Punteggio + +Confronto case-insensitive (`unicode.ToLower` su entrambe le rune). + +| Contributo | Valore | Quando | +|---|---|---| +| Consecutivo | `+10` | la rune agganciata segue immediatamente la precedente | +| Inizio parola | `+8` | indice 0, o la rune precedente è uno spazio, `-`, `_`, `/` o `.` | +| Gap iniziale | `-min(i₀, 10)` | `i₀` è l'indice della prima rune agganciata | + +Il gap iniziale è limitato a 10 di proposito: senza limite un'etichetta lunga verrebbe +penalizzata al punto da perdere contro un match peggiore ma più a sinistra. + +Ogni candidato consuma **tutta** la query, quindi non serve un contributo per rune: sarebbe +costante fra i candidati e non ordinerebbe niente. + +I test bloccano gli **ordinamenti osservabili** (`"exp"` mette `Export` sopra `Go to +export`; `"rt"` su `"report table"` restituisce `[0, 7]`), più un caso minuscolo con +punteggio esatto. Le costanti si possono ritoccare senza riscrivere la suite. + +`Export`, non `Export report`: la sorgente 1 produce `capitalize(b.Help().Desc)`, e il desc +di `Export` è `"export"` (`keys.go:148`). Una prima stesura di questa sezione citava +un'etichetta che nessuna delle due sorgenti genera. La conclusione regge con i valori veri +(`Export` 28 punti contro `Go to export` 22), ma il test va scritto sull'etichetta reale. + +--- + +## 8. La palette + +### 8.1 Il modello + +```go +type paletteItem struct { + a action + idx []int // indici di rune agganciati, da fuzzy.Match — servono a evidenziare + score int +} + +type paletteModel struct { + query string // append di rune + backspace + items []paletteItem // filtrato e ordinato + idx int // riga selezionata dentro items + top int // prima riga visibile (finestra di scorrimento) +} +``` + +Non c'è un campo con l'elenco completo: a ogni cambio di query `updateOverlay` (che è un +metodo di `Model`) ricalcola da zero con `paletteActions(m)` e rifiltra. Venti voci per un +match fuzzy non sono niente, e non tenere una copia significa non poterla far invecchiare. + +`idx` e `top` tornano a 0 a ogni cambio di query. + +Il ricalcolo è legato ai tasti, non ai messaggi: se un `runningTimerMsg` azzera il timer +mentre la palette è aperta, la riga `Stop timer` resta visibile fino al tasto successivo. +**Accettato.** L'esecuzione è comunque sicura — il replay ripassa da `key.Matches` con +l'enablement aggiornato, quindi una riga stantia è un no-op, non un'azione sbagliata — e +ricalcolare a ogni msg asincrono significherebbe far muovere la lista sotto le dita di chi +sta scegliendo. + +Lo scorrimento è quello classico e tiene il cursore sempre visibile: +`if idx < top { top = idx }`, `if idx >= top+righe { top = idx - righe + 1 }`. È +letteralmente la cosa che manca alla schermata Filters ed è una casella aperta di #28: qui +si fa giusta dall'inizio. + +### 8.2 Geometria — valori esatti + +```go +const ( + paletteWidth = 52 // larghezza preferita del box, bordi inclusi + paletteMinWidth = 24 // sotto questa non si scende + paletteMaxRows = 8 // righe di azione visibili + paletteChrome = 4 // bordo alto + riga query + separatore + bordo basso + paletteTopY = 2 // riga del corpo su cui poggia il bordo superiore +) +``` + +- `boxW = paletteWidth`; se `m.width > 0`, `boxW = max(paletteMinWidth, min(paletteWidth, m.width-4))`. +- `x = max(0, (m.width - boxW) / 2)`; con `m.width == 0` (nessun `WindowSizeMsg` ancora + arrivato) vale `x = 0`. +- `y = paletteTopY`, così la riga di titolo della schermata sottostante e la riga vuota + sotto di essa restano leggibili — **ma corretto verso il basso quando il corpo è più + alto del terminale**. Il renderer standard di bubbletea, quando la view supera l'altezza, + tiene le **ultime** `m.height` righe (`bubbletea@v1.3.10/standard_renderer.go:186-188`). + `y` è una coordinata del corpo, non del terminale: su un report raggruppato per task con + cinquanta bucket, un box a `y = 2` finisce **sopra il bordo visibile** e l'utente scrive + in una palette che non vede — proprio sulla schermata dove la palette serve di più. + + Quindi `layout` riceve anche il numero di righe del corpo e sposta il box esattamente di + quanto il corpo verrà scorso via: + + ```go + y := paletteTopY + if height > 0 { + if overflow := bodyLines + 2 - height; overflow > 0 { // +2 = riga vuota + footer + y += overflow + } + } + ``` +- `righe = paletteMaxRows`; se `m.height > 0`, + `righe = max(3, min(paletteMaxRows, m.height - paletteChrome - paletteTopY - 2))`. + I due termini sottratti sono la riga vuota e il footer che `View()` aggiunge sempre; il + pavimento a 3 dice che sotto una certa altezza la palette si stringe ma non sparisce. + +Se `m.width` è così stretto che `boxW` finisce a `paletteMinWidth` e sfora comunque, il box +resta a 24 colonne e `x` vale 0: preferiamo un box che sborda a destra a un box illeggibile. +**È il comportamento voluto**, e ha un test che lo dichiara. + +### 8.3 La resa + +Costruzione manuale, riga per riga, con `th.Border` per la cornice (lo stesso token della +tabella del report) — vedi §2.5: + +``` +╭─ Command palette ──────────────────────────────────╮ +│ > exp │ +├────────────────────────────────────────────────────┤ +│ ▸ Export report e │ +│ Go to export │ +╰────────────────────────────────────────────────────╯ +``` + +- Il cursore è `▸ ` / ` `, la convenzione di `members.go`, `export.go` e `filters.go`. +- Le rune agganciate si illuminano in `th.Accent`. Sotto `termenv.Ascii` i golden non lo + vedono, quindi serve un **test diretto sullo stile** — la lezione della tranche C. +- La riga selezionata: prefisso `▸` in `th.Accent`, testo in `th.Cell`. Lo sfondo `th.Zebra` + **non** si usa qui: si scontrerebbe con l'evidenziazione delle rune agganciate. +- Il suggerimento tasto è allineato a destra dentro la larghezza interna, in `th.Help`. +- L'etichetta si tronca **per larghezza di visualizzazione**, riusando `shaveToWidth` + (`report_table.go:250`), non per rune. Il troncamento per rune contro larghezza di + visualizzazione è già costato un bug in tranche C: la palette non diventa un sesto sito + rotto di **#135**. +- **Prima si tronca, poi si evidenzia.** `fuzzy.Match` restituisce indici di rune + dell'etichetta *intera*; applicarli dopo il taglio richiede di scartare quelli che cadono + oltre. La regola esatta: dopo `shaveToWidth`, sopravvivono gli indici `< len(rune + dell'etichetta troncata) - 1` quando il troncamento ha aggiunto `…` (l'ellissi non si + illumina), altrimenti `< len(rune dell'etichetta troncata)`. L'ordine inverso — + evidenziare e poi tagliare — spezzerebbe le sequenze di escape a metà. + `TestPaletteHighlightSurvivesTruncation` copre un'etichetta più lunga del box. +- Nessun risultato → una riga sola, `no matching action`, in `th.Help`. Enter non fa niente. + +### 8.4 I tasti della palette + +| Tasto | Effetto | +|---|---| +| rune stampabile | appesa alla query; `items` ricalcolato; `idx`, `top` a 0 | +| `backspace` | ultima rune tolta; stesso ricalcolo | +| `↑` / `↓` | `idx` si muove, con clamp agli estremi (nessun wrap) | +| `enter` | chiude l'overlay **e poi** esegue l'azione selezionata | +| `esc` / `ctrl+p` | chiude l'overlay; nessun altro stato cambia | +| `ctrl+c` | esce dal programma (intercettato prima, §5.2) | + +La condizione esatta per «rune stampabile» è +`(msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) && !msg.Alt`, e le rune si leggono +sempre da `msg.Runes`. Lo spazio ha bisogno del suo ramo: bubbletea v1 riclassifica una +rune sola pari a `' '` come `KeySpace` pur lasciandola in `Runes` +(`bubbletea@v1.3.10/key.go:698-702`). Senza quel ramo la query non potrebbe contenere +spazi, e `"log h"` non troverebbe niente. + +L'ordine su `enter` conta: l'overlay va chiuso **prima** di eseguire, altrimenti l'azione +cambia schermata e la palette resta disegnata sopra la nuova. + +`openPalette` azzera anche `m.helpAll`. Il footer full occupa più righe di quello breve, e +la formula d'altezza della §8.2 sottrae **due** righe (riga vuota + footer) presumendone +una sola: aprire la palette con l'aiuto esteso già aperto sballerebbe il conto. Chiudere +l'aiuto esteso è anche la cosa giusta a prescindere — sono due modi di rispondere alla +stessa domanda, e non serve mostrarli insieme. + +`paletteKeys(d)` assegna `Back` (esc), `Confirm` (enter), `Up`, `Down`, `ForceQuit` e +`Palette` — quest'ultimo perché `ctrl+p` chiude come esc — e lascia `Quit`, `Help` e ogni +altro binding non assegnati. In `short` va solo `↑/↓ move · enter run · esc close · +ctrl+c force quit`: `ctrl+p` chiude ma non si pubblicizza, perché `esc` è la via che tutte +le altre schermate insegnano già. + +> **Emendamento (scritto durante il piano).** I campi `Up`/`Down` della `keyMap` della +> palette **non** possono ricevere `d.Up`/`d.Down`: quei default valgono `up`/`k` e +> `down`/`j`, quindi scrivere `j` o `k` nella query muoverebbe il cursore invece di +> digitare una lettera. Servono due default nuovi in `keyDefaults`, solo frecce: +> +> ```go +> PaletteUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "move up")), +> PaletteDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "move down")), +> ``` +> +> assegnati ai campi `Up`/`Down` della keyMap della palette (nessun campo nuovo in +> `keyMap`, quindi `TestAllBindingsCoversEveryField` non cambia; `keyDefaults` non ha un +> test di parità per riflessione). `TestPaletteTypesJAndK` va scritto contro la versione +> che usa `d.Up`/`d.Down` e verificato fallente. +> +> Il backspace **non** diventa un binding: è editing di testo, che nessuna delle dieci +> schermate con un `textinput` focalizzato pubblicizza. `updateOverlay` controlla +> `msg.Type == tea.KeyBackspace` direttamente. + +--- + +## 9. Test + +### 9.1 `internal/fuzzy` + +Table-driven: query vuota; nessun match; match esatto; sottosequenza; case-insensitivity in +entrambe le direzioni; bonus di inizio parola; bonus consecutivo; **ottimalità della DP** +(`"rt"` su `"report table"` → `[0, 7]`, il caso che smaschera greedy); target non-ASCII con +rune multibyte (gli indici sono indici di **rune**, non di byte); query più lunga del target. + +### 9.2 `overlay.go` + +I quattro casi limite della §4.2, ognuno **verificato fallente** contro l'implementazione +che lo ignora, con la trascrizione allegata al report dell'implementer. Più: splice a +`x == 0`; box più largo del corpo; larghezza di ogni riga composta pari a +`max(larghezzaCorpo, x+larghezzaBox)`. + +Il test di larghezza vuole **due** fixture: una ASCII e una con un glifo wide (CJK) messo +a cavallo di entrambi i bordi del box. Con la sola fixture ASCII quel test passa contro il +bug del taglio straddle della §4.1 — che è precisamente il modo in cui, nella tranche C, un +test di larghezza è passato sopra un overflow da 78 colonne in un terminale da 60. + +### 9.3 `actions.go` + +Le azioni di schermata coincidono con i binding abilitati di `paletteBindings()` su ogni +schermata; `TestEveryPaletteBindingIsReplayable` (§6.1); l'azione globale col bersaglio +uguale a `m.screen` sparisce; i gate (`Go to members` solo team, i cinque +`len(m.entries) > 0`); l'etichetta ha l'iniziale maiuscola anche con una prima rune +multibyte. + +### 9.4 `palette.go` e integrazione + +`ctrl+p` apre e non tocca `m.nav`; `esc` chiude e non fa `pop()`; scrivere `q` non chiude +il programma (test di regressione, **non** un RED — vedi §5.2); +`TestPaletteCtrlPClosesRatherThanReopening` (§5.2, questo sì scritto contro l'ordine +sbagliato e verificato fallente); `TestPaletteKeepsScreenActionsWhileTyping` (§5.2b, +scritto contro la versione che chiama `keysFor` e verificato fallente); `?` diventa un +carattere della query e non ribalta `m.helpAll`; **lo spazio entra nella query** +(`tea.KeySpace`, §8.4); `j` e `k` sono lettere, non movimenti (emendamento §8.4); +backspace su query vuota è un no-op; `↑`/`↓` clampano; la finestra di scorrimento tiene il +cursore visibile con più azioni che righe; `TestPaletteHighlightSurvivesTruncation` (§8.3, +con una query che aggancia rune **oltre** il taglio, altrimenti il ramo non viene +esercitato); l'evidenziazione usa davvero `th.Accent` (renderer con profilo a colori, non +`testTheme`, che fissa Ascii); `enter` su lista vuota è un no-op; `enter` su un'azione di +schermata produce lo stesso `Model` della pressione diretta del tasto; `enter` su +un'azione globale apre la schermata giusta; `ctrl+p` non apre su Setup, Loading, Error né +su `entriesConfirmDelete`; **un 401 che rilancia il wizard dissolve la palette** e +**un `retryableErrMsg` che atterra su `screenError` la dissolve** (§5.2c); il box scende +quando il corpo è più alto del terminale (§8.2). + +Golden nuovi: `palette_report` (palette sopra il Report a larghezza fissa), +`palette_filtered` (con query), `palette_no_match`, `palette_narrow`. +Golden rigenerati: `footer_*_full` delle schermate che ora annunciano `ctrl+p`. + +I golden si rigenerano **solo** con `go test ./internal/tui -update`, mai a mano. + +### 9.5 Parità con la demo + +`CLICKUP_DEMO=1` deve dare la stessa palette: nessuna azione fa I/O all'apertura, e le +azioni globali che ne fanno (`Go to filters` → arricchimento stati, `Go to members`) +passano già dai rami demo dei rispettivi `openX()`. Un test in demo mode lo verifica. + +--- + +## 10. Fuori scope, dichiarato + +- **`?` come pannello flottante** — decisione definitiva, §2.3, nessuna issue aperta. +- **Switcher mese/lista/membro di #71** — issue nuova nella milestone v1.12. +- **I siti di troncamento preesistenti di #135** — quindici, su `budget.go`, + `rates_view.go`, `entries.go` e `log.go` — restano a #135. Questa tranche ne introduce + uno nuovo e lo fa giusto; non ripara i vecchi. +- **Un secondo tipo di overlay.** `overlayKind` è un iota con due valori perché ne esiste + un cliente. Il terzo valore lo aggiunge chi porta il terzo cliente. +- **Ricerca nel testo del report, azioni componibili, cronologia dei comandi.** Nessuna + delle tre è in #71. + +--- + +## 11. Trappole ereditate da onorare + +Dalle tranche precedenti, e valide anche qui: + +1. **Un test scritto contro un bug specifico va verificato fallente contro quel bug**, con + la trascrizione allegata al report. Nella tranche C quattro test sono stati colti a + passare sul bug che dovevano prendere. +2. **I golden sono ciechi al colore.** `TestMain` fissa `termenv.Ascii`, che toglie sfondi + *e* grassetto. Ogni decisione di colore vuole un test diretto sullo `lipgloss.Style` + restituito. +3. **Larghezza di visualizzazione ≠ conteggio di rune.** Coincidono solo in ASCII. +4. **Nessuna funzione di stile chiama `lipgloss.NewStyle()`**: si passa dal `theme`, che + porta il renderer iniettato. `th.Cell` esiste per questo. +5. **`Co-Authored-By` non compare in nessun messaggio di commit.** +6. Tutto ciò che vive nel repo è in **inglese** — codice, commenti, stringhe UI, messaggi + dei test, messaggi di commit. Questa spec è l'eccezione storica di `docs/superpowers/`. diff --git a/go.mod b/go.mod index 0b34863..b3315ad 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 golang.org/x/time v0.15.0 @@ -16,7 +17,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect diff --git a/internal/fuzzy/fuzzy.go b/internal/fuzzy/fuzzy.go new file mode 100644 index 0000000..bf5cd5d --- /dev/null +++ b/internal/fuzzy/fuzzy.go @@ -0,0 +1,133 @@ +// Package fuzzy scores a query against a candidate string the way a command +// palette needs it: case-insensitive subsequence matching that also reports +// which runes matched, so the caller can highlight them. +// +// The package is pure — no I/O, nothing outside the standard library — for the +// same reason internal/report and internal/duration are: the interesting part +// is an algorithm, and an algorithm is worth testing without a Model. +package fuzzy + +import ( + "math" + "unicode" +) + +// Scoring weights. See the spec's section 7.2. +const ( + // consecutiveBonus rewards a rune that immediately follows the previous match. + consecutiveBonus = 10 + // boundaryBonus rewards a match that starts a word. + boundaryBonus = 8 + // maxLeadPenalty caps "how far into the target the match starts". Without a + // cap, a long label loses to a worse match that merely starts further left. + maxLeadPenalty = 10 +) + +// noMatch marks an unreachable cell of the table. It is halved so that adding a +// bonus to it can never wrap around into a plausible score. +const noMatch = math.MinInt / 2 + +// Match reports whether query matches target as a case-insensitive +// subsequence. When ok, score ranks the match and idx holds the rune indices of +// target that were matched, in ascending order, one per query rune. +// +// An empty query always matches, with score 0 and a nil idx. +// +// The search is exhaustive rather than greedy because idx is not only a ranking +// input: it decides which runes the caller highlights. Greedy matching of "rt" +// against "report table" yields [0, 5] — repor[t] — where the right answer is +// [0, 7], [r]eport [t]able. +func Match(query, target string) (score int, idx []int, ok bool) { + q := lowerRunes(query) + if len(q) == 0 { + return 0, nil, true + } + t := lowerRunes(target) + if len(q) > len(t) { + return 0, nil, false + } + + // best[j][i] is the highest score for matching q[:j+1] with q[j] landing on + // t[i]. from[j][i] is the target index q[j-1] landed on along that path, + // which is what lets idx be reconstructed at the end. + best := make([][]int, len(q)) + from := make([][]int, len(q)) + for j := range best { + best[j] = make([]int, len(t)) + from[j] = make([]int, len(t)) + for i := range best[j] { + best[j][i] = noMatch + from[j][i] = -1 + } + } + + for i := range t { + if t[i] != q[0] { + continue + } + best[0][i] = boundaryScore(t, i) - min(i, maxLeadPenalty) + } + for j := 1; j < len(q); j++ { + for i := j; i < len(t); i++ { + if t[i] != q[j] { + continue + } + for p := j - 1; p < i; p++ { + if best[j-1][p] == noMatch { + continue + } + s := best[j-1][p] + boundaryScore(t, i) + if p == i-1 { + s += consecutiveBonus + } + // Strictly greater keeps the leftmost path on a tie, which is + // what makes the result deterministic. + if s > best[j][i] { + best[j][i], from[j][i] = s, p + } + } + } + } + + last := len(q) - 1 + end := -1 + score = noMatch + for i := range t { + if best[last][i] > score { + end, score = i, best[last][i] + } + } + if end < 0 { + return 0, nil, false + } + idx = make([]int, len(q)) + for j, i := last, end; j >= 0; j-- { + idx[j] = i + i = from[j][i] + } + return score, idx, true +} + +// boundaryScore is the word-start bonus for t[i]: index 0, or a rune that +// follows a separator. +func boundaryScore(t []rune, i int) int { + if i == 0 { + return boundaryBonus + } + switch t[i-1] { + case ' ', '-', '_', '/', '.': + return boundaryBonus + } + return 0 +} + +// lowerRunes decomposes s into lower-cased runes. Indices into the result are +// rune indices, which is what Match reports and what a caller highlighting +// characters needs — byte offsets would be wrong for any accented label. +func lowerRunes(s string) []rune { + r := []rune(s) + for i := range r { + r[i] = unicode.ToLower(r[i]) + } + return r +} diff --git a/internal/fuzzy/fuzzy_test.go b/internal/fuzzy/fuzzy_test.go new file mode 100644 index 0000000..c4aa74c --- /dev/null +++ b/internal/fuzzy/fuzzy_test.go @@ -0,0 +1,98 @@ +package fuzzy + +import ( + "slices" + "testing" +) + +func TestMatch(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + query string + target string + wantOK bool + wantIdx []int + }{ + {"empty query matches anything", "", "Export report", true, nil}, + {"empty query matches an empty target", "", "", true, nil}, + {"query longer than target", "exports", "exp", false, nil}, + {"no match at all", "zz", "Export report", false, nil}, + {"exact prefix", "exp", "Export report", true, []int{0, 1, 2}}, + {"query upper, target lower", "EXP", "export report", true, []int{0, 1, 2}}, + {"query lower, target upper", "exp", "EXPORT REPORT", true, []int{0, 1, 2}}, + {"subsequence with gaps", "ert", "Export report", true, []int{0, 4, 5}}, + // Greedy matching returns [0, 5] here: r at 0, then the first t, the one + // inside "report" — highlighting repor[t]. The word-start bonus is what + // makes [0, 7] win instead: [r]eport [t]able. This row is the reason + // Match is a dynamic program and not a left-to-right scan. + {"prefers a word start over the nearest rune", "rt", "report table", true, []int{0, 7}}, + // idx is in RUNES. With byte offsets this row would be [0, 5]. + {"multibyte target", "cà", "città", true, []int{0, 4}}, + {"whole target", "abc", "abc", true, []int{0, 1, 2}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, idx, ok := Match(tc.query, tc.target) + if ok != tc.wantOK { + t.Fatalf("Match(%q, %q) ok = %v, want %v", tc.query, tc.target, ok, tc.wantOK) + } + if !ok { + return + } + if !slices.Equal(idx, tc.wantIdx) { + t.Errorf("Match(%q, %q) idx = %v, want %v", tc.query, tc.target, idx, tc.wantIdx) + } + }) + } +} + +// TestMatchScoreIsExact pins the arithmetic on the smallest case that exercises +// both bonuses, so the weights cannot drift unnoticed while the orderings in +// TestMatchRanks still happen to hold. +func TestMatchScoreIsExact(t *testing.T) { + t.Parallel() + // a at 0: word start (+8), no lead penalty. b at 1: consecutive (+10). + score, _, ok := Match("ab", "ab") + if !ok { + t.Fatal(`Match("ab", "ab") did not match`) + } + if score != 18 { + t.Errorf("score = %d, want 18", score) + } +} + +// TestMatchRanks pins the orderings the palette depends on. These survive a +// tweak to the weights; TestMatchScoreIsExact does not. That split is +// deliberate: the constants are tunable, the orderings are not. +func TestMatchRanks(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + query string + better string + worse string + }{ + // "Export", not "Export report": screen actions are labeled + // capitalize(binding.Help().Desc), and Export's description is "export". + {"a screen action beats the navigation row", "exp", "Export", "Go to export"}, + {"a prefix beats a match buried inside", "rep", "Report", "Go to prep"}, + {"consecutive beats scattered", "ab", "ab c", "a x b"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + b, _, ok := Match(tc.query, tc.better) + if !ok { + t.Fatalf("Match(%q, %q) did not match", tc.query, tc.better) + } + w, _, ok := Match(tc.query, tc.worse) + if !ok { + t.Fatalf("Match(%q, %q) did not match", tc.query, tc.worse) + } + if b <= w { + t.Errorf("Match(%q, %q) = %d, want more than Match(%q, %q) = %d", + tc.query, tc.better, b, tc.query, tc.worse, w) + } + }) + } +} diff --git a/internal/report/aggregate_test.go b/internal/report/aggregate_test.go index fd188fe..8ef3708 100644 --- a/internal/report/aggregate_test.go +++ b/internal/report/aggregate_test.go @@ -50,7 +50,7 @@ func TestBuildTotal(t *testing.T) { t.Fatalf("total amount = %v, want 300", r.TotalAmount) } if len(r.Buckets) != 1 || r.Buckets[0].Label != "Total" { - t.Fatalf("total should have one bucket labelled Total, got %+v", r.Buckets) + t.Fatalf("total should have one bucket labeled Total, got %+v", r.Buckets) } if r.Timezone != "UTC" { t.Errorf("nil loc should default to UTC, got %q", r.Timezone) diff --git a/internal/report/budget.go b/internal/report/budget.go index 3863a3e..4215486 100644 --- a/internal/report/budget.go +++ b/internal/report/budget.go @@ -23,7 +23,7 @@ type BudgetLine struct { // exactly as it does when the money is computed. // // A budgeted list with no hours in the period has no entry in listNames (the -// caller derives them from the report's buckets) — it is labelled by its id +// caller derives them from the report's buckets) — it is labeled by its id // rather than left blank, because an untouched budget is precisely the row a // user opens this view to see. func BudgetLines(billedByList, budgets map[string]float64, p Pricing, listNames map[string]string) []BudgetLine { diff --git a/internal/report/budget_test.go b/internal/report/budget_test.go index 7dd3a54..c381ffb 100644 --- a/internal/report/budget_test.go +++ b/internal/report/budget_test.go @@ -94,7 +94,7 @@ func TestBudgetLinesCurrencyResolution(t *testing.T) { } // TestBudgetLinesNameFallsBackToListID pins that a budgeted list with no hours -// in the period -- the most interesting row, an untouched budget -- is labelled +// in the period -- the most interesting row, an untouched budget -- is labeled // by its id instead of rendering as a blank label. func TestBudgetLinesNameFallsBackToListID(t *testing.T) { lines := BudgetLines(nil, map[string]float64{"901": 1000}, Pricing{DefaultCurrency: "EUR"}, nil) diff --git a/internal/report/period.go b/internal/report/period.go index 7cf6a6e..dd4f490 100644 --- a/internal/report/period.go +++ b/internal/report/period.go @@ -34,7 +34,7 @@ func midnightIn(t time.Time, loc *time.Location) time.Time { // treated as UTC. // // isoWeek is not bounds-checked: it is used as an offset from week 1, so an -// out-of-range value simply lands in the neighbouring year. Callers must pass a +// out-of-range value simply lands in the neighboring year. Callers must pass a // week they obtained from time.ISOWeek or otherwise validated. func WeekRange(isoYear, isoWeek int, loc *time.Location) (start, end time.Time) { loc = normLoc(loc) diff --git a/internal/service/update.go b/internal/service/update.go index 7bfa5b1..dad5e78 100644 --- a/internal/service/update.go +++ b/internal/service/update.go @@ -44,7 +44,7 @@ type updateCache struct { Latest string `json:"latest"` } -// defaultCachePath returns the cache location. os.UserCacheDir honours +// defaultCachePath returns the cache location. os.UserCacheDir honors // XDG_CACHE_HOME on Linux and gives the right directory on macOS, mirroring // how internal/config uses os.UserConfigDir rather than a hardcoded path. func defaultCachePath() (string, error) { diff --git a/internal/tui/actions.go b/internal/tui/actions.go new file mode 100644 index 0000000..59fbe3d --- /dev/null +++ b/internal/tui/actions.go @@ -0,0 +1,175 @@ +package tui + +import ( + "unicode" + + tea "github.com/charmbracelet/bubbletea" +) + +// action is one row of the command palette (#71). +// +// run returns tea.Model rather than Model on purpose: that is routeKey's own +// signature, so a screen action is a direct forward with no type assertion in +// between. +type action struct { + label string // "Export", "Go to rates" + hint string // the key that also does this, "" for a navigation row + run func(Model) (tea.Model, tea.Cmd) +} + +// paletteActions is everything ctrl+p offers right now: the current screen's +// commands first, then the cross-screen navigation. With an empty query every +// score is 0, so this order survives into the rendered list — "what can I do +// here" above "take me elsewhere". +func paletteActions(m Model) []action { + screen := screenActions(m) + global := globalActions(m) + out := make([]action, 0, len(screen)+len(global)) + out = append(out, screen...) + return append(out, global...) +} + +// screenActions derives the current screen's commands from its keymap rather +// than from a second list that would have to be kept in step with it. +// +// Three things fall out of that for free: the gating (a disabled binding simply +// is not here), the wording (the label is the footer's own description, so the +// two surfaces cannot drift), and the behavior (run replays the key, so there +// is no duplicated handler to diverge). +// +// screenKeys, not keysFor: with the palette open keysFor answers for the +// palette, and this list would empty itself on the first keystroke. +func screenActions(m Model) []action { + var out []action + for _, b := range screenKeys(m).paletteBindings() { + if !b.Enabled() { + continue + } + keys := b.Keys() + if len(keys) == 0 { + continue + } + msg, ok := keyMsgFor(keys[0]) + if !ok { + // Unreachable today, pinned by TestEveryPaletteBindingIsReplayable. + // Dropping beats firing a KeyMsg that matches nothing. + continue + } + out = append(out, action{ + label: capitalize(b.Help().Desc), + hint: b.Help().Key, + run: func(m Model) (tea.Model, tea.Cmd) { return m.routeKey(msg) }, + }) + } + return out +} + +// globalActions is the cross-screen navigation ctrl+p offers from anywhere. +// +// The rule this list obeys, and the reason it is short: global actions are +// navigation only. Anything that changes state stays a screen action, where the +// footer teaches it and the palette repeats it. +// +// Each row opens its screen through the same openX method the key handler uses +// (see open.go), so the two can never build a screen differently. +func globalActions(m Model) []action { + hasReport := len(m.entries) > 0 + rows := []struct { + label string + target screen + enabled bool + run func(Model) (tea.Model, tea.Cmd) + }{ + {"Go to home", screenHome, true, func(m Model) (tea.Model, tea.Cmd) { + return m.resetTo(screenHome), nil + }}, + {"Go to report", screenReport, hasReport, func(m Model) (tea.Model, tea.Cmd) { + return m.goTo(screenReport), nil + }}, + {"Go to rates", screenRates, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openRates(), nil + }}, + {"Go to budgets", screenBudget, hasReport, func(m Model) (tea.Model, tea.Cmd) { + // A false return means the config's pricing or timezone failed to + // parse, and openBudgetView has already routed to screenError — + // which is the right landing either way, so there is nothing to add. + m.openBudgetView() + return m, nil + }}, + {"Go to filters", screenFilters, hasReport, func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openFilters() + return mm, cmd + }}, + {"Go to entries", screenEntries, hasReport, func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openEntries() + return mm, cmd + }}, + {"Go to export", screenExport, hasReport, func(m Model) (tea.Model, tea.Cmd) { + return m.openExport(), nil + }}, + {"Go to range", screenRange, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openRange(), nil + }}, + {"Go to members", screenMembers, m.scope == "team", func(m Model) (tea.Model, tea.Cmd) { + mm, cmd := m.openMembers(m.screen) + return mm, cmd + }}, + // "Go to log hours", not "Log hours": on Home and Report the screen + // keymap already yields an action labeled exactly "Log hours", and two + // identical rows in one list read as a bug rather than as the legible + // do-it-here / take-me-there pair the other duplicates form. + {"Go to log hours", screenLog, true, func(m Model) (tea.Model, tea.Cmd) { + return m.openLog(), nil + }}, + } + + var out []action + for _, r := range rows { + if !r.enabled || r.target == m.screen { + continue + } + out = append(out, action{label: r.label, run: r.run}) + } + // Quit is the one row that is not navigation, so it has no target screen to + // compare against and is appended rather than given a fake one. + return append(out, action{label: "Quit", run: func(m Model) (tea.Model, tea.Cmd) { + return m, tea.Quit + }}) +} + +// keyMsgFor rebuilds the tea.KeyMsg a binding's first key would produce. +// +// The set is closed on purpose. key.Matches compares msg.String() against the +// binding's key strings, and these four round-trip exactly. A KeyRunes message +// carrying "tab" would round-trip too, but every handler that reads msg.Type +// rather than key.Matches would then see the wrong thing — so the palette +// leaves those bindings out instead of forging their messages. ok is false for +// anything else, so an action that cannot be executed faithfully is dropped +// rather than mis-fired. +func keyMsgFor(s string) (tea.KeyMsg, bool) { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter}, true + // PrevMonth and NextMonth lead with "left" and "right" (keys.go:124-125), + // and they are the only two palette bindings whose first key is not a rune. + case "left": + return tea.KeyMsg{Type: tea.KeyLeft}, true + case "right": + return tea.KeyMsg{Type: tea.KeyRight}, true + } + if r := []rune(s); len(r) == 1 { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: r}, true + } + return tea.KeyMsg{}, false +} + +// capitalize upper-cases the first rune. strings.ToUpper on s[:1] would corrupt +// any label whose first character is multibyte. +func capitalize(s string) string { + if s == "" { + return "" + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} diff --git a/internal/tui/actions_test.go b/internal/tui/actions_test.go new file mode 100644 index 0000000..dc07e45 --- /dev/null +++ b/internal/tui/actions_test.go @@ -0,0 +1,240 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/marcoarnulfo/clickup-cli/internal/clickup" +) + +func labels(as []action) []string { + out := make([]string, len(as)) + for i, a := range as { + out[i] = a.label + } + return out +} + +func hasLabel(as []action, want string) bool { + for _, a := range as { + if a.label == want { + return true + } + } + return false +} + +func TestKeyMsgFor(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + wantOK bool + want string + }{ + {"g", true, "g"}, + {"enter", true, "enter"}, + // PrevMonth and NextMonth are "left"/"h" and "right"/"l", so their + // first key is not a rune. Without these two the palette would silently + // drop both month-navigation actions on Home. + {"left", true, "left"}, + {"right", true, "right"}, + {"tab", false, ""}, + {"shift+tab", false, ""}, + {"up", false, ""}, + {"", false, ""}, + } { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + msg, ok := keyMsgFor(tc.in) + if ok != tc.wantOK { + t.Fatalf("keyMsgFor(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) + } + // The round trip is the whole point: key.Matches compares + // msg.String() against the binding's key strings. + if ok && msg.String() != tc.want { + t.Errorf("keyMsgFor(%q).String() = %q, want %q", tc.in, msg.String(), tc.want) + } + }) + } +} + +func TestScreenActionsComeFromTheScreenKeymap(t *testing.T) { + t.Parallel() + got := labels(screenActions(newTestModelOnReport())) + for _, want := range []string{"Grouping", "Export", "Budgets", "Filters", "Entries"} { + if !hasLabel(screenActions(newTestModelOnReport()), want) { + t.Errorf("the report screen offers no %q action; got %v", want, got) + } + } + // Cursor mechanics are not commands. + for _, unwanted := range []string{"Move up", "Move down", "Confirm", "Back", "Help", "Quit"} { + if hasLabel(screenActions(newTestModelOnReport()), unwanted) { + t.Errorf("%q leaked into the palette; it is cursor mechanics, not a command", unwanted) + } + } +} + +// A disabled binding must not appear. This is what makes the palette's gating +// free: the same Enabled() that decides the footer decides the palette. +func TestScreenActionsRespectEnablement(t *testing.T) { + t.Parallel() + m := newTestModel() + m.screen = screenHome + m.scope = "me" + if hasLabel(screenActions(m), "Select members") { + t.Error("the members action appears in the me scope, where its binding is disabled") + } + m.scope = "team" + if !hasLabel(screenActions(m), "Select members") { + t.Error("the members action is missing in the team scope, where its binding is enabled") + } +} + +// Labels are the footer's own words with the first rune upper-cased, so the two +// surfaces cannot drift. ToUpper on the first BYTE would corrupt any label that +// starts with a multibyte rune. +func TestCapitalizeHandlesMultibyte(t *testing.T) { + t.Parallel() + if got := capitalize("èxport"); got != "Èxport" { + t.Errorf("capitalize(%q) = %q, want %q", "èxport", got, "Èxport") + } + if got := capitalize(""); got != "" { + t.Errorf("capitalize(%q) = %q, want %q", "", got, "") + } +} + +func TestGlobalActionsAreGated(t *testing.T) { + t.Parallel() + m := newTestModel() + m.screen = screenHome + m.entries = nil + for _, unwanted := range []string{"Go to report", "Go to budgets", "Go to filters", "Go to entries", "Go to export"} { + if hasLabel(globalActions(m), unwanted) { + t.Errorf("%q is offered with no entries loaded; there is no report to show", unwanted) + } + } + m.entries = goldenEntries() + for _, want := range []string{"Go to report", "Go to budgets", "Go to filters", "Go to entries", "Go to export"} { + if !hasLabel(globalActions(m), want) { + t.Errorf("%q is missing although entries are loaded", want) + } + } +} + +func TestGlobalActionsSkipTheCurrentScreen(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + if hasLabel(globalActions(m), "Go to report") { + t.Error(`"Go to report" is offered while already on the report screen`) + } + if !hasLabel(globalActions(m), "Go to rates") { + t.Error(`"Go to rates" is missing from the report screen`) + } +} + +func TestGlobalMembersActionIsTeamOnly(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.scope = "me" + if hasLabel(globalActions(m), "Go to members") { + t.Error(`"Go to members" is offered in the me scope`) + } + m.scope = "team" + if !hasLabel(globalActions(m), "Go to members") { + t.Error(`"Go to members" is missing in the team scope`) + } +} + +// Screen actions come first: "what can I do here" before "take me elsewhere". +func TestPaletteActionsPutScreenActionsFirst(t *testing.T) { + t.Parallel() + got := labels(paletteActions(newTestModelOnReport())) + firstGlobal := -1 + for i, l := range got { + if strings.HasPrefix(l, "Go to ") { + firstGlobal = i + break + } + } + if firstGlobal <= 0 { + t.Fatalf("expected screen actions before the first navigation row; got %v", got) + } + // Every global row is a "Go to …" except Quit, which is appended last. + for _, l := range got[firstGlobal:] { + if !strings.HasPrefix(l, "Go to ") && l != "Quit" { + t.Errorf("screen action %q appears after the navigation block: %v", l, got) + } + } +} + +// A screen action must do exactly what pressing its key does. This is the whole +// justification for deriving them instead of writing a second registry. +func TestScreenActionRunMatchesTheKeypress(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.report = goldenReport() + + var run func(Model) (tea.Model, tea.Cmd) + for _, a := range screenActions(m) { + if a.label == "Export" { + run = a.run + } + } + if run == nil { + t.Fatal(`no "Export" action on the report screen`) + } + viaAction, _ := run(m) + viaKey, _ := m.updateReport(keyMsg("e")) + if viaAction.(Model).screen != viaKey.(Model).screen { + t.Errorf("action landed on %v, the keypress on %v", + viaAction.(Model).screen, viaKey.(Model).screen) + } + if viaAction.(Model).screen != screenExport { + t.Errorf("screen = %v, want screenExport", viaAction.(Model).screen) + } +} + +// Quit is the one global action that is not navigation, so it is appended +// outside the target-skipping loop rather than given a fake target screen. +func TestQuitActionIsAlwaysOffered(t *testing.T) { + t.Parallel() + for _, s := range []screen{screenHome, screenReport, screenRates} { + m := newTestModelOnReport() + m.screen = s + if !hasLabel(globalActions(m), "Quit") { + t.Errorf("Quit is missing on %v", s) + } + } +} + +func TestGlobalMembersActionUsesTheCallersScreenAsOrigin(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.scope = "team" + m.screen = screenRates + m.teamMembers = nil + m.client = clickup.New("tok") + m.client.BaseURL = "http://127.0.0.1:1" // refused instantly, no test server needed + + var run func(Model) (tea.Model, tea.Cmd) + for _, a := range globalActions(m) { + if a.label == "Go to members" { + run = a.run + } + } + if run == nil { + t.Fatal(`no "Go to members" action`) + } + _, cmd := run(m) + if cmd == nil { + t.Fatal("no command issued with no members cached") + } + msg, ok := cmd().(retryableErrMsg) + if !ok { + t.Fatalf("command produced %T, want retryableErrMsg", cmd()) + } + if msg.origin != screenRates { + t.Errorf("origin = %v, want screenRates", msg.origin) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 2d12895..67ff4b0 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -36,6 +36,19 @@ const ( screenEntries ) +// overlayKind is the floating layer drawn over the current screen, orthogonal +// to m.screen: opening one does not touch m.nav and closing one is not a pop(). +// An overlay is not a place you navigated to. +// +// There are two values because there is one client. The third value arrives +// with the third client, not before. +type overlayKind int + +const ( + overlayNone overlayKind = iota + overlayPalette +) + // Async messages. type ( entriesMsg struct{ entries []report.TimeEntry } @@ -101,6 +114,12 @@ type Model struct { // screen; nothing renders it yet — Task 5 wires the footer into View(). helpAll bool + // overlay is the floating layer over m.screen, and palette is its state + // when overlay == overlayPalette (#71). While an overlay is open it owns + // the keyboard: see Update's tea.KeyMsg branch. + overlay overlayKind + palette paletteModel + // current selection year int month time.Month @@ -499,8 +518,9 @@ func pruneFilterSet(sel, present map[string]bool) map[string]bool { } // loadMembersCmd fetches the workspace members in the background and returns -// membersMsg or retryableErrMsg{origin, err}. It's Home-only today, so origin -// is always screenHome at the call site. +// membersMsg or retryableErrMsg{origin, err}. origin is the screen to return +// a failure to — Home for the key binding, the caller's own screen when the +// command palette opens it. func loadMembersCmd(c *clickup.Client, teamID string, origin screen) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -610,10 +630,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickCmd() case tea.KeyMsg: - if key.Matches(msg, keysFor(m).Quit) { + // ForceQuit first and unconditionally: with an overlay open it is the + // only way out that nothing else can intercept. + if key.Matches(msg, defaultKeys().ForceQuit) { return m, tea.Quit } - if key.Matches(msg, defaultKeys().ForceQuit) { + // An open overlay owns the keyboard, and this check MUST stay above + // Palette: below it, ctrl+p would call openPalette a second time and + // wipe the query instead of closing. + // TestPaletteCtrlPClosesRatherThanReopening pins the ordering. + if m.overlay != overlayNone { + return m.updateOverlay(msg) + } + if key.Matches(msg, keysFor(m).Quit) { return m, tea.Quit } // Checked here, beside Quit/ForceQuit, rather than inside routeKey: that @@ -627,6 +656,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.helpAll = !m.helpAll return m, nil } + if key.Matches(msg, keysFor(m).Palette) { + return m.openPalette(), nil + } return m.routeKey(msg) case errMsg: @@ -956,6 +988,13 @@ func (m Model) View() string { // says so in its own sentence instead. return body } + if m.overlay == overlayPalette { + // Composed over the BODY, not over the finished view: the footer stays + // below and visible, and it advertises the palette's own keys because + // keysFor follows the overlay. + box, x, y := m.palette.layout(m.theme, m.width, m.height, strings.Count(body, "\n")+1) + body = composite(body, box, x, y) + } // Screens differ on whether their body ends with a newline; trimming here // is what puts the footer the same distance below every one of them. return strings.TrimRight(body, "\n") + "\n\n" + footerView(m.theme, m.width, m.helpAll, keysFor(m)) diff --git a/internal/tui/budget_test.go b/internal/tui/budget_test.go index f34cc77..22db2f4 100644 --- a/internal/tui/budget_test.go +++ b/internal/tui/budget_test.go @@ -71,7 +71,7 @@ func TestBudgetBarColorsOverBudget(t *testing.T) { func TestBudgetKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenBudget} - want := []string{"?", "b", "ctrl+c", "esc", "q"} + want := []string{"?", "b", "ctrl+c", "ctrl+p", "esc", "q"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("budget labels = %v, want %v", got, want) } diff --git a/internal/tui/entries.go b/internal/tui/entries.go index 5d770f9..352cabe 100644 --- a/internal/tui/entries.go +++ b/internal/tui/entries.go @@ -128,12 +128,20 @@ func enterEditForm(es entriesModel, now time.Time, loc *time.Location) entriesMo return es } -// openEntries builds the browser from the filter-applied entries. (The lazy -// userID retry, when m.userID == 0, is dispatched by the caller in updateReport.) -func (m Model) openEntries() Model { +// openEntries opens the time-entry browser over the currently visible entries. +// +// It also issues the lazy currentUserCmd retry when the authenticated user is +// still unknown: ownership gating (which entries can be edited or deleted) +// stays off until that lands, and this is the only screen that needs it. That +// retry used to live in updateReport's OpenEntries case; it moved here so the +// command palette's "Go to entries" cannot forget it. +func (m Model) openEntries() (Model, tea.Cmd) { m.entriesScreen = entriesModel{entries: sortEntriesByStartDesc(m.visibleEntries())} m = m.goTo(screenEntries) - return m + if m.userID == 0 { + return m, m.currentUserCmd() + } + return m, nil } func (m Model) updateEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go index 1d3b8c3..abee77b 100644 --- a/internal/tui/entries_test.go +++ b/internal/tui/entries_test.go @@ -89,7 +89,7 @@ func TestEntriesListEscReturnsToReport(t *testing.T) { func TestEntriesListKeyLabels(t *testing.T) { t.Parallel() m := entriesFixture(true) - want := []string{"?", "ctrl+c", "down", "e", "esc", "h", "j", "k", "t", "up", "x"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "e", "esc", "h", "j", "k", "t", "up", "x"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("entries list-mode labels = %v, want %v", got, want) } @@ -103,7 +103,7 @@ func TestEntriesListEmptyKeyLabels(t *testing.T) { m := newTestModel() m.screen = screenEntries m.entriesScreen = entriesModel{} - want := []string{"?", "ctrl+c", "down", "esc", "j", "k", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "esc", "j", "k", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("entries empty-list labels = %v, want %v", got, want) } @@ -173,7 +173,7 @@ func TestEntriesHistoryModeKeyLabels(t *testing.T) { t.Parallel() m := entriesFixture(true) m.entriesScreen.mode = entriesHistory - want := []string{"?", "ctrl+c", "esc"} + want := []string{"?", "ctrl+c", "ctrl+p", "esc"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("entries history-mode labels = %v, want %v", got, want) } @@ -184,7 +184,7 @@ func TestEntriesTagsKeyLabels(t *testing.T) { t.Parallel() m := entriesFixture(true) m.entriesScreen.mode = entriesTags - want := []string{" ", "?", "ctrl+c", "down", "enter", "esc", "j", "k", "n", "up"} + want := []string{" ", "?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "n", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("entries tags labels = %v, want %v", got, want) } @@ -198,7 +198,7 @@ func TestEntriesTagsNewModeKeyLabels(t *testing.T) { m := entriesFixture(true) m.entriesScreen.mode = entriesTags m.entriesScreen.tagNewMode = true - want := []string{"ctrl+c", "enter", "esc"} + want := []string{"ctrl+c", "ctrl+p", "enter", "esc"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("entries tags new-mode labels = %v, want %v", got, want) } @@ -216,11 +216,11 @@ func TestEntriesEditKeyLabelsPerStep(t *testing.T) { step int want []string }{ - {"duration", 0, []string{"ctrl+c", "enter", "esc"}}, - {"date", 1, []string{"ctrl+c", "enter", "esc"}}, - {"time", 2, []string{"ctrl+c", "enter", "esc"}}, - {"note", 3, []string{"ctrl+c", "enter", "esc"}}, - {"billable", 4, []string{"?", "N", "Y", "ctrl+c", "enter", "esc", "n", "y"}}, + {"duration", 0, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"date", 1, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"time", 2, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"note", 3, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"billable", 4, []string{"?", "N", "Y", "ctrl+c", "ctrl+p", "enter", "esc", "n", "y"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/tui/export_test.go b/internal/tui/export_test.go index eb53355..30d0839 100644 --- a/internal/tui/export_test.go +++ b/internal/tui/export_test.go @@ -14,7 +14,7 @@ import ( func TestExportKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenExport} - want := []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "q", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "q", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("export labels = %v, want %v", got, want) } diff --git a/internal/tui/filters_test.go b/internal/tui/filters_test.go index 77fee00..395d3a3 100644 --- a/internal/tui/filters_test.go +++ b/internal/tui/filters_test.go @@ -176,7 +176,7 @@ func TestFiltersBillableANoOp(t *testing.T) { func TestFiltersKeyLabels(t *testing.T) { t.Parallel() m := filtersFixture() - want := []string{" ", "?", "a", "ctrl+c", "down", "enter", "esc", "j", "k", "q", "shift+tab", "tab", "up"} + want := []string{" ", "?", "a", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "q", "shift+tab", "tab", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("filters labels = %v, want %v", got, want) } diff --git a/internal/tui/footer.go b/internal/tui/footer.go index f1b056d..093e8e4 100644 --- a/internal/tui/footer.go +++ b/internal/tui/footer.go @@ -79,7 +79,7 @@ func pairHelp(a, b key.Binding, keyLabel, desc string) key.Binding { return p } -// anyKeyHelp returns a display-only binding for a handler whose behaviour is +// anyKeyHelp returns a display-only binding for a handler whose behavior is // triggered by the ABSENCE of a match — the delete confirmation's "any other // key cancels". No real binding can express that, and dropping it from the // footer would lose information the hand-written line carried. diff --git a/internal/tui/golden_test.go b/internal/tui/golden_test.go index 42e679f..c9c5b66 100644 --- a/internal/tui/golden_test.go +++ b/internal/tui/golden_test.go @@ -350,3 +350,46 @@ func TestGoldenLoading(t *testing.T) { m.screen = screenLoading golden(t, "loading", m.View()) } + +// goldenPaletteModel is a report screen with the palette open at a fixed size, +// so the box's position and contents are byte-stable. +func goldenPaletteModel() Model { + m := goldenModel() + m.theme = testTheme(true) + m.entries = goldenEntries() + m.report = goldenReport() + m.rep = newReport(m.report, "", goldenDaily()) + m.screen = screenReport + m.nav = []screen{screenHome} + m.width, m.height = 90, 30 + return m.openPalette() +} + +func TestGoldenPalette(t *testing.T) { + t.Parallel() + golden(t, "palette_report", goldenPaletteModel().View()) +} + +func TestGoldenPaletteFiltered(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.palette.query = "exp" + m = m.refreshPalette() + golden(t, "palette_filtered", m.View()) +} + +func TestGoldenPaletteNoMatch(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.palette.query = "zzzzzz" + m = m.refreshPalette() + golden(t, "palette_no_match", m.View()) +} + +// A terminal narrower than the preferred box, to pin the floor and the centering. +func TestGoldenPaletteNarrow(t *testing.T) { + t.Parallel() + m := goldenPaletteModel() + m.width = 40 + golden(t, "palette_narrow", m.View()) +} diff --git a/internal/tui/home.go b/internal/tui/home.go index b09e9ce..defc589 100644 --- a/internal/tui/home.go +++ b/internal/tui/home.go @@ -52,8 +52,7 @@ func (m Model) updateHome(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.periodMode = periodModeWeek } case key.Matches(msg, k.Range): - m.rangeScreen = newRange(m.preset) - m = m.goTo(screenRange) + m = m.openRange() return m, nil case key.Matches(msg, k.ToggleScope): if m.scope == "me" { @@ -62,8 +61,7 @@ func (m Model) updateHome(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.scope = "me" } case key.Matches(msg, k.LogHours): - m.logScreen = newLog(m.entries, m.cfg) - m = m.goTo(screenLog) + m = m.openLog() case key.Matches(msg, k.Timer): // The running-timer gate also lives entirely in homeKeys' Enabled() // now (see TestHomeTimerKeyMatchesGuard). @@ -72,19 +70,8 @@ func (m Model) updateHome(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.logScreen.step = logTimerRunning m = m.goTo(screenLog) case key.Matches(msg, k.Members): - // The team-scope gate lives entirely in homeKeys' Enabled() now (see - // TestHomeMembersKeyIsTeamScopeOnly). - if len(m.teamMembers) > 0 { - m.membersScreen = newMembers(m.teamMembers, m.selectedMembers) - m = m.goTo(screenMembers) - return m, nil - } - m.membersScreen = membersModel{loading: true} - m = m.goTo(screenMembers) - if m.demo { - return m, demoMembersCmd() - } - return m, loadMembersCmd(m.client, m.cfg.WorkspaceID, screenHome) + mm, cmd := m.openMembers(screenHome) + return mm, cmd case key.Matches(msg, k.Generate): m.home.errText = "" // clear any previous inline error before retrying m = m.replace(screenLoading) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3e1ea65..bcc4cd8 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -31,6 +31,17 @@ type keyDefaults struct { // textinput, an any-key confirmation, Error's own catch-all). Help key.Binding + // Palette opens the command palette (#71) and, while it is open, closes it + // again. Unlike Help its enablement varies by screen only in that three + // screens switch it off entirely: see keysFor. + Palette key.Binding + + // PaletteUp/PaletteDown are the palette's cursor keys, arrows ONLY. The + // generic Up/Down also accept k and j, which the palette needs as query + // characters — a filter you cannot type "kanban" into is not a filter. + PaletteUp key.Binding + PaletteDown key.Binding + // Home (home.go's updateHome switch, migrated in full here since Home is // the one screen this task fully covers). PrevMonth key.Binding @@ -121,6 +132,10 @@ func defaultKeys() keyDefaults { ForceQuit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "force quit")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Palette: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "command palette")), + PaletteUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "move up")), + PaletteDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "move down")), + PrevMonth: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("◂/h", "prev month (this_month only)")), NextMonth: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("▸/l", "next month (this_month only)")), ToggleWeek: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "this week/month")), @@ -185,6 +200,7 @@ type keyMap struct { Back key.Binding ForceQuit key.Binding Help key.Binding + Palette key.Binding PrevMonth key.Binding NextMonth key.Binding @@ -254,7 +270,7 @@ func (k keyMap) FullHelp() [][]key.Binding { return k.full } // instead. Nothing renders FullHelp() yet — Task 4's ? overlay is what will. func (k keyMap) allBindings() []key.Binding { return []key.Binding{ - k.Quit, k.Back, k.ForceQuit, k.Help, + k.Quit, k.Back, k.ForceQuit, k.Help, k.Palette, k.PrevMonth, k.NextMonth, k.ToggleWeek, k.Range, k.ToggleScope, k.LogHours, k.Timer, k.Members, k.Generate, k.Up, k.Down, k.Confirm, k.ToggleItem, k.SelectAll, k.NextField, k.PrevField, @@ -265,13 +281,27 @@ func (k keyMap) allBindings() []key.Binding { } } -// keysFor returns the bindings the given Model state accepts right now. It +// keysFor returns the bindings the given Model state accepts right now, and is +// what both Update and the footer read. +// +// An open overlay owns the keyboard, so it answers before the screen does. +// screenActions deliberately calls screenKeys instead: it derives the palette's +// own rows from the screen underneath, and asking keysFor there would hand it +// the palette's four keys and empty the list on the first keystroke. +func keysFor(m Model) keyMap { + if m.overlay == overlayPalette { + return paletteKeys(defaultKeys()) + } + return screenKeys(m) +} + +// screenKeys returns the bindings the given Model state accepts right now. It // is pure: nothing is cached, so there is no second copy of the truth to // keep in sync. Contextual gating lives here AND in the handlers' guards — // once a binding is disabled key.Matches fails and the guard is // unreachable, so enablement is load-bearing for behavior, not just for // display. -func keysFor(m Model) keyMap { +func screenKeys(m Model) keyMap { d := defaultKeys() switch m.screen { case screenHome: @@ -329,6 +359,7 @@ func homeKeys(m Model, d keyDefaults) keyMap { Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, + Palette: d.Palette, PrevMonth: d.PrevMonth, NextMonth: d.NextMonth, ToggleWeek: d.ToggleWeek, @@ -356,7 +387,7 @@ func homeKeys(m Model, d keyDefaults) keyMap { {monthPair, k.ToggleWeek}, {k.Range, k.ToggleScope, k.Members}, {k.Generate, k.LogHours, k.Timer}, - {k.Help, k.Quit}, + {k.Help, k.Quit, k.Palette}, } return k } @@ -401,56 +432,56 @@ func logKeys(m Model, d keyDefaults) keyMap { lg := m.logScreen switch lg.step { case logModeSelect: - k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, PickGuided: d.PickGuided, PickByID: d.PickByID, PickTimer: d.PickTimer} + k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, PickGuided: d.PickGuided, PickByID: d.PickByID, PickTimer: d.PickTimer} k.short = []key.Binding{k.PickGuided, k.PickByID, k.PickTimer, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.PickGuided, k.PickByID, k.PickTimer}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.PickGuided, k.PickByID, k.PickTimer}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k case logTimerPick: - k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, PickGuided: d.PickGuided, PickByID: d.PickByID} + k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, PickGuided: d.PickGuided, PickByID: d.PickByID} k.short = []key.Binding{k.PickGuided, k.PickByID, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.PickGuided, k.PickByID}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.PickGuided, k.PickByID}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k case logListPick, logTaskPick: - k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Up: d.Up, Down: d.Down, Confirm: d.Confirm} + k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, Up: d.Up, Down: d.Down, Confirm: d.Confirm} pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{pair, k.Confirm, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k case logIDInput: // A focused textinput takes '?' as a character, so Help stays // unassigned here. - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} k.short = []key.Binding{k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k case logForm: if lg.formField == 3 { // billable toggle (keypress, not a text field) - k := keyMap{Yes: d.Yes, No: d.No, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Yes: d.Yes, No: d.No, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} k.short = []key.Binding{k.Yes, k.No, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.Yes, k.No}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Yes, k.No}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } // Every other form field is a focused textinput, so Help stays // unassigned here too. - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} k.short = []key.Binding{k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k case logTimerRunning: - k := keyMap{StopTimer: d.StopTimer, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{StopTimer: d.StopTimer, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} k.short = []key.Binding{k.StopTimer, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.StopTimer}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.StopTimer}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k case logDone: - k := keyMap{Reload: d.Reload, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Reload: d.Reload, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} k.short = []key.Binding{k.Reload, k.Confirm, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.Reload, k.Confirm}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Reload, k.Confirm}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } return keyMap{} @@ -486,44 +517,44 @@ func entriesKeys(m Model, d keyDefaults) keyMap { case entriesEdit: if es.editStep == 4 { - k := keyMap{Yes: d.Yes, No: d.No, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Yes: d.Yes, No: d.No, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} k.short = []key.Binding{k.Yes, k.No, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.Yes, k.No}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Yes, k.No}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } // Steps 0-3 forward everything but Enter/Esc to a focused textinput, // so Help stays unassigned. - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} k.short = []key.Binding{k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k case entriesHistory: - k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} k.short = []key.Binding{k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Help, k.Back, k.ForceQuit, k.Palette}} return k case entriesTags: if es.tagNewMode { // A focused textinput takes '?' as a character, so Help stays // unassigned here. - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} k.short = []key.Binding{k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k } k := keyMap{ Up: d.Up, Down: d.Down, ToggleItem: d.ToggleItem, NewTag: d.NewTag, Confirm: d.Confirm, Back: d.Back, - ForceQuit: d.ForceQuit, Help: d.Help, + ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, } pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{k.ToggleItem, k.NewTag, k.Confirm, k.Back, k.Help, k.ForceQuit} k.full = [][]key.Binding{ {pair, k.ToggleItem}, {k.NewTag, k.Confirm}, - {k.Help, k.Back, k.ForceQuit}, + {k.Help, k.Back, k.ForceQuit, k.Palette}, } return k @@ -531,7 +562,7 @@ func entriesKeys(m Model, d keyDefaults) keyMap { k := keyMap{ Up: d.Up, Down: d.Down, Back: d.Back, Delete: d.Delete, Edit: d.Edit, History: d.History, Tags: d.Tags, - ForceQuit: d.ForceQuit, Help: d.Help, + ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, } hasEntries := len(es.entries) > 0 editable := hasEntries && canEdit(es.entries[es.idx], m.userID) @@ -544,7 +575,7 @@ func entriesKeys(m Model, d keyDefaults) keyMap { k.full = [][]key.Binding{ {pair}, {k.Edit, k.Delete, k.Tags, k.History}, - {k.Help, k.Back, k.ForceQuit}, + {k.Help, k.Back, k.ForceQuit, k.Palette}, } return k } @@ -570,16 +601,16 @@ func ratesKeys(m Model, d keyDefaults) keyMap { // A focused textinput takes '?' as a character, so Help stays // unassigned here — this is also reached via the override draft's // rate step (rt.draft.active && rt.editing). - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} k.short = []key.Binding{k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k } if rt.draft.active { - k := keyMap{Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{pair, k.Confirm, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } @@ -589,7 +620,7 @@ func ratesKeys(m Model, d keyDefaults) keyMap { ListCurrency: d.ListCurrency, ListBudget: d.ListBudget, NewOverride: d.NewOverride, ClearValue: d.ClearValue, BrowseList: d.BrowseList, Save: d.Save, Back: d.Back, - ForceQuit: d.ForceQuit, Help: d.Help, + ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, } listsWithRows := rt.sec == secLists && len(rt.rows) > 0 k.ListCurrency.SetEnabled(listsWithRows) @@ -616,7 +647,7 @@ func ratesKeys(m Model, d keyDefaults) keyMap { k.full = [][]key.Binding{ {sectionPair, movePair, k.Confirm}, {k.ListCurrency, k.ListBudget, k.NewOverride, k.ClearValue, k.BrowseList}, - {k.Save, k.Help, k.Back, k.ForceQuit}, + {k.Save, k.Help, k.Back, k.ForceQuit, k.Palette}, } return k } @@ -626,7 +657,7 @@ func ratesKeys(m Model, d keyDefaults) keyMap { // the active mode (rangeScreen.editing) decides which are live. Quit stays // off in both modes (q does not quit this screen today). func rangeKeys(m Model, d keyDefaults) keyMap { - k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit} + k := keyMap{Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Palette: d.Palette} if m.rangeScreen.editing { // Both fromInput and toInput are focused textinputs here, so Help // stays unassigned — one keysFor branch covers both fields. @@ -634,7 +665,7 @@ func rangeKeys(m Model, d keyDefaults) keyMap { k.PrevField = d.PrevField pair := pairHelp(k.NextField, k.PrevField, "tab/⇧tab", "next/prev field") k.short = []key.Binding{pair, k.Confirm, k.Back, k.ForceQuit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Back, k.ForceQuit, k.Palette}} return k } k.Up = d.Up @@ -642,7 +673,7 @@ func rangeKeys(m Model, d keyDefaults) keyMap { k.Help = d.Help pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{pair, k.Confirm, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } @@ -653,10 +684,10 @@ func rangeKeys(m Model, d keyDefaults) keyMap { // there is no separate loading state to model here. Quit stays off (q does // not quit this screen today). func listBrowserKeys(d keyDefaults) keyMap { - k := keyMap{Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help} + k := keyMap{Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette} pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{pair, k.Confirm, k.Back, k.Help, k.ForceQuit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.ForceQuit, k.Palette}} return k } @@ -665,7 +696,7 @@ func listBrowserKeys(d keyDefaults) keyMap { // filters.go, budget.go); q DOES quit these screens today. func reportKeys(d keyDefaults) keyMap { k := keyMap{ - Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Back: d.Back, GroupBy: d.GroupBy, ChangeRange: d.ChangeRange, Reload: d.Reload, + Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, Back: d.Back, GroupBy: d.GroupBy, ChangeRange: d.ChangeRange, Reload: d.Reload, Export: d.Export, Rates: d.Rates, LogHours: d.LogHours, Filters: d.Filters, Budget: d.Budget, OpenEntries: d.OpenEntries, } @@ -673,22 +704,22 @@ func reportKeys(d keyDefaults) keyMap { k.full = [][]key.Binding{ {k.GroupBy, k.Export, k.Rates, k.LogHours}, {k.Filters, k.Budget, k.OpenEntries}, - {k.ChangeRange, k.Reload, k.Help, k.Back, k.Quit}, + {k.ChangeRange, k.Reload, k.Help, k.Back, k.Quit, k.Palette}, } return k } func exportKeys(d keyDefaults) keyMap { - k := keyMap{Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back} + k := keyMap{Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, Up: d.Up, Down: d.Down, Confirm: d.Confirm, Back: d.Back} pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") k.short = []key.Binding{pair, k.Confirm, k.Back, k.Help, k.Quit} - k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.Quit}} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Help, k.Back, k.Quit, k.Palette}} return k } func membersKeys(d keyDefaults) keyMap { k := keyMap{ - Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Up: d.Up, Down: d.Down, ToggleItem: d.ToggleItem, + Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, Up: d.Up, Down: d.Down, ToggleItem: d.ToggleItem, SelectAll: d.SelectAll, Confirm: d.Confirm, Back: d.Back, } pair := pairHelp(k.Up, k.Down, "↑/↓/j/k", "move") @@ -696,14 +727,14 @@ func membersKeys(d keyDefaults) keyMap { k.full = [][]key.Binding{ {pair, k.ToggleItem, k.SelectAll}, {k.Confirm}, - {k.Help, k.Back, k.Quit}, + {k.Help, k.Back, k.Quit, k.Palette}, } return k } func filtersKeys(d keyDefaults) keyMap { k := keyMap{ - Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, NextField: d.NextField, PrevField: d.PrevField, Up: d.Up, Down: d.Down, + Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, NextField: d.NextField, PrevField: d.PrevField, Up: d.Up, Down: d.Down, ToggleItem: d.ToggleItem, SelectAll: d.SelectAll, Confirm: d.Confirm, Back: d.Back, } // "section", not "field": on this screen tab cycles the four filter @@ -715,17 +746,75 @@ func filtersKeys(d keyDefaults) keyMap { k.full = [][]key.Binding{ {sectionPair, movePair, k.ToggleItem, k.SelectAll}, {k.Confirm}, - {k.Help, k.Back, k.Quit}, + {k.Help, k.Back, k.Quit, k.Palette}, } return k } func budgetKeys(d keyDefaults) keyMap { - k := keyMap{Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Back: d.Back, Budget: d.Budget} + k := keyMap{Quit: d.Quit, ForceQuit: d.ForceQuit, Help: d.Help, Palette: d.Palette, Back: d.Back, Budget: d.Budget} // The default help text ("budgets") describes b from the report, where it // opens this screen. Here the same key closes it. k.Budget.SetHelp("b", "close budgets") k.short = []key.Binding{k.Back, k.Budget, k.Help, k.Quit} - k.full = [][]key.Binding{{k.Budget}, {k.Help, k.Back, k.Quit}} + k.full = [][]key.Binding{{k.Budget}, {k.Help, k.Back, k.Quit, k.Palette}} + return k +} + +// paletteKeys is the binding set while the command palette is open (#71). It is +// not a screen: m.screen still names what is underneath, and this set simply +// takes precedence for as long as the overlay lives. +// +// Quit and Help stay unassigned, which is the second, independent guard on top +// of Update's ordering: even if the overlay check were moved below them, q and +// ? could not fire while the user is typing a query. +func paletteKeys(d keyDefaults) keyMap { + k := keyMap{ + Up: d.PaletteUp, Down: d.PaletteDown, + Confirm: d.Confirm, Back: d.Back, + Palette: d.Palette, ForceQuit: d.ForceQuit, + } + k.Confirm.SetHelp("enter", "run") + k.Back.SetHelp("esc", "close") + pair := pairHelp(k.Up, k.Down, "↑/↓", "move") + // ctrl+p closes as well as opens, but esc is the close every other screen + // already teaches, so only esc is advertised. + k.short = []key.Binding{pair, k.Confirm, k.Back, k.ForceQuit} + k.full = [][]key.Binding{{pair, k.Confirm}, {k.Back, k.ForceQuit}} return k } + +// paletteBindings is the subset of a screen's bindings the command palette +// offers as commands. It is allBindings' curated twin: cursor mechanics (move, +// confirm, toggle, switch section, go back) are not commands and would only +// bury the ones that are. +// +// Generate is here despite being Enter rather than a rune: it is the most-used +// action in the program and keyMsgFor rebuilds it exactly. Confirm is not, even +// though it shares that key — the selection is by field, not by keystroke. +func (k keyMap) paletteBindings() []key.Binding { + return []key.Binding{ + k.Generate, k.GroupBy, k.ChangeRange, k.Reload, k.Export, k.Rates, + k.Filters, k.Budget, k.OpenEntries, k.LogHours, k.Timer, k.Members, + k.Range, k.ToggleScope, k.ToggleWeek, k.PrevMonth, k.NextMonth, + k.ListCurrency, k.ListBudget, k.NewOverride, k.ClearValue, k.BrowseList, + k.Save, k.Delete, k.Edit, k.History, k.Tags, k.NewTag, k.StopTimer, + } +} + +// paletteDefaults mirrors paletteBindings over keyDefaults, so a test can check +// every palette-eligible key without having to reach a screen that enables it. +func (d keyDefaults) paletteDefaults() []key.Binding { + return keyMap{ + Generate: d.Generate, GroupBy: d.GroupBy, ChangeRange: d.ChangeRange, + Reload: d.Reload, Export: d.Export, Rates: d.Rates, Filters: d.Filters, + Budget: d.Budget, OpenEntries: d.OpenEntries, LogHours: d.LogHours, + Timer: d.Timer, Members: d.Members, Range: d.Range, + ToggleScope: d.ToggleScope, ToggleWeek: d.ToggleWeek, + PrevMonth: d.PrevMonth, NextMonth: d.NextMonth, + ListCurrency: d.ListCurrency, ListBudget: d.ListBudget, + NewOverride: d.NewOverride, ClearValue: d.ClearValue, + BrowseList: d.BrowseList, Save: d.Save, Delete: d.Delete, Edit: d.Edit, + History: d.History, Tags: d.Tags, NewTag: d.NewTag, StopTimer: d.StopTimer, + }.paletteBindings() +} diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index c5f662a..8aff238 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -36,7 +36,7 @@ func TestHomeKeyLabels(t *testing.T) { m := newTestModel() m.screen = screenHome m.scope = "me" - want := []string{"?", "ctrl+c", "d", "enter", "h", "l", "left", "n", "q", "right", "t", "w"} + want := []string{"?", "ctrl+c", "ctrl+p", "d", "enter", "h", "l", "left", "n", "q", "right", "t", "w"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("home labels (me scope, no running timer) = %v, want %v", got, want) } @@ -163,3 +163,74 @@ func TestAllBindingsCoversEveryField(t *testing.T) { t.Errorf("allBindings returns %d bindings, but keyMap has %d key.Binding fields — a field is missing from allBindings", got, want) } } + +// The palette must not inherit d.Up/d.Down: those are "up"/"k" and "down"/"j", +// so typing j or k into the query would move the cursor instead of entering a +// letter. paletteKeys uses the arrow-only PaletteUp/PaletteDown defaults. +func TestPaletteKeysAreArrowOnly(t *testing.T) { + t.Parallel() + k := paletteKeys(defaultKeys()) + for _, tc := range []struct { + name string + binding key.Binding + }{ + {"up", k.Up}, + {"down", k.Down}, + } { + for _, bad := range []string{"j", "k"} { + if slices.Contains(tc.binding.Keys(), bad) { + t.Errorf("palette %s binding accepts %q, which the query needs as a character", tc.name, bad) + } + } + } +} + +// While the palette is open it owns the keyboard, so q must not quit and ? must +// not toggle help. A zero key.Binding has nil keys, which Enabled() reports as +// false and key.Matches never fires on. +func TestPaletteKeysLeaveQuitAndHelpUnassigned(t *testing.T) { + t.Parallel() + k := paletteKeys(defaultKeys()) + if k.Quit.Enabled() { + t.Error("the palette assigned Quit; q would close the program while typing") + } + if k.Help.Enabled() { + t.Error("the palette assigned Help; ? would toggle full help instead of filtering") + } +} + +// keysFor is what the footer reads, so it must switch to the palette's bindings +// while the overlay is open. screenKeys is what screenActions reads, and it must +// keep answering for the screen underneath — see the spec's section 5.2b. +func TestKeysForFollowsTheOverlayAndScreenKeysDoesNot(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + if !keysFor(m).GroupBy.Enabled() { + t.Fatal("the report screen lost its GroupBy binding; the fixture is wrong") + } + m.overlay = overlayPalette + if keysFor(m).GroupBy.Enabled() { + t.Error("keysFor still returns the report's bindings with the palette open") + } + if !screenKeys(m).GroupBy.Enabled() { + t.Error("screenKeys stopped answering for the screen underneath the overlay") + } +} + +// Every binding the palette offers must be replayable through routeKey, which +// means its first key round-trips through keyMsgFor. Anything else would build +// a KeyMsg whose String() does not match the binding, and the action would +// silently do nothing. +func TestEveryPaletteBindingIsReplayable(t *testing.T) { + t.Parallel() + for _, b := range defaultKeys().paletteDefaults() { + keys := b.Keys() + if len(keys) == 0 { + t.Errorf("a palette default has no keys: %+v", b.Help()) + continue + } + if _, ok := keyMsgFor(keys[0]); !ok { + t.Errorf("binding %q (%s) has a first key keyMsgFor cannot rebuild", keys[0], b.Help().Desc) + } + } +} diff --git a/internal/tui/listbrowser_test.go b/internal/tui/listbrowser_test.go index df65725..6e4d083 100644 --- a/internal/tui/listbrowser_test.go +++ b/internal/tui/listbrowser_test.go @@ -14,7 +14,7 @@ import ( func TestListBrowserKeyLabels(t *testing.T) { t.Parallel() m := browserFixture(screenLog) - want := []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("list browser labels = %v, want %v", got, want) } diff --git a/internal/tui/log_test.go b/internal/tui/log_test.go index 583ef9b..31720bc 100644 --- a/internal/tui/log_test.go +++ b/internal/tui/log_test.go @@ -599,17 +599,17 @@ func TestLogKeyLabelsPerStep(t *testing.T) { formField int want []string }{ - {"mode select", logModeSelect, 0, []string{"1", "2", "3", "?", "ctrl+c", "esc"}}, - {"timer pick", logTimerPick, 0, []string{"1", "2", "?", "ctrl+c", "esc"}}, - {"list pick", logListPick, 0, []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"}}, - {"task pick", logTaskPick, 0, []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"}}, - {"id input", logIDInput, 0, []string{"ctrl+c", "enter", "esc"}}, - {"form duration", logForm, 0, []string{"ctrl+c", "enter", "esc"}}, - {"form date", logForm, 1, []string{"ctrl+c", "enter", "esc"}}, - {"form note", logForm, 2, []string{"ctrl+c", "enter", "esc"}}, - {"form billable", logForm, 3, []string{"?", "N", "Y", "ctrl+c", "enter", "esc", "n", "y"}}, - {"timer running", logTimerRunning, 0, []string{"?", "ctrl+c", "esc", "s"}}, - {"done", logDone, 0, []string{"?", "ctrl+c", "enter", "esc", "r"}}, + {"mode select", logModeSelect, 0, []string{"1", "2", "3", "?", "ctrl+c", "ctrl+p", "esc"}}, + {"timer pick", logTimerPick, 0, []string{"1", "2", "?", "ctrl+c", "ctrl+p", "esc"}}, + {"list pick", logListPick, 0, []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"}}, + {"task pick", logTaskPick, 0, []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"}}, + {"id input", logIDInput, 0, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"form duration", logForm, 0, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"form date", logForm, 1, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"form note", logForm, 2, []string{"ctrl+c", "ctrl+p", "enter", "esc"}}, + {"form billable", logForm, 3, []string{"?", "N", "Y", "ctrl+c", "ctrl+p", "enter", "esc", "n", "y"}}, + {"timer running", logTimerRunning, 0, []string{"?", "ctrl+c", "ctrl+p", "esc", "s"}}, + {"done", logDone, 0, []string{"?", "ctrl+c", "ctrl+p", "enter", "esc", "r"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/tui/members_test.go b/internal/tui/members_test.go index 3b39c9c..1bf4fef 100644 --- a/internal/tui/members_test.go +++ b/internal/tui/members_test.go @@ -89,7 +89,7 @@ func TestMembersAllNoneEmptyRosterNoPanic(t *testing.T) { func TestMembersKeyLabels(t *testing.T) { t.Parallel() m := membersFixture() - want := []string{" ", "?", "a", "ctrl+c", "down", "enter", "esc", "j", "k", "q", "up"} + want := []string{" ", "?", "a", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "q", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("members labels = %v, want %v", got, want) } diff --git a/internal/tui/nav.go b/internal/tui/nav.go index 4111d64..4a762da 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -1,3 +1,20 @@ +// Every function here dissolves an open overlay when it changes the screen: +// an overlay belongs to the screen it was raised over, so whatever swaps that +// screen out — a key handler, an async error, a 401 relaunching the setup +// wizard — takes the overlay with it. goTo, pop and resetTo always change (or +// re-affirm) the screen, so each calls closePalette() unconditionally, on +// every path, including their own no-op branches: a branch that silently kept +// the overlay open would be a latent trap for the next handler built on top of +// it. replace is the one exception, and deliberately so: several async +// handlers call replace() — statusesMsg on +// screenFilters, membersMsg on screenMembers, entriesReloadedMsg/ +// entriesErrMsg on screenEntries, logDoneMsg/timerMsg on screenLog — and that +// is not a screen swap, so replace only dissolves the overlay when s != +// m.screen. Otherwise an in-flight fetch of that kind would silently wipe a +// query the user is still typing into a palette opened while it was running. +// The palette's own actions are not an exception to any of this: they run +// after closePalette(), so the clearing here is a no-op for them. + package tui import "slices" @@ -9,6 +26,7 @@ import "slices" // depth is bounded by the number of screens. This is a structural invariant, // not a rule anyone has to remember to apply. func (m Model) goTo(s screen) Model { + m = m.closePalette() if i := slices.Index(m.nav, s); i >= 0 { m.nav = m.nav[:i] m.screen = s @@ -26,6 +44,9 @@ func (m Model) goTo(s screen) Model { // loading and error screens, and the async handlers that land on a result // screen, are the same logical step as whatever launched them. func (m Model) replace(s screen) Model { + if s != m.screen { + m = m.closePalette() + } m.screen = s return m } @@ -33,6 +54,7 @@ func (m Model) replace(s screen) Model { // pop returns to the parent screen. On an empty chain it is a no-op, which is // exactly what Home needs — no special case. func (m Model) pop() Model { + m = m.closePalette() if len(m.nav) == 0 { return m } @@ -44,6 +66,7 @@ func (m Model) pop() Model { // resetTo clears the parent chain: Home, and the setup wizard relaunched by a // 401. func (m Model) resetTo(s screen) Model { + m = m.closePalette() m.nav = nil m.screen = s return m diff --git a/internal/tui/open.go b/internal/tui/open.go new file mode 100644 index 0000000..52f8a91 --- /dev/null +++ b/internal/tui/open.go @@ -0,0 +1,72 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +// The screen-opening surface: every method here builds a screen's sub-model and +// navigates to it, so a key handler and the command palette (#71) open a screen +// the same way instead of each knowing how. +// +// Three older members of this family live elsewhere and stay there — moving +// them would inflate a diff for no behavioral gain: openListBrowser in app.go, +// openEntries in entries.go, openBudgetView in report.go. + +// openExport builds the export screen for the current report. +func (m Model) openExport() Model { + m.export = newExport(m.report) + return m.goTo(screenExport) +} + +// openRates builds the per-list/per-member rates editor. +func (m Model) openRates() Model { + m.ratesScreen = newRates(m.entries, m.cfg) + return m.goTo(screenRates) +} + +// openLog builds the hour-logging flow at its first step. +func (m Model) openLog() Model { + m.logScreen = newLog(m.entries, m.cfg) + return m.goTo(screenLog) +} + +// openRange builds the range/preset picker. +func (m Model) openRange() Model { + m.rangeScreen = newRange(m.preset) + return m.goTo(screenRange) +} + +// openFilters opens the client-side filter screen, enriching task statuses +// first when any are missing. The demo branch keeps the zero-I/O rule. +func (m Model) openFilters() (Model, tea.Cmd) { + missing := m.tasksMissingStatus() + if len(missing) == 0 { + m.assignStatuses() + m.filtersScreen = newFilters(m.entries, m.filterLists, m.filterTags, m.filterStatuses, m.filterBillable) + return m.goTo(screenFilters), nil + } + m.filtersScreen = filtersModel{loadingStatuses: true} + m = m.goTo(screenFilters) + if m.demo { + return m, demoStatusEnrichCmd(m.entries) + } + return m, statusEnrichCmd(m.client, missing) +} + +// openMembers opens the member selector, fetching the workspace roster when it +// is not cached yet. +// +// origin is where a load failure returns to. It is a parameter rather than a +// constant screenHome because the command palette can open this screen from +// anywhere: attributing a failure to Home while the user was on Rates would +// both lose their place and misname the culprit. +func (m Model) openMembers(origin screen) (Model, tea.Cmd) { + if len(m.teamMembers) > 0 { + m.membersScreen = newMembers(m.teamMembers, m.selectedMembers) + return m.goTo(screenMembers), nil + } + m.membersScreen = membersModel{loading: true} + m = m.goTo(screenMembers) + if m.demo { + return m, demoMembersCmd() + } + return m, loadMembersCmd(m.client, m.cfg.WorkspaceID, origin) +} diff --git a/internal/tui/open_test.go b/internal/tui/open_test.go new file mode 100644 index 0000000..223e548 --- /dev/null +++ b/internal/tui/open_test.go @@ -0,0 +1,172 @@ +package tui + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/marcoarnulfo/clickup-cli/internal/clickup" + "github.com/marcoarnulfo/clickup-cli/internal/report" +) + +// openBase is a Model on the report screen with entries and a built report — +// the state every one of these methods is reached from in the real app. +func openBase() Model { + m := newTestModelOnReport() + m.report = goldenReport() + return m +} + +func TestOpenMethodsBuildTheirScreen(t *testing.T) { + t.Parallel() + t.Run("export", func(t *testing.T) { + t.Parallel() + m := openBase().openExport() + if m.screen != screenExport { + t.Errorf("screen = %v, want screenExport", m.screen) + } + if reflect.DeepEqual(m.export, exportModel{}) { + t.Error("openExport left the export sub-model at its zero value") + } + if len(m.nav) == 0 || m.nav[len(m.nav)-1] != screenReport { + t.Errorf("nav = %v, want screenReport on top so esc goes back", m.nav) + } + }) + t.Run("rates", func(t *testing.T) { + t.Parallel() + m := openBase().openRates() + if m.screen != screenRates { + t.Errorf("screen = %v, want screenRates", m.screen) + } + if reflect.DeepEqual(m.ratesScreen, ratesModel{}) { + t.Error("openRates left the rates sub-model at its zero value") + } + }) + t.Run("log", func(t *testing.T) { + t.Parallel() + m := openBase().openLog() + if m.screen != screenLog { + t.Errorf("screen = %v, want screenLog", m.screen) + } + if reflect.DeepEqual(m.logScreen, logModel{}) { + t.Error("openLog left the log sub-model at its zero value") + } + }) + t.Run("range", func(t *testing.T) { + t.Parallel() + // PresetLast7d, not the default: newRange sets only rm.idx, and + // this_month is rangePresets[0], so newRange(PresetThisMonth) IS the + // zero rangeModel (range.go:37-45). A zero-value assertion would fail + // against a perfectly correct openRange. + m := openBase() + m.preset = report.PresetLast7d + m = m.openRange() + if m.screen != screenRange { + t.Errorf("screen = %v, want screenRange", m.screen) + } + if m.rangeScreen.idx == 0 { + t.Error("openRange did not seed the cursor from m.preset") + } + }) + t.Run("entries", func(t *testing.T) { + t.Parallel() + m, _ := openBase().openEntries() + if m.screen != screenEntries { + t.Errorf("screen = %v, want screenEntries", m.screen) + } + }) +} + +// openEntries absorbed the lazy user fetch that used to live in updateReport's +// OpenEntries case. Without it here, the palette's "Go to entries" would have +// to copy that branch. +func TestOpenEntriesFetchesTheUserWhenUnknown(t *testing.T) { + t.Parallel() + m := openBase() + m.userID = 0 + if _, cmd := m.openEntries(); cmd == nil { + t.Error("openEntries returned no command with an unknown user; ownership gating stays off forever") + } + m.userID = 42 + if _, cmd := m.openEntries(); cmd != nil { + t.Error("openEntries re-fetched a user it already knows") + } +} + +func TestOpenFiltersOpensImmediatelyWhenStatusesAreCached(t *testing.T) { + t.Parallel() + m := openBase() + m.taskStatus = map[string]string{} + for _, e := range m.entries { + m.taskStatus[e.TaskID] = "open" + } + got, cmd := m.openFilters() + if got.screen != screenFilters { + t.Errorf("screen = %v, want screenFilters", got.screen) + } + if cmd != nil { + t.Error("openFilters issued an enrichment command although every status was cached") + } +} + +// openMembers takes an origin because that value decides where a load failure +// lands. Pinned to screenHome, a failure raised from the palette on another +// screen would teleport the user Home and blame a screen they were not on. +func TestOpenMembersRoutesFailuresBackToItsOrigin(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + m := openBase() + m.client = clickup.New("tok") + m.client.BaseURL = srv.URL + m.teamMembers = nil // force the fetch + + _, cmd := m.openMembers(screenRates) + if cmd == nil { + t.Fatal("openMembers issued no command with no members cached") + } + msg, ok := cmd().(retryableErrMsg) + if !ok { + t.Fatalf("command produced %T, want retryableErrMsg", cmd()) + } + if msg.origin != screenRates { + t.Errorf("origin = %v, want screenRates — the caller's screen, not a hardcoded Home", msg.origin) + } +} + +// The key handlers must go through the same methods, or the palette and the +// keyboard drift apart. +func TestReportAndHomeKeysReachTheSameScreens(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + key string + from func() Model + want screen + }{ + {"report export", "e", openBase, screenExport}, + {"report rates", "p", openBase, screenRates}, + {"report log hours", "n", openBase, screenLog}, + {"home range", "d", func() Model { m := newTestModel(); m.screen = screenHome; return m }, screenRange}, + {"home log hours", "n", func() Model { m := newTestModel(); m.screen = screenHome; return m }, screenLog}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := tc.from() + var got tea.Model + if m.screen == screenReport { + got, _ = m.updateReport(keyMsg(tc.key)) + } else { + got, _ = m.updateHome(keyMsg(tc.key)) + } + if s := got.(Model).screen; s != tc.want { + t.Errorf("screen = %v, want %v", s, tc.want) + } + }) + } +} diff --git a/internal/tui/overlay.go b/internal/tui/overlay.go new file mode 100644 index 0000000..0b4aaac --- /dev/null +++ b/internal/tui/overlay.go @@ -0,0 +1,87 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// composite draws box over body with the box's top-left cell at (x, y) and +// returns the combined text. Cells of body outside the box's rectangle survive +// verbatim, styling included — that layering is the whole point of #59, and it +// is what separates an overlay from a screen that replaced another one. +// +// x and y are terminal cells, not bytes and not runes. ansi.Cut measures in +// cells and never splits an escape sequence, which is what makes this safe to +// run over output lipgloss has already styled. +// +// Three shapes need help. A body line shorter than x gains spaces, or the box +// slides left on that line alone. A body shorter than y+height gains blank +// lines, or the box is clipped at the bottom — the Home screen's body is three +// lines and the palette is ten. And a wide glyph straddling either edge gets +// dropped rather than kept, because half a glyph cannot be drawn and staying +// aligned matters more than one character under the box's border. +// +// box must be rectangular — every line the same display width (boxW below). +// The right-hand segment of each composited row resumes at x+boxW regardless +// of that box line's own width, so a ragged box silently loses body cells on +// any line narrower than the others. +func composite(body, box string, x, y int) string { + if box == "" { + return body + } + x = max(x, 0) + y = max(y, 0) + + boxLines := strings.Split(box, "\n") + boxW := 0 + for _, l := range boxLines { + boxW = max(boxW, lipgloss.Width(l)) + } + + lines := strings.Split(body, "\n") + for len(lines) < y+len(boxLines) { + lines = append(lines, "") + } + + for i, bl := range boxLines { + row := lines[y+i] + rowW := lipgloss.Width(row) + + left := ansi.Cut(row, 0, x) + if pad := x - lipgloss.Width(left); pad > 0 { + left += strings.Repeat(" ", pad) + } + // ansi.Cut closes a style it had to reopen, but leaves an already-open, + // unterminated one open (measured against x/ansi v0.11.6). Without this + // reset such a line paints the box in its own color. + if strings.ContainsRune(left, '\x1b') { + left += ansi.ResetStyle + } + + // Guarded rather than left to Cut alone: ansi.Cut(row, x+boxW, rowW) + // already returns "" once the box reaches or exceeds the line's width + // (right <= left is Cut's own early return). But without this guard the + // straddle correction just below still runs against that empty string, + // and left+bl alone can exceed rowW when the box is wider than the line + // — firing spuriously and appending a stray space that does not belong. + // TestCompositeBoxWiderThanBody pins a box wider than the body catching + // no such space. + right := "" + if x+boxW < rowW { + right = ansi.Cut(row, x+boxW, rowW) + // A wide glyph straddling the box's right edge is KEPT WHOLE by Cut + // (the left edge drops it instead), which would start this segment a + // column early and push the line one cell too wide. Half a glyph + // cannot be drawn, so drop it and hand its column back as a space. + // At most one cluster can straddle, so one correction is enough. + if lipgloss.Width(left)+lipgloss.Width(bl)+lipgloss.Width(right) > rowW { + right = " " + ansi.Cut(row, x+boxW+1, rowW) + } + } + + lines[y+i] = left + bl + right + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/overlay_test.go b/internal/tui/overlay_test.go new file mode 100644 index 0000000..c6f420c --- /dev/null +++ b/internal/tui/overlay_test.go @@ -0,0 +1,128 @@ +package tui + +import ( + "io" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +func TestCompositeSplicesTheBoxIntoTheBody(t *testing.T) { + t.Parallel() + body := "abcdefghij\nklmnopqrst\nuvwxyz1234" + got := composite(body, "[]\n[]", 3, 1) + want := "abcdefghij\nklm[]pqrst\nuvw[]z1234" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +// A body line shorter than x must gain spaces. Without the padding the box +// slides left on that line alone, which reads as a broken border. +func TestCompositePadsShortBodyLines(t *testing.T) { + t.Parallel() + got := composite("abcdefghij\nkl", "[]", 5, 1) + want := "abcdefghij\nkl []" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +// A body shorter than y+height must gain blank lines, or the box is clipped at +// the bottom — the Home screen's body is three lines and the palette is ten. +func TestCompositeExtendsAShortBody(t *testing.T) { + t.Parallel() + got := composite("abc", "[]\n[]", 0, 2) + want := "abc\n\n[]\n[]" + if got != want { + t.Errorf("composite =\n%q\nwant\n%q", got, want) + } +} + +func TestCompositeBoxWiderThanBody(t *testing.T) { + t.Parallel() + got := composite("ab", "XXXXXX", 0, 0) + if got != "XXXXXX" { + t.Errorf("composite = %q, want %q", got, "XXXXXX") + } +} + +func TestCompositeKeepsEveryLineWidth(t *testing.T) { + t.Parallel() + body := strings.Repeat("x", 40) + "\n" + strings.Repeat("y", 40) + for _, l := range strings.Split(composite(body, "AAAA\nBBBB", 10, 0), "\n") { + if w := lipgloss.Width(l); w != 40 { + t.Errorf("line %q is %d cells wide, want 40", l, w) + } + } +} + +// The same property with wide glyphs, which is where it actually breaks. +// ansi.Cut treats the two edges differently: on the left it DROPS a cluster +// that straddles the limit, on the right it KEEPS it whole — so the right-hand +// segment starts one column early and the line comes out a cell too wide. +// +// An ASCII-only width test passes straight over this. That is exactly how a +// width test in the previous tranche passed over a 78-column table rendered +// into a 60-column terminal. +func TestCompositeHandlesWideGlyphsOnBothEdges(t *testing.T) { + t.Parallel() + // 20 double-width glyphs: 40 cells, one glyph per two columns, so a box at + // an odd x straddles a glyph at both edges. + body := strings.Repeat("漢", 20) + if w := lipgloss.Width(body); w != 40 { + t.Fatalf("the fixture is %d cells, want 40; this test assumes double-width glyphs", w) + } + got := composite(body, "AAAAAA", 5, 0) + if w := lipgloss.Width(got); w != 40 { + t.Errorf("composited line is %d cells, want 40: %q", w, got) + } + if !strings.Contains(got, "AAAAAA") { + t.Errorf("the box was mangled: %q", got) + } +} + +// The one failure mode a golden can never see. TestMain pins the DEFAULT +// renderer to termenv.Ascii, so golden output carries no escapes at all; this +// builds its own renderer with a real profile and asserts on the bytes. +// +// The trailing reset is stripped on purpose: an unterminated style is the one +// shape ansi.Cut does not close on its own, so it is the only shape whose color +// can bleed into the box. +func TestCompositeDoesNotLeakStyleIntoTheBox(t *testing.T) { + t.Parallel() + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + styled := r.NewStyle().Foreground(lipgloss.Color("1")).Render("HELLO world") + open := strings.TrimSuffix(styled, "\x1b[0m") + if !strings.Contains(open, "\x1b[") { + t.Fatalf("the fixture carries no escape sequence (%q); this test would pass vacuously", open) + } + + got := composite(open, "[]", 3, 0) + i := strings.Index(got, "[]") + if i < 0 { + t.Fatalf("the box is missing from the composited line: %q", got) + } + if !strings.HasSuffix(got[:i], ansi.ResetStyle) { + t.Errorf("the box is not preceded by a style reset, so the body's color bleeds into it: %q", got) + } +} + +// Past the end of a line the right-hand segment is skipped by construction +// (composite's x+boxW < rowW guard), not cut and discovered empty: without +// that guard the straddle correction would still run against Cut's own empty +// result and could append a stray character. +func TestCompositeAddsNoTrailingEscapes(t *testing.T) { + t.Parallel() + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + body := r.NewStyle().Foreground(lipgloss.Color("2")).Render("abcd") + got := composite(body, "XX", 2, 0) + if !strings.HasSuffix(got, "XX") { + t.Errorf("composite = %q, want it to end with the box, with nothing appended", got) + } +} diff --git a/internal/tui/palette.go b/internal/tui/palette.go new file mode 100644 index 0000000..6146d54 --- /dev/null +++ b/internal/tui/palette.go @@ -0,0 +1,300 @@ +package tui + +import ( + "slices" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/marcoarnulfo/clickup-cli/internal/fuzzy" +) + +// Geometry. See the spec's section 8.2. +const ( + paletteWidth = 52 // preferred box width, borders included + paletteMinWidth = 24 // never narrower, even on a narrow terminal + paletteMaxRows = 8 // action rows visible at once + paletteChrome = 4 // top border + query row + separator + bottom border + paletteTopY = 2 // body row the top border sits on + paletteTitle = "Command palette" +) + +// paletteItem is one filtered row: the action, the rune indices fuzzy.Match +// hit (which drive the highlighting, not just the ranking) and its score. +type paletteItem struct { + a action + idx []int + score int +} + +// paletteModel is the command palette's state (#71). +// +// The query is a plain string with append and backspace rather than a +// textinput.Model: that type derives its styles from lipgloss's default +// renderer — the same reason footer.go refuses help.New() — and would add three +// fields of editing state a twenty-item filter never uses. +// +// There is no cached copy of every action. refreshPalette rebuilds from +// paletteActions on each keystroke: a fuzzy match over twenty short strings +// costs nothing, and a copy that is never kept cannot go stale. +type paletteModel struct { + query string + items []paletteItem + idx int // selected row + top int // first visible row +} + +// openPalette raises the overlay. It does not touch m.nav: an overlay is not a +// place you navigated to, so closing it is not a pop(). +// +// helpAll is cleared because the expanded footer is several lines tall, and +// paletteRows subtracts a fixed two rows for the blank line and the footer. +// Dismissing it is also right on its own terms: the palette and full help +// answer the same question, and showing both at once answers it twice. +func (m Model) openPalette() Model { + m.overlay = overlayPalette + m.helpAll = false + m.palette = paletteModel{} + return m.refreshPalette() +} + +func (m Model) closePalette() Model { + m.overlay = overlayNone + m.palette = paletteModel{} + return m +} + +// refreshPalette rebuilds the filtered, ranked list for the current query. +func (m Model) refreshPalette() Model { + all := paletteActions(m) + items := make([]paletteItem, 0, len(all)) + for _, a := range all { + score, idx, ok := fuzzy.Match(m.palette.query, a.label) + if !ok { + continue + } + items = append(items, paletteItem{a: a, idx: idx, score: score}) + } + // Stable, so an empty query (every score 0) keeps paletteActions' order: + // screen commands above the navigation rows. + slices.SortStableFunc(items, func(a, b paletteItem) int { return b.score - a.score }) + m.palette.items = items + m.palette.idx = 0 + m.palette.top = 0 + return m +} + +// updateOverlay handles every key while an overlay is open. Update routes here +// before it checks Quit, so nothing below can be reached by a query character. +func (m Model) updateOverlay(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + k := keysFor(m) // == paletteKeys: the handler and the footer read one source + switch { + case key.Matches(msg, k.Back), key.Matches(msg, k.Palette): + return m.closePalette(), nil + + case key.Matches(msg, k.Confirm): + if len(m.palette.items) == 0 { + return m, nil + } + run := m.palette.items[m.palette.idx].a.run + // Close FIRST: the action changes screen, and a palette still raised + // would be composited over the screen it just opened. + return run(m.closePalette()) + + case key.Matches(msg, k.Up): + if m.palette.idx > 0 { + m.palette.idx-- + } + m.palette = scrollPalette(m.palette, paletteRows(m.height)) + return m, nil + + case key.Matches(msg, k.Down): + if m.palette.idx < len(m.palette.items)-1 { + m.palette.idx++ + } + m.palette = scrollPalette(m.palette, paletteRows(m.height)) + return m, nil + + // Backspace is text editing, which none of the ten textinput screens + // advertises either, so it is a type check rather than a keymap binding. + case msg.Type == tea.KeyBackspace: + if r := []rune(m.palette.query); len(r) > 0 { + m.palette.query = string(r[:len(r)-1]) + m = m.refreshPalette() + } + return m, nil + } + + // bubbletea reclassifies a lone space as KeySpace while still carrying it in + // Runes (key.go:698-702), so a query could not contain a space without this + // second type. Alt-modified runes are chords, not text. + if (msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) && !msg.Alt { + m.palette.query += string(msg.Runes) + m = m.refreshPalette() + } + return m, nil +} + +// scrollPalette moves the visible window so idx is always inside it. This is the +// scrolling the Filters screen still lacks (#28); the palette gets it right from +// the start. +func scrollPalette(p paletteModel, rows int) paletteModel { + if p.idx < p.top { + p.top = p.idx + } + if p.idx >= p.top+rows { + p.top = p.idx - rows + 1 + } + return p +} + +// paletteRows is how many action rows fit. The subtraction accounts for the box +// chrome, the rows above it, and the blank line plus footer View always appends. +// The floor of 3 says the palette shrinks on a short terminal but never vanishes. +func paletteRows(height int) int { + if height <= 0 { + return paletteMaxRows + } + return max(3, min(paletteMaxRows, height-paletteChrome-paletteTopY-2)) +} + +// paletteBoxWidth is the box's total width, borders included. +func paletteBoxWidth(width int) int { + if width <= 0 { + return paletteWidth + } + // On a terminal too narrow even for the floor, the box keeps its 24 columns + // and overflows to the right: a box that spills is readable, a box squeezed + // below its floor is not. + return max(paletteMinWidth, min(paletteWidth, width-4)) +} + +// layout renders the box and returns the cell its top-left corner goes in. +// +// bodyLines is how many lines the screen underneath occupies, and it is not +// decoration. y is a coordinate in the BODY, but bubbletea's standard renderer +// keeps only the LAST height lines when a view overflows +// (standard_renderer.go:186-188). On a report grouped by task with fifty +// buckets, a box pinned at y=2 scrolls off the top and the user types into a +// palette they cannot see — on exactly the screen where it is most useful. So +// the box moves down by however much the body will be scrolled up. +func (p paletteModel) layout(th theme, width, height, bodyLines int) (string, int, int) { + boxW := paletteBoxWidth(width) + x := 0 + if width > boxW { + x = (width - boxW) / 2 + } + y := paletteTopY + if height > 0 { + if overflow := bodyLines + 2 - height; overflow > 0 { // +2: blank line + footer + y += overflow + } + } + return p.box(th, boxW, paletteRows(height)), x, y +} + +// box builds the frame a line at a time rather than through th.Box. +// +// For an overlay the rendered width is not decoration: it is the number +// composite uses to decide where the body resumes. lipgloss's own border and +// padding arithmetic already cost this package one bug (the report table's +// amputated right border, #66), so here every line is boxW cells wide by +// construction and a test checks it. +func (p paletteModel) box(th theme, boxW, rows int) string { + innerW := boxW - 4 + var b strings.Builder + + // th.Accent, never th.Title: Title carries MarginBottom(1) and would inject + // a blank line into the middle of the overlay. + dashes := boxW - lipgloss.Width(paletteTitle) - 5 + b.WriteString(th.Border.Render("╭─ ") + th.Accent.Render(paletteTitle) + + th.Border.Render(" "+strings.Repeat("─", dashes)+"╮") + "\n") + + q := shaveToWidth("> "+p.query, innerW) + b.WriteString(paletteLine(th, th.Cell.Render(q), innerW-lipgloss.Width(q))) + b.WriteString(th.Border.Render("├"+strings.Repeat("─", boxW-2)+"┤") + "\n") + + if len(p.items) == 0 { + msg := shaveToWidth("no matching action", innerW) + b.WriteString(paletteLine(th, th.Help.Render(msg), innerW-lipgloss.Width(msg))) + } + for i := p.top; i < len(p.items) && i < p.top+rows; i++ { + b.WriteString(paletteLine(th, paletteRow(th, p.items[i], i == p.idx, innerW), 0)) + } + + b.WriteString(th.Border.Render("╰" + strings.Repeat("─", boxW-2) + "╯")) + return b.String() +} + +// paletteLine wraps already-styled content in the side borders, padding it out +// by pad cells so every line is the same width. +func paletteLine(th theme, content string, pad int) string { + if pad > 0 { + content += th.Cell.Render(strings.Repeat(" ", pad)) + } + return th.Border.Render("│ ") + content + th.Border.Render(" │") + "\n" +} + +// paletteRow renders one action's inner content, exactly innerW cells wide. +func paletteRow(th theme, it paletteItem, selected bool, innerW int) string { + cursor := " " + style := th.Cell + if selected { + cursor = "▸ " // the marker members.go, export.go and filters.go already use + style = th.Accent + } + + hintW := lipgloss.Width(it.a.hint) + labelW := innerW - 2 - hintW + if hintW > 0 { + labelW -= 2 // two spaces between the label and the key + } + label := shaveToWidth(it.a.label, max(labelW, 1)) + gap := innerW - 2 - lipgloss.Width(label) - hintW + + out := style.Render(cursor) + highlight(th, label, it.idx) + if gap > 0 { + out += th.Cell.Render(strings.Repeat(" ", gap)) + } + return out + th.Help.Render(it.a.hint) +} + +// highlight renders label with the runes at idx in th.Accent and the rest in +// th.Cell. +// +// idx indexes the FULL label, because that is what fuzzy.Match was given. +// Truncation happens first (highlighting first would leave escape sequences to +// be cut in half), so indices past the shortened label are dropped here, and a +// trailing ellipsis never lights up. +func highlight(th theme, label string, idx []int) string { + r := []rune(label) + limit := len(r) + if limit > 0 && r[limit-1] == '…' { + limit-- + } + hit := make(map[int]bool, len(idx)) + for _, i := range idx { + if i < limit { + hit[i] = true + } + } + + var b strings.Builder + // Runs, not runes: one Render per stretch keeps the escape sequences down to + // a handful per row instead of one pair per character. + for i := 0; i < len(r); { + j := i + for j < len(r) && hit[j] == hit[i] { + j++ + } + seg := string(r[i:j]) + if hit[i] { + b.WriteString(th.Accent.Render(seg)) + } else { + b.WriteString(th.Cell.Render(seg)) + } + i = j + } + return b.String() +} diff --git a/internal/tui/palette_demo_test.go b/internal/tui/palette_demo_test.go new file mode 100644 index 0000000..b809d30 --- /dev/null +++ b/internal/tui/palette_demo_test.go @@ -0,0 +1,93 @@ +package tui + +import ( + "testing" + + "github.com/marcoarnulfo/clickup-cli/internal/clickup" +) + +// Demo mode must offer the same palette, and open it without any I/O: the demo +// exists so the TUI can be tried without an account, and a palette that reached +// the network there would break that promise. +// +// t.Setenv makes this test non-parallel by construction, which is what keeps it +// from racing TestMain's os.Unsetenv of the same variable. +func TestPaletteWorksInDemoMode(t *testing.T) { + t.Setenv("CLICKUP_DEMO", "1") + m := New(demoConfig()) + m.entries = goldenEntries() + // A non-empty Status is load-bearing for the "Go to filters" assertion + // below: demoStatusEnrichCmd echoes each entry's own Status, and + // goldenEntries() sets none, so without this the demo branch would also + // report an all-empty byTask and the assertion could not tell it apart + // from the real branch failing to reach every task. + for i := range m.entries { + m.entries[i].Status = "open" + } + m.screen = screenReport + m.nav = []screen{screenHome} + + // A client pointed at a refused local port rather than the real API: if + // the demo guard in openFilters/openMembers were ever bypassed, the real + // branch fails in milliseconds instead of hanging for its 120s/30s + // timeout — a fast red rather than a slow one. + m.client = clickup.New("DEMO") + m.client.BaseURL = "http://127.0.0.1:1" + + m = m.openPalette() + if len(m.palette.items) == 0 { + t.Fatal("the palette is empty in demo mode") + } + if !hasLabel(paletteActions(m), "Go to rates") { + t.Error("demo mode lost the navigation rows") + } + + // The two navigation rows that issue commands must take their demo + // branch. A bare "cmd() != nil" cannot discriminate that: both the demo + // commands and the real ones (statusEnrichCmd, loadMembersCmd) always + // return a non-nil message, including on failure (statusesMsg with every + // status cached as "", or errMsg/retryableErrMsg). So each command's + // result is asserted on its concrete type and, for "Go to filters", its + // content — that is what actually tells the two branches apart. + for _, label := range []string{"Go to filters", "Go to members"} { + m.scope = "team" + m.teamMembers = nil + for _, a := range paletteActions(m) { + if a.label != label { + continue + } + _, cmd := a.run(m) + if cmd == nil { + continue + } + msg := cmd() + switch label { + case "Go to filters": + // The real branch, unable to reach any task, still yields a + // statusesMsg — just one where every cached status is "". + sm, ok := msg.(statusesMsg) + if !ok { + t.Errorf("%s produced %T, want statusesMsg — the real branch ran instead of the demo one", label, msg) + continue + } + nonEmpty := false + for _, st := range sm.byTask { + if st != "" { + nonEmpty = true + break + } + } + if !nonEmpty { + t.Errorf("%s: every cached status was empty — the real branch ran instead of the demo one", label) + } + case "Go to members": + // The real branch, unable to reach the API, yields + // retryableErrMsg, not membersMsg — the type alone tells the + // two branches apart here. + if _, ok := msg.(membersMsg); !ok { + t.Errorf("%s produced %T, want membersMsg — the real branch ran instead of the demo one", label, msg) + } + } + } + } +} diff --git a/internal/tui/palette_test.go b/internal/tui/palette_test.go new file mode 100644 index 0000000..4f0d89a --- /dev/null +++ b/internal/tui/palette_test.go @@ -0,0 +1,453 @@ +package tui + +import ( + "errors" + "io" + "slices" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/marcoarnulfo/clickup-cli/internal/clickup" + "github.com/marcoarnulfo/clickup-cli/internal/fuzzy" +) + +func openPaletteOn(m Model) Model { return m.openPalette() } + +func typeInto(m Model, s string) Model { + for _, r := range s { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} + if r == ' ' { + msg = tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}} + } + got, _ := m.updateOverlay(msg) + m = got.(Model) + } + return m +} + +func TestPaletteOpensAndClosesWithoutTouchingNav(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + before := append([]screen(nil), m.nav...) + + // Through m.Update, not openPaletteOn: that helper bypasses the Update + // switch entirely, so a test built on it can never notice ctrl+p itself + // failing to reach openPalette (see Update's tea.KeyMsg branch). + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + m = got.(Model) + if m.overlay != overlayPalette { + t.Fatal("ctrl+p did not open the palette") + } + if m.screen != screenReport { + t.Errorf("screen = %v, want screenReport — an overlay is not a place you navigated to", m.screen) + } + if len(m.nav) != len(before) { + t.Errorf("nav = %v, want %v unchanged", m.nav, before) + } + + got, _ = m.updateOverlay(keyMsg("esc")) + m = got.(Model) + if m.overlay != overlayNone { + t.Error("esc did not close the palette") + } + if m.screen != screenReport || len(m.nav) != len(before) { + t.Errorf("closing changed the screen or nav: screen=%v nav=%v", m.screen, m.nav) + } +} + +// openPalette's doc comment explains why: the expanded footer is several +// lines tall, and paletteRows subtracts a fixed two rows for the blank line +// plus footer, so a surviving helpAll would misdraw the box on top of it. +func TestPaletteOpenClearsHelpAll(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.helpAll = true + m = m.openPalette() + if m.helpAll { + t.Error("openPalette left helpAll set") + } +} + +// A regression test, NOT a red-green one. Moving the overlay check below the +// Quit check does not break it: with the palette open, keysFor answers with +// paletteKeys, where Quit is unassigned, so q reaches the query either way. +// The ordering's real teeth are in TestPaletteCtrlPClosesRatherThanReopening. +func TestPaletteQueryAcceptsQ(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd != nil { + t.Fatal("typing q issued a command; the quit path fired inside the palette") + } + if q := got.(Model).palette.query; q != "q" { + t.Errorf("query = %q, want %q", q, "q") + } +} + +// Same shape for ?: inside the palette it is a character, not the help toggle. +func TestPaletteQueryAcceptsQuestionMark(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + if got.(Model).helpAll { + t.Error("? toggled full help while the palette was open") + } + if q := got.(Model).palette.query; q != "?" { + t.Errorf("query = %q, want %q", q, "?") + } +} + +// THIS is what makes Update's ordering load-bearing. paletteKeys assigns +// Palette (ctrl+p has to close), so with the overlay check below the Palette +// check, a ctrl+p would call openPalette a second time and wipe the query +// instead of closing. +func TestPaletteCtrlPClosesRatherThanReopening(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "exp") + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + after := got.(Model) + if after.overlay != overlayNone { + t.Errorf("ctrl+p left the overlay open with query %q; it reopened instead of closing", after.palette.query) + } +} + +// An overlay belongs to the screen it was raised over. A 401 relaunches the +// setup wizard, and a palette that survived would be drawn over — and would own +// the keyboard on — the one screen ctrl+p is not allowed on at all. +func TestPaletteDissolvesWhenAnErrorSwapsTheScreen(t *testing.T) { + t.Parallel() + t.Run("401 relaunches setup", func(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(errMsg{err: clickup.ErrUnauthorized}) + after := got.(Model) + if after.screen != screenSetup { + t.Fatalf("screen = %v, want screenSetup", after.screen) + } + if after.overlay != overlayNone { + t.Error("the palette survived onto the setup wizard") + } + }) + t.Run("a retryable error lands on the error screen", func(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + got, _ := m.Update(retryableErrMsg{origin: screenRates, err: errors.New("boom")}) + after := got.(Model) + if after.screen != screenError { + t.Fatalf("screen = %v, want screenError", after.screen) + } + // View returns early for screenError, so a surviving palette would be + // invisible AND would swallow the "press a key to return home" keypress. + if after.overlay != overlayNone { + t.Error("the palette survived onto the error screen, where it is invisible but still eats keys") + } + }) +} + +// A same-screen replace is not the screen swap the invariant above is about. +// Several async handlers call replace(): +// statusesMsg on screenFilters, membersMsg on screenMembers, +// entriesReloadedMsg/entriesErrMsg on screenEntries, logDoneMsg/timerMsg on +// screenLog. openFilters launches the status enrichment that produces +// statusesMsg, so a user can open the palette on screenFilters while that +// fetch is still in flight — and its return must not silently wipe the query +// out from under them. +func TestPaletteSurvivesReplaceOnTheSameScreen(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.screen = screenFilters + m = typeInto(openPaletteOn(m), "go") + + after := m.replace(screenFilters) + if after.overlay != overlayPalette { + t.Error("a same-screen replace dissolved the palette") + } + if after.palette.query != "go" { + t.Errorf("a same-screen replace wiped the query: got %q, want %q", after.palette.query, "go") + } +} + +// The box is positioned in body coordinates, but bubbletea keeps the LAST +// height lines of an overflowing view. Without the correction the box scrolls +// off the top of a long report. +func TestPaletteBoxStaysVisibleUnderALongBody(t *testing.T) { + t.Parallel() + p := openPaletteOn(newTestModelOnReport()).palette + _, _, shortY := p.layout(testTheme(true), 90, 30, 10) + if shortY != paletteTopY { + t.Errorf("y = %d on a body that fits, want %d", shortY, paletteTopY) + } + _, _, longY := p.layout(testTheme(true), 90, 30, 60) + if longY <= shortY { + t.Errorf("y = %d on a 60-line body in a 30-row terminal, want more than %d — the box would be scrolled away", longY, shortY) + } + // The box must land inside the window bubbletea will actually show. + if longY < 60+2-30 { + t.Errorf("y = %d is still above the visible window, which starts at body line %d", longY, 60+2-30) + } +} + +// j and k are cursor keys everywhere else in this program. In a query they are +// letters, and a filter you cannot type "kanban" into is not a filter. +func TestPaletteTypesJAndK(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "jk") + if q := m.palette.query; q != "jk" { + t.Errorf("query = %q, want %q — j/k moved the cursor instead of typing", q, "jk") + } +} + +// bubbletea reclassifies a lone space as KeySpace while leaving it in Runes. +// Without a branch for that type the query could never contain a space. +func TestPaletteQueryAcceptsSpace(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "log h") + if q := m.palette.query; q != "log h" { + t.Errorf("query = %q, want %q", q, "log h") + } + if len(m.palette.items) == 0 { + t.Error(`"log h" matched nothing; the space broke the filter`) + } +} + +// The permanent form of Task 5's temporary check: screenActions must keep +// answering for the screen underneath once the overlay owns keysFor. +func TestPaletteKeepsScreenActionsWhileTyping(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "e") + for _, it := range m.palette.items { + if it.a.hint != "" { + return // a screen action survived; that is all this test needs + } + } + t.Errorf("every row lost its key hint, so no screen action survived typing: %v", m.palette.items) +} + +func TestPaletteBackspace(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "exp") + got, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyBackspace}) + m = got.(Model) + if m.palette.query != "ex" { + t.Errorf("query = %q, want %q", m.palette.query, "ex") + } + m.palette.query = "" + got, _ = m.updateOverlay(tea.KeyMsg{Type: tea.KeyBackspace}) + if q := got.(Model).palette.query; q != "" { + t.Errorf("backspace on an empty query produced %q", q) + } +} + +func TestPaletteCursorClampsAndScrolls(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.height = 24 + if len(m.palette.items) <= paletteMaxRows { + t.Fatalf("the fixture has only %d actions; this test needs more than %d", len(m.palette.items), paletteMaxRows) + } + + up, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyUp}) + if i := up.(Model).palette.idx; i != 0 { + t.Errorf("idx = %d after up at the top, want 0", i) + } + + for range len(m.palette.items) + 3 { + got, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyDown}) + m = got.(Model) + } + if i, n := m.palette.idx, len(m.palette.items); i != n-1 { + t.Errorf("idx = %d, want %d — down ran past the last row", i, n-1) + } + rows := paletteRows(m.height) + if m.palette.idx < m.palette.top || m.palette.idx >= m.palette.top+rows { + t.Errorf("cursor %d is outside the visible window [%d, %d)", m.palette.idx, m.palette.top, m.palette.top+rows) + } +} + +// A query typed with the cursor sitting near the bottom of a longer list can +// shrink the list out from under it. Without refreshPalette resetting idx and +// top on every keystroke, idx would stay past the end of the new, shorter +// items slice, and Confirm's m.palette.items[m.palette.idx] would panic with +// an index out of range. +func TestPaletteRefreshResetsCursorWhenQueryShrinksTheList(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.height = 24 + if len(m.palette.items) <= paletteMaxRows { + t.Fatalf("the fixture has only %d actions; this test needs more than %d", len(m.palette.items), paletteMaxRows) + } + + for range len(m.palette.items) - 1 { + got, _ := m.updateOverlay(tea.KeyMsg{Type: tea.KeyDown}) + m = got.(Model) + } + if i, n := m.palette.idx, len(m.palette.items); i != n-1 { + t.Fatalf("idx = %d, want %d — the cursor did not reach the bottom of the full list", i, n-1) + } + + m = typeInto(m, "export") + if n := len(m.palette.items); n == 0 || n >= paletteMaxRows { + t.Fatalf(`query "export" left %d items; this test needs a short list for the old idx to land past its end`, n) + } + if i, n := m.palette.idx, len(m.palette.items); i < 0 || i >= n { + t.Fatalf("idx = %d out of range for %d items after the query shrank the list", i, n) + } + if m.palette.top < 0 || m.palette.top > m.palette.idx { + t.Errorf("top = %d, idx = %d — the scroll window was not reset with the cursor", m.palette.top, m.palette.idx) + } + + // Confirm must not panic indexing into the shrunk list. + got, _ := m.updateOverlay(keyMsg("enter")) + if after := got.(Model); after.screen != screenExport { + t.Errorf("screen = %v, want screenExport", after.screen) + } +} + +func TestPaletteEnterRunsTheSelectedActionAndCloses(t *testing.T) { + t.Parallel() + m := newTestModelOnReport() + m.report = goldenReport() + m = typeInto(openPaletteOn(m), "export") + + got, _ := m.updateOverlay(keyMsg("enter")) + after := got.(Model) + if after.overlay != overlayNone { + t.Error("the palette stayed open after running an action; it would render over the new screen") + } + if after.screen != screenExport { + t.Errorf("screen = %v, want screenExport", after.screen) + } +} + +func TestPaletteEnterOnAnEmptyListDoesNothing(t *testing.T) { + t.Parallel() + m := typeInto(openPaletteOn(newTestModelOnReport()), "zzzzzz") + if len(m.palette.items) != 0 { + t.Fatalf("expected no matches for %q, got %d", "zzzzzz", len(m.palette.items)) + } + got, cmd := m.updateOverlay(keyMsg("enter")) + if cmd != nil { + t.Error("enter on an empty list issued a command") + } + if got.(Model).screen != screenReport { + t.Errorf("screen = %v, want screenReport unchanged", got.(Model).screen) + } +} + +func TestPaletteDoesNotOpenOnSetupLoadingOrError(t *testing.T) { + t.Parallel() + for _, s := range []screen{screenSetup, screenLoading, screenError} { + m := newTestModel() + m.screen = s + got, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + if got.(Model).overlay != overlayNone { + t.Errorf("ctrl+p opened the palette on %v", s) + } + } +} + +func TestPaletteBoxIsExactlyItsWidth(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.width, m.height = 100, 30 + box, x, y := m.palette.layout(testTheme(true), m.width, m.height, 12) + if y != paletteTopY { + t.Errorf("y = %d, want %d", y, paletteTopY) + } + lines := strings.Split(box, "\n") + want := lipgloss.Width(lines[0]) + for i, l := range lines { + if w := lipgloss.Width(l); w != want { + t.Errorf("box line %d is %d cells, want %d: %q", i, w, want, l) + } + } + if x+want > m.width { + t.Errorf("the box overflows: x=%d width=%d terminal=%d", x, want, m.width) + } +} + +// A query longer than the box's inner width must be shaved like every other +// line box builds, or it breaks the box's own right border with nothing +// noticing. +func TestPaletteLongQueryStaysInsideTheBox(t *testing.T) { + t.Parallel() + m := openPaletteOn(newTestModelOnReport()) + m.width, m.height = 100, 30 + m = typeInto(m, strings.Repeat("x", paletteWidth*2)) + + box, _, _ := m.palette.layout(testTheme(true), m.width, m.height, 12) + lines := strings.Split(box, "\n") + want := lipgloss.Width(lines[0]) + for i, l := range lines { + if w := lipgloss.Width(l); w != want { + t.Errorf("box line %d is %d cells, want %d: %q", i, w, want, l) + } + } +} + +// colorTheme builds a theme on a renderer with a real color profile. testTheme +// pins termenv.Ascii, which strips every escape, so a style assertion made +// through it cannot fail no matter what highlight does. +func colorTheme() theme { + r := lipgloss.NewRenderer(io.Discard) + r.SetColorProfile(termenv.ANSI) + r.SetHasDarkBackground(true) + return newTheme(r, defaultPalette()) +} + +func TestPaletteHighlightUsesTheAccentStyle(t *testing.T) { + t.Parallel() + th := colorTheme() + if probe := th.Accent.Render("x"); !strings.Contains(probe, "\x1b[") { + t.Fatalf("the accent style renders no escape (%q); this test would pass vacuously", probe) + } + got := highlight(th, "export", []int{0, 1, 2}) + if w := lipgloss.Width(got); w != 6 { + t.Errorf("highlight changed the label's width: %d cells, %q", w, got) + } + if !strings.HasPrefix(got, th.Accent.Render("exp")) { + t.Errorf("the matched prefix is not rendered in the accent style: %q", got) + } + if !strings.HasSuffix(got, th.Cell.Render("ort")) { + t.Errorf("the unmatched tail picked up the accent style: %q", got) + } +} + +// fuzzy.Match indexes the FULL label, so a truncated label must drop any +// index landing at or past the cut — and the ellipsis must never light up. +// +// The query is chosen so its match lands EXACTLY on the ellipsis column, not +// merely "somewhere past the cut": "t" is label's 20th rune (index 19), and +// shaveToWidth(label, 20) replaces that very column with "…". That is the only +// boundary this test can actually observe. An index further out (the original +// version of this test used "z9", matching at columns 25 and 35) is never even +// read: the render loop below only walks the truncated string's own 20 runes, +// so such an index sits in the hit map but nothing ever looks it up — dropping +// it or not produces byte-identical output, and the test would pass against a +// highlight that drops nothing at all. +func TestPaletteHighlightSurvivesTruncation(t *testing.T) { + t.Parallel() + th := colorTheme() + label := "abcdefghijklmnopqrstuvwxyz0123456789" + _, idx, ok := fuzzy.Match("t", label) + if !ok { + t.Fatal(`Match("t", label) did not match`) + } + if want := []int{19}; !slices.Equal(idx, want) { + t.Fatalf("idx = %v, want %v — this test needs the match to land exactly on the ellipsis column shaveToWidth(label, 20) produces", idx, want) + } + + short := shaveToWidth(label, 20) + got := highlight(th, short, idx) + if w := lipgloss.Width(got); w != 20 { + t.Errorf("highlighted label is %d cells, want 20: %q", w, got) + } + // The one match landed on the ellipsis itself, so nothing may be accented. + if want := th.Cell.Render(short); got != want { + t.Errorf("highlight lit up the ellipsis:\n got %q\nwant %q", got, want) + } +} diff --git a/internal/tui/range_test.go b/internal/tui/range_test.go index 18cd3f4..db0b2ae 100644 --- a/internal/tui/range_test.go +++ b/internal/tui/range_test.go @@ -15,7 +15,7 @@ import ( func TestRangeListModeKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRange, rangeScreen: newRange(report.PresetThisMonth), nav: []screen{screenHome}} - want := []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("range list-mode labels = %v, want %v", got, want) } @@ -28,7 +28,7 @@ func TestRangeEditingModeKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRange, rangeScreen: newRange(report.PresetThisMonth), nav: []screen{screenHome}} m.rangeScreen.editing = true - want := []string{"ctrl+c", "enter", "esc", "shift+tab", "tab"} + want := []string{"ctrl+c", "ctrl+p", "enter", "esc", "shift+tab", "tab"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("range editing-mode labels = %v, want %v", got, want) } diff --git a/internal/tui/rates_test.go b/internal/tui/rates_test.go index a2582c8..ce498ec 100644 --- a/internal/tui/rates_test.go +++ b/internal/tui/rates_test.go @@ -639,7 +639,7 @@ func TestRatesNewOverrideOnExistingPairUpdatesIt(t *testing.T) { func TestRatesNormalModeKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRates, ratesScreen: ratesModel{sec: secRules}} - want := []string{"?", "ctrl+c", "d", "down", "enter", "esc", "h", "j", "k", "l", "left", "right", "s", "shift+tab", "tab", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "d", "down", "enter", "esc", "h", "j", "k", "l", "left", "right", "s", "shift+tab", "tab", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("rates normal-mode labels = %v, want %v", got, want) } @@ -689,7 +689,7 @@ func TestRatesNewOverrideKeyGatedToOverridesSection(t *testing.T) { func TestRatesDraftPickListKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRates, ratesScreen: ratesModel{draft: overrideDraft{active: true, step: draftPickList}}} - want := []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("rates draft pick-list labels = %v, want %v", got, want) } @@ -698,7 +698,7 @@ func TestRatesDraftPickListKeyLabels(t *testing.T) { func TestRatesDraftPickMemberKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRates, ratesScreen: ratesModel{draft: overrideDraft{active: true, step: draftPickMember}}} - want := []string{"?", "ctrl+c", "down", "enter", "esc", "j", "k", "up"} + want := []string{"?", "ctrl+c", "ctrl+p", "down", "enter", "esc", "j", "k", "up"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("rates draft pick-member labels = %v, want %v", got, want) } @@ -755,7 +755,7 @@ func TestRatesDraftRateStepKeyLabels(t *testing.T) { edit: editOverrideRate, draft: overrideDraft{active: true, step: draftRate}, }} - want := []string{"ctrl+c", "enter", "esc"} + want := []string{"ctrl+c", "ctrl+p", "enter", "esc"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("rates draft rate-step labels = %v, want %v", got, want) } @@ -769,7 +769,7 @@ func TestRatesDraftRateStepKeyLabels(t *testing.T) { func TestRatesEditingModeKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenRates, ratesScreen: ratesModel{editing: true, edit: editListRate}} - want := []string{"ctrl+c", "enter", "esc"} + want := []string{"ctrl+c", "ctrl+p", "enter", "esc"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("rates editing-mode labels = %v, want %v", got, want) } diff --git a/internal/tui/report.go b/internal/tui/report.go index 08139ec..2ee84a2 100644 --- a/internal/tui/report.go +++ b/internal/tui/report.go @@ -90,39 +90,19 @@ func (m Model) updateReport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m = m.replace(screenLoading) return m, m.reloadEntriesCmd(screenReport) case key.Matches(msg, k.Export): - m.export = newExport(m.report) - m = m.goTo(screenExport) + m = m.openExport() case key.Matches(msg, k.Rates): - m.ratesScreen = newRates(m.entries, m.cfg) - m = m.goTo(screenRates) + m = m.openRates() case key.Matches(msg, k.LogHours): - m.logScreen = newLog(m.entries, m.cfg) - m = m.goTo(screenLog) + m = m.openLog() case key.Matches(msg, k.Filters): - missing := m.tasksMissingStatus() - if len(missing) == 0 { - m.assignStatuses() - m.filtersScreen = newFilters(m.entries, m.filterLists, m.filterTags, m.filterStatuses, m.filterBillable) - m = m.goTo(screenFilters) - return m, nil - } - m.filtersScreen = filtersModel{loadingStatuses: true} - m = m.goTo(screenFilters) - if m.demo { - return m, demoStatusEnrichCmd(m.entries) - } - return m, statusEnrichCmd(m.client, missing) + return m.openFilters() case key.Matches(msg, k.Budget): if !m.openBudgetView() { return m, nil } case key.Matches(msg, k.OpenEntries): - m = m.openEntries() - var cmd tea.Cmd - if m.userID == 0 { - cmd = m.currentUserCmd() // lazy retry so gating can enable - } - return m, cmd + return m.openEntries() } return m, nil } diff --git a/internal/tui/report_test.go b/internal/tui/report_test.go index ce086b0..9cf230b 100644 --- a/internal/tui/report_test.go +++ b/internal/tui/report_test.go @@ -17,7 +17,7 @@ import ( func TestReportKeyLabels(t *testing.T) { t.Parallel() m := Model{screen: screenReport} - want := []string{"?", "b", "ctrl+c", "e", "esc", "f", "g", "m", "n", "p", "q", "r", "s", "v"} + want := []string{"?", "b", "ctrl+c", "ctrl+p", "e", "esc", "f", "g", "m", "n", "p", "q", "r", "s", "v"} if got := enabledLabels(keysFor(m)); !slices.Equal(got, want) { t.Errorf("report labels = %v, want %v", got, want) } diff --git a/internal/tui/testdata/footer_budget_full.golden b/internal/tui/testdata/footer_budget_full.golden index 6f3c414..dfa9d0a 100644 --- a/internal/tui/testdata/footer_budget_full.golden +++ b/internal/tui/testdata/footer_budget_full.golden @@ -1,3 +1,4 @@ -b close budgets ? help - esc back - q quit \ No newline at end of file +b close budgets ? help + esc back + q quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_edit_billable_full.golden b/internal/tui/testdata/footer_entries_edit_billable_full.golden index 98a8550..b6f1c26 100644 --- a/internal/tui/testdata/footer_entries_edit_billable_full.golden +++ b/internal/tui/testdata/footer_entries_edit_billable_full.golden @@ -1,3 +1,4 @@ -y/enter yes ? help -n no esc back - ctrl+c force quit \ No newline at end of file +y/enter yes ? help +n no esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_edit_date_full.golden b/internal/tui/testdata/footer_entries_edit_date_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_entries_edit_date_full.golden +++ b/internal/tui/testdata/footer_entries_edit_date_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_edit_duration_full.golden b/internal/tui/testdata/footer_entries_edit_duration_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_entries_edit_duration_full.golden +++ b/internal/tui/testdata/footer_entries_edit_duration_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_edit_note_full.golden b/internal/tui/testdata/footer_entries_edit_note_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_entries_edit_note_full.golden +++ b/internal/tui/testdata/footer_entries_edit_note_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_edit_time_full.golden b/internal/tui/testdata/footer_entries_edit_time_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_entries_edit_time_full.golden +++ b/internal/tui/testdata/footer_entries_edit_time_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_history_full.golden b/internal/tui/testdata/footer_entries_history_full.golden index 5736213..42b9d3c 100644 --- a/internal/tui/testdata/footer_entries_history_full.golden +++ b/internal/tui/testdata/footer_entries_history_full.golden @@ -1,3 +1,4 @@ -? help -esc back -ctrl+c force quit \ No newline at end of file +? help +esc back +ctrl+c force quit +ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_list_empty_full.golden b/internal/tui/testdata/footer_entries_list_empty_full.golden index 7db0fa5..bb14eb3 100644 --- a/internal/tui/testdata/footer_entries_list_empty_full.golden +++ b/internal/tui/testdata/footer_entries_list_empty_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help - esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help + esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_list_full.golden b/internal/tui/testdata/footer_entries_list_full.golden index dc8871a..393cf75 100644 --- a/internal/tui/testdata/footer_entries_list_full.golden +++ b/internal/tui/testdata/footer_entries_list_full.golden @@ -1,4 +1,4 @@ -↑/↓/j/k move e edit ? help - x delete esc back - t tags ctrl+c force quit - h history \ No newline at end of file +↑/↓/j/k move e edit ? help + x delete esc back + t tags ctrl+c force quit + h history ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_tags_full.golden b/internal/tui/testdata/footer_entries_tags_full.golden index 833f8db..7eeb819 100644 --- a/internal/tui/testdata/footer_entries_tags_full.golden +++ b/internal/tui/testdata/footer_entries_tags_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move n new tag ? help -space toggle enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move n new tag ? help +space toggle enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_entries_tags_new_mode_full.golden b/internal/tui/testdata/footer_entries_tags_new_mode_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_entries_tags_new_mode_full.golden +++ b/internal/tui/testdata/footer_entries_tags_new_mode_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_export_full.golden b/internal/tui/testdata/footer_export_full.golden index e1c5400..da96614 100644 --- a/internal/tui/testdata/footer_export_full.golden +++ b/internal/tui/testdata/footer_export_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - q quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + q quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_filters_full.golden b/internal/tui/testdata/footer_filters_full.golden index 4b8450e..bed8e22 100644 --- a/internal/tui/testdata/footer_filters_full.golden +++ b/internal/tui/testdata/footer_filters_full.golden @@ -1,4 +1,4 @@ -tab/⇧tab section enter confirm ? help -↑/↓/j/k move esc back -space toggle q quit -a all/none \ No newline at end of file +tab/⇧tab section enter confirm ? help +↑/↓/j/k move esc back +space toggle q quit +a all/none ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_home_full.golden b/internal/tui/testdata/footer_home_full.golden index 02acf12..bef6b34 100644 --- a/internal/tui/testdata/footer_home_full.golden +++ b/internal/tui/testdata/footer_home_full.golden @@ -1,2 +1,3 @@ -◂/▸/h/l change month d range enter generate report ? help -w this week/month t me/team n log hours q quit \ No newline at end of file +◂/▸/h/l change month d range enter generate report ? help +w this week/month t me/team n log hours q quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_home_team_timer_full.golden b/internal/tui/testdata/footer_home_team_timer_full.golden index f2fe9de..7ae32a6 100644 --- a/internal/tui/testdata/footer_home_team_timer_full.golden +++ b/internal/tui/testdata/footer_home_team_timer_full.golden @@ -1,3 +1,3 @@ -◂/▸/h/l change month d range enter generate report ? help -w this week/month t me/team n log hours q quit - f select members c timer \ No newline at end of file +◂/▸/h/l change month d range enter generate report ? help +w this week/month t me/team n log hours q quit + f select members c timer ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_home_timer_full.golden b/internal/tui/testdata/footer_home_timer_full.golden index 1efcae6..1938ae3 100644 --- a/internal/tui/testdata/footer_home_timer_full.golden +++ b/internal/tui/testdata/footer_home_timer_full.golden @@ -1,3 +1,3 @@ -◂/▸/h/l change month d range enter generate report ? help -w this week/month t me/team n log hours q quit - c timer \ No newline at end of file +◂/▸/h/l change month d range enter generate report ? help +w this week/month t me/team n log hours q quit + c timer ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_listbrowser_full.golden b/internal/tui/testdata/footer_listbrowser_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_listbrowser_full.golden +++ b/internal/tui/testdata/footer_listbrowser_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_done_full.golden b/internal/tui/testdata/footer_log_done_full.golden index cd81885..aeeb90c 100644 --- a/internal/tui/testdata/footer_log_done_full.golden +++ b/internal/tui/testdata/footer_log_done_full.golden @@ -1,3 +1,4 @@ -r reload ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +r reload ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_form_billable_full.golden b/internal/tui/testdata/footer_log_form_billable_full.golden index 98a8550..b6f1c26 100644 --- a/internal/tui/testdata/footer_log_form_billable_full.golden +++ b/internal/tui/testdata/footer_log_form_billable_full.golden @@ -1,3 +1,4 @@ -y/enter yes ? help -n no esc back - ctrl+c force quit \ No newline at end of file +y/enter yes ? help +n no esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_form_date_full.golden b/internal/tui/testdata/footer_log_form_date_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_log_form_date_full.golden +++ b/internal/tui/testdata/footer_log_form_date_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_form_duration_full.golden b/internal/tui/testdata/footer_log_form_duration_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_log_form_duration_full.golden +++ b/internal/tui/testdata/footer_log_form_duration_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_form_note_full.golden b/internal/tui/testdata/footer_log_form_note_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_log_form_note_full.golden +++ b/internal/tui/testdata/footer_log_form_note_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_id_input_full.golden b/internal/tui/testdata/footer_log_id_input_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_log_id_input_full.golden +++ b/internal/tui/testdata/footer_log_id_input_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_list_pick_full.golden b/internal/tui/testdata/footer_log_list_pick_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_log_list_pick_full.golden +++ b/internal/tui/testdata/footer_log_list_pick_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_mode_select_full.golden b/internal/tui/testdata/footer_log_mode_select_full.golden index 284364d..5a03556 100644 --- a/internal/tui/testdata/footer_log_mode_select_full.golden +++ b/internal/tui/testdata/footer_log_mode_select_full.golden @@ -1,3 +1,4 @@ -1 guided ? help -2 task ID/URL esc back -3 timer ctrl+c force quit \ No newline at end of file +1 guided ? help +2 task ID/URL esc back +3 timer ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_task_pick_full.golden b/internal/tui/testdata/footer_log_task_pick_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_log_task_pick_full.golden +++ b/internal/tui/testdata/footer_log_task_pick_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_timer_pick_full.golden b/internal/tui/testdata/footer_log_timer_pick_full.golden index 78444e6..8150445 100644 --- a/internal/tui/testdata/footer_log_timer_pick_full.golden +++ b/internal/tui/testdata/footer_log_timer_pick_full.golden @@ -1,3 +1,4 @@ -1 guided ? help -2 task ID/URL esc back - ctrl+c force quit \ No newline at end of file +1 guided ? help +2 task ID/URL esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_log_timer_running_full.golden b/internal/tui/testdata/footer_log_timer_running_full.golden index 0c8ce07..e001bf7 100644 --- a/internal/tui/testdata/footer_log_timer_running_full.golden +++ b/internal/tui/testdata/footer_log_timer_running_full.golden @@ -1,3 +1,4 @@ -s stop timer ? help - esc back - ctrl+c force quit \ No newline at end of file +s stop timer ? help + esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_members_full.golden b/internal/tui/testdata/footer_members_full.golden index 7f4d2e4..a41bfc3 100644 --- a/internal/tui/testdata/footer_members_full.golden +++ b/internal/tui/testdata/footer_members_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move enter confirm ? help -space toggle esc back -a all/none q quit \ No newline at end of file +↑/↓/j/k move enter confirm ? help +space toggle esc back +a all/none q quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_range_editing_mode_full.golden b/internal/tui/testdata/footer_range_editing_mode_full.golden index 95765d3..b82fdfb 100644 --- a/internal/tui/testdata/footer_range_editing_mode_full.golden +++ b/internal/tui/testdata/footer_range_editing_mode_full.golden @@ -1,2 +1,3 @@ -tab/⇧tab next/prev field esc back -enter confirm ctrl+c force quit \ No newline at end of file +tab/⇧tab next/prev field esc back +enter confirm ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_range_list_mode_full.golden b/internal/tui/testdata/footer_range_list_mode_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_range_list_mode_full.golden +++ b/internal/tui/testdata/footer_range_list_mode_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_draft_pick_list_full.golden b/internal/tui/testdata/footer_rates_draft_pick_list_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_rates_draft_pick_list_full.golden +++ b/internal/tui/testdata/footer_rates_draft_pick_list_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_draft_pick_member_full.golden b/internal/tui/testdata/footer_rates_draft_pick_member_full.golden index 2735ca8..9f68d98 100644 --- a/internal/tui/testdata/footer_rates_draft_pick_member_full.golden +++ b/internal/tui/testdata/footer_rates_draft_pick_member_full.golden @@ -1,3 +1,4 @@ -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_draft_rate_step_full.golden b/internal/tui/testdata/footer_rates_draft_rate_step_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_rates_draft_rate_step_full.golden +++ b/internal/tui/testdata/footer_rates_draft_rate_step_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_editing_full.golden b/internal/tui/testdata/footer_rates_editing_full.golden index 4ab669c..8150b6e 100644 --- a/internal/tui/testdata/footer_rates_editing_full.golden +++ b/internal/tui/testdata/footer_rates_editing_full.golden @@ -1,2 +1,3 @@ -enter confirm esc back - ctrl+c force quit \ No newline at end of file +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_normal_full.golden b/internal/tui/testdata/footer_rates_normal_full.golden index 0b1ee8f..c87e4e6 100644 --- a/internal/tui/testdata/footer_rates_normal_full.golden +++ b/internal/tui/testdata/footer_rates_normal_full.golden @@ -1,4 +1,5 @@ -tab/⇧tab section d clear/revert s save -↑/↓/j/k move ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +tab/⇧tab section d clear/revert s save +↑/↓/j/k move ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_normal_lists_full.golden b/internal/tui/testdata/footer_rates_normal_lists_full.golden index 85d4610..d525bfc 100644 --- a/internal/tui/testdata/footer_rates_normal_lists_full.golden +++ b/internal/tui/testdata/footer_rates_normal_lists_full.golden @@ -1,4 +1,5 @@ -tab/⇧tab section c currency (empty clears) s save -↑/↓/j/k move g budget (empty clears) ? help -enter confirm d use the default rate esc back - b browse lists ctrl+c force quit \ No newline at end of file +tab/⇧tab section c currency (empty clears) s save +↑/↓/j/k move g budget (empty clears) ? help +enter confirm d use the default rate esc back + b browse lists ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_rates_normal_overrides_full.golden b/internal/tui/testdata/footer_rates_normal_overrides_full.golden index 04cb8e8..4a3ce11 100644 --- a/internal/tui/testdata/footer_rates_normal_overrides_full.golden +++ b/internal/tui/testdata/footer_rates_normal_overrides_full.golden @@ -1,4 +1,5 @@ -tab/⇧tab section n new override s save -↑/↓/j/k move d delete this override ? help -enter confirm esc back - ctrl+c force quit \ No newline at end of file +tab/⇧tab section n new override s save +↑/↓/j/k move d delete this override ? help +enter confirm esc back + ctrl+c force quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/footer_report_full.golden b/internal/tui/testdata/footer_report_full.golden index 14d89b5..c568d44 100644 --- a/internal/tui/testdata/footer_report_full.golden +++ b/internal/tui/testdata/footer_report_full.golden @@ -1,5 +1,6 @@ -g grouping f filters m/s change range/scope -e export b budgets r reload -p rates v entries ? help -n log hours esc back - q quit \ No newline at end of file +g grouping f filters m/s change range/scope +e export b budgets r reload +p rates v entries ? help +n log hours esc back + q quit + ctrl+p command palette \ No newline at end of file diff --git a/internal/tui/testdata/palette_filtered.golden b/internal/tui/testdata/palette_filtered.golden new file mode 100644 index 0000000..3f8a359 --- /dev/null +++ b/internal/tui/testdata/palette_filtered.golden @@ -0,0 +1,17 @@ +Report July 2026 — scope me — grouped by list — tz UTC + + ╭─ Command palette ────────────────────────────────╮ +0 billing lines · 1│ > exp │ + ├──────────────────────────────────────────────────┤ +hours/day ▅ ▂▆ ▃█ │ ▸ Export e │ + │ Go to export │ +╭──────────────────╰──────────────────────────────────────────────────╯ +│ Item Hours Billed Amount │ +├─────────────────────────────────────┤ +│ Website 12.50 12.50 625.00 EUR │ +│ Internal 3.00 0.00 0.00 EUR │ +│ TOTAL 15.50 12.50 625.00 EUR │ +╰─────────────────────────────────────╯ + billable 12.50h · non-billable 3.00h + +↑/↓ move · enter run · esc close · ctrl+c force quit \ No newline at end of file diff --git a/internal/tui/testdata/palette_narrow.golden b/internal/tui/testdata/palette_narrow.golden new file mode 100644 index 0000000..bf5cba7 --- /dev/null +++ b/internal/tui/testdata/palette_narrow.golden @@ -0,0 +1,17 @@ +Report July 2026 — scope me — grouped by list — tz UTC + + ╭─ Command palette ────────────────╮ +0 │ > │ + ├──────────────────────────────────┤ +ho│ ▸ Grouping g │ + │ Change range/scope m/s │ +╭─│ Reload r │ +│ │ Export e │ +├─│ Rates p │ +│ │ Filters f │ +│ │ Budgets b │ +│ │ Entries v │ +╰─╰──────────────────────────────────╯ + billable 12.50h · non-billable 3.00h + +↑/↓ move · enter run · esc close … \ No newline at end of file diff --git a/internal/tui/testdata/palette_no_match.golden b/internal/tui/testdata/palette_no_match.golden new file mode 100644 index 0000000..fd80af2 --- /dev/null +++ b/internal/tui/testdata/palette_no_match.golden @@ -0,0 +1,17 @@ +Report July 2026 — scope me — grouped by list — tz UTC + + ╭─ Command palette ────────────────────────────────╮ +0 billing lines · 1│ > zzzzzz │ + ├──────────────────────────────────────────────────┤ +hours/day ▅ ▂▆ ▃█ │ no matching action │ + ╰──────────────────────────────────────────────────╯ +╭─────────────────────────────────────╮ +│ Item Hours Billed Amount │ +├─────────────────────────────────────┤ +│ Website 12.50 12.50 625.00 EUR │ +│ Internal 3.00 0.00 0.00 EUR │ +│ TOTAL 15.50 12.50 625.00 EUR │ +╰─────────────────────────────────────╯ + billable 12.50h · non-billable 3.00h + +↑/↓ move · enter run · esc close · ctrl+c force quit \ No newline at end of file diff --git a/internal/tui/testdata/palette_report.golden b/internal/tui/testdata/palette_report.golden new file mode 100644 index 0000000..96ee412 --- /dev/null +++ b/internal/tui/testdata/palette_report.golden @@ -0,0 +1,17 @@ +Report July 2026 — scope me — grouped by list — tz UTC + + ╭─ Command palette ────────────────────────────────╮ +0 billing lines · 1│ > │ + ├──────────────────────────────────────────────────┤ +hours/day ▅ ▂▆ ▃█ │ ▸ Grouping g │ + │ Change range/scope m/s │ +╭──────────────────│ Reload r │ +│ Item Hours │ Export e │ +├──────────────────│ Rates p │ +│ Website 12.50 │ Filters f │ +│ Internal 3.00 │ Budgets b │ +│ TOTAL 15.50 │ Entries v │ +╰──────────────────╰──────────────────────────────────────────────────╯ + billable 12.50h · non-billable 3.00h + +↑/↓ move · enter run · esc close · ctrl+c force quit \ No newline at end of file diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index e3ddc2d..836a551 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -62,7 +62,7 @@ func paletteTheme(dark bool) theme { return newTheme(r, defaultPalette()) } -// paletteSample renders one labelled line per style, so a single golden pins +// paletteSample renders one labeled line per style, so a single golden pins // every token's color. func paletteSample(th theme) string { var b strings.Builder