diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md index c971474..a2f95a0 100644 --- a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md @@ -1,42 +1,56 @@ -# Phase 4: Compact Insights Categories Implementation Plan +# Compact Insights categories 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. -**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` +**Goal:** Replace the unsupported Insights period selector with all-time category navigation that stays visible inside Pi's overlay at the minimum supported terminal size. -**Goal:** Replace the unsupported Insights period selector with all-time category navigation that remains fully visible in Pi's overlay at the minimum supported terminal size. +**Architecture:** Keep `UsageDashboardComponent`, its frame, and its existing tab renderer. Derive populated Insight categories in fixed order, retain one selected category ID as component state, and render only that category's existing capped items. Statistics keeps its separate period state. -**Architecture:** Keep the existing dashboard component, frame, and tab renderer. Derive populated Insight categories in fixed order, retain one category ID as UI state, and render only that category's existing capped items. +**Tech Stack:** TypeScript 6, Vitest 4, pnpm, `@earendil-works/pi-coding-agent` 0.82.1, and `@earendil-works/pi-tui` 0.82.1. The installed packages remain unchanged; Pi 0.82.0 source is used as the overlay sizing reference. -**Tech Stack:** TypeScript 6, Vitest 4, Pi TUI 0.82.0. +**Design reference:** `docs/superpowers/specs/2026-07-27-compact-insights-design.md` -**Phase dependency:** Phase 3 is committed and `pnpm check` passes. +**Phase dependency:** Phase 3 is committed. The baseline worktree is clean and `pnpm check` passes 258 tests. -**Usable result:** Insights truthfully displays all-time data, Left/Right switches populated categories, and the footer and bottom frame remain visible at 40×24 and larger terminals. +**Usable result:** Insights shows only populated all-time categories, Left/Right cycles them independently of Statistics, and the footer and bottom frame remain visible at 40×24. -**Out of scope:** Offline insight calculations, category item caps, provider behavior, scrolling, new TUI components, and unrelated dashboard refactors. +**Out of scope:** Offline insight calculations, category item caps, provider behavior, scrolling, new TUI components, dependency changes, overlay option changes, and unrelated dashboard refactors. -## Fixed layout contract +--- + +## File map + +- Modify `tests/dashboard.test.ts` for category selection, rendering, state-update fallback, and height-budget coverage. +- Modify `tests/constants.test.ts` for the new Insights footer string. +- Modify `src/tui/dashboard.ts` for fixed category descriptors, selected category state, populated-category derivation, rendering, and input handling. +- Modify `src/shared/constants.ts` for the Insights footer shortcut. +- Do not modify Pi dependencies, offline insight generation, provider code, public types, or overlay configuration. -- Category order: Projects, Skills, MCP servers, Cost patterns. +## Fixed behavior contract + +- Category order is Projects, Skills, MCP servers, Cost patterns. - Categories with no items are omitted. -- Missing `category` values retain current behavior and belong to Cost patterns. -- The first populated category is the effective default. -- If state updates remove the selected category, selection permanently falls back to the first populated category. -- Insights are all-time; Today, This Week, Last Week, and All Time controls do not render in this tab. -- At a 40×24 terminal, Pi computes overlay width `floor(40 × 0.92) = 36` and maximum height `floor(24 × 0.85) = 20`. The component must render no more than 20 lines at width 36. -- At widths 73 and 100, the component must render no more than 17 lines. +- Missing `category` values map to Cost patterns. +- Unknown category strings remain excluded, matching the current renderer. +- The first populated category is the default. +- Left and Right cycle populated categories with wraparound. +- If the selected category disappears, selection is stored as the first populated category. If the old category returns later, the fallback remains selected. +- Statistics period state is unaffected by Insights navigation. +- Insights does not render Today, This Week, Last Week, or All Time controls. +- At component widths 36, 73, and 100, rendered output must contain at most 20, 17, and 17 lines respectively. +- Pi 0.82.0's reference implementation floors percentage sizes and slices overlay output after `maxHeight`; the current installed 0.82.1 packages remain in use. --- -### Task 1: Specify category selection behavior +### Task 1: Add failing category behavior tests **Files:** + - Modify: `tests/dashboard.test.ts` -- [ ] **Step 1: Add a shared tab-switch helper** +- [ ] **Step 1: Add the shared Insights tab helper** -Add near the dashboard tests: +Add this function after `mkState()` and before the first dashboard test: ```ts function switchToInsights(component: UsageDashboardComponent): void { @@ -45,11 +59,11 @@ function switchToInsights(component: UsageDashboardComponent): void { } ``` -Use it in new and existing tests that switch from Statistics to Insights. +Use this helper in every test that switches from the default Statistics tab to Insights. -- [ ] **Step 2: Replace the independent-period test** +- [ ] **Step 2: Replace the first stale grouped-category test** -Delete `has independent period selector for Insights tab`. Add: +Replace the test named `renders Insights tab with insights grouped by category` with: ```ts it("shows only populated Insight categories and defaults to the first", () => { @@ -77,7 +91,9 @@ it("shows only populated Insight categories and defaults to the first", () => { }); ``` -- [ ] **Step 3: Add category cycling without Statistics-state coupling** +- [ ] **Step 3: Replace the obsolete independent-period test** + +Delete the test named `has independent period selector for Insights tab` and add: ```ts it("cycles Insight categories without changing the Statistics period", () => { @@ -91,6 +107,8 @@ it("cycles Insight categories without changing the Statistics period", () => { }); c.handleInput("\u001b[D"); + expect(c.render(120).join("\n")).toContain("[Last Week]"); + switchToInsights(c); c.handleInput("\u001b[C"); let out = c.render(100).join("\n"); @@ -104,10 +122,14 @@ it("cycles Insight categories without changing the Statistics period", () => { }); ``` -- [ ] **Step 4: Add durable fallback after a state update** +This proves one Left in Statistics remains active after category navigation. The category test starts with Projects selected and Right moves to Cost patterns. + +- [ ] **Step 4: Add durable fallback coverage** + +Add: ```ts -it("falls back when the selected Insight category disappears", () => { +it("falls back permanently when the selected Insight category disappears", () => { const state = mkState(); state.insights = [ { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, @@ -135,26 +157,85 @@ it("falls back when the selected Insight category disappears", () => { }); ``` -The final assertion proves fallback updates the selected ID rather than temporarily substituting rendered content. +The final assertion proves the component stores the fallback ID instead of temporarily substituting the first category during rendering. + +- [ ] **Step 5: Rewrite the duplicate grouped-rendering test** -- [ ] **Step 5: Run the dashboard tests and confirm failure** +Replace the later test named `renders insights grouped by category in Insights tab` with: + +```ts +it("renders the selected Insight category with its existing format", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, + { category: "project", label: "dotfiles", cost: 1, detail: "10.0%" }, + { + category: "cost", + label: "Large context", + cost: 5, + detail: "50.0% over 150k context", + }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + let lines = c.render(100); + let out = lines.join("\n"); + expect(out).toContain("[Projects]"); + expect(out).toContain("Cost patterns"); + expect(out).toContain("career-ops"); + expect(out).toContain("90.0%"); + expect(out).not.toContain("Large context"); + + const projectsIdx = lines.findIndex((line) => line.includes("% of usage")); + expect(projectsIdx).toBeGreaterThan(-1); + expect(lines[projectsIdx + 1]).toContain("career-ops"); + + c.handleInput("\u001b[C"); + lines = c.render(100); + out = lines.join("\n"); + expect(out).toContain("[Cost patterns]"); + expect(out).toContain("Large context"); + expect(out).toContain(" - Large context:"); + expect(out).not.toContain("career-ops"); +}); +``` + +- [ ] **Step 6: Update existing missing-category test to use the helper** + +In `defaults insights without category to cost patterns`, replace the two direct Tab calls with: + +```ts +switchToInsights(c); +``` + +Keep its assertions for `Cost patterns` and `No category` unchanged. + +- [ ] **Step 7: Run the dashboard tests and confirm the expected failure** + +Run: ```sh pnpm test -- tests/dashboard.test.ts ``` -Expected: FAIL because Insights still renders periods and all categories simultaneously. +Expected: FAIL because the current implementation renders period tabs, renders every category at once, and stores an Insights period index rather than a category ID. The existing test suite must not be treated as passing until the stale assertions are replaced. --- -### Task 2: Specify the Pi overlay height budget and footer +### Task 2: Add the height-budget and footer tests **Files:** + - Modify: `tests/dashboard.test.ts` - Modify: `tests/constants.test.ts` - [ ] **Step 1: Add the maximum-category line-budget test** +Add to the dashboard render tests: + ```ts it("keeps a maximum Insight category inside the supported overlay height", () => { const state = mkState(); @@ -180,17 +261,26 @@ it("keeps a maximum Insight category inside the supported overlay height", () => }); ``` -- [ ] **Step 2: Change dashboard footer expectations** +- [ ] **Step 2: Update the per-tab footer assertion** -In `renders context-aware footer per tab`, replace the Insights period assertion with: +In `renders context-aware footer per tab`, replace: + +```ts +expect(stripped).toContain("[Left/Right] Period"); +``` + +in the Insights section with: ```ts expect(stripped).toContain("[Left/Right] Category"); +expect(stripped).not.toContain("[Left/Right] Period"); ``` -- [ ] **Step 3: Change the constant test** +Do not change the Statistics or Current Usage footer assertions. + +- [ ] **Step 3: Update the constant expectation** -In `tests/constants.test.ts`, expect: +In `tests/constants.test.ts`, replace the Insights expected string with: ```ts expect(UI_STRINGS.dashboardFooters.insights).toBe( @@ -198,24 +288,27 @@ expect(UI_STRINGS.dashboardFooters.insights).toBe( ); ``` -- [ ] **Step 4: Run focused tests and confirm failure** +- [ ] **Step 4: Run the focused tests and confirm the expected failure** + +Run: ```sh pnpm test -- tests/dashboard.test.ts tests/constants.test.ts ``` -Expected: FAIL because the current Insights view exceeds the line budget and still advertises Period navigation. +Expected: FAIL because the implementation still advertises Period navigation and renders the old multi-category layout. --- -### Task 3: Implement available category state and rendering +### Task 3: Implement populated category state and rendering **Files:** + - Modify: `src/tui/dashboard.ts` -- [ ] **Step 1: Define category order and types** +- [ ] **Step 1: Add the fixed category descriptors** -Add below `DASHBOARD_TABS`: +Immediately below `DASHBOARD_TABS`, add: ```ts const INSIGHT_CATEGORIES = [ @@ -234,7 +327,7 @@ type AvailableInsightCategory = { }; ``` -- [ ] **Step 2: Replace the unused period state** +- [ ] **Step 2: Replace the unused Insights period state** Replace: @@ -248,11 +341,11 @@ with: private insightsCategory: InsightCategoryId = "project"; ``` -Do not change `periodIndex`; Statistics still uses it. +Leave `periodIndex` and `DEFAULT_PERIOD_INDEX` unchanged because Statistics still uses them. -- [ ] **Step 3: Derive populated categories and durable selection** +- [ ] **Step 3: Add populated-category derivation and durable selection** -Add: +Add these methods to `UsageDashboardComponent` before the Insights renderer: ```ts private availableInsightCategories(): AvailableInsightCategory[] { @@ -277,9 +370,11 @@ private activeInsightCategory( } ``` -- [ ] **Step 4: Replace all-category rendering with one complete renderer** +This derives from current state on every call and updates the stored selection when the previous category disappears. + +- [ ] **Step 4: Replace the all-category renderer** -Replace `renderInsightsByCategory` with: +Delete `renderInsightsByCategory` and add: ```ts private renderInsightCategory(category: AvailableInsightCategory): string[] { @@ -315,17 +410,14 @@ private renderInsightCategory(category: AvailableInsightCategory): string[] { } ``` -This preserves the current formatting and item caps; it only removes the outer category loop and leading blank lines. +This keeps the existing project, skill, MCP, and cost formatting while removing the outer category loop and its extra blank row. -- [ ] **Step 5: Replace `renderInsightsTab`** +- [ ] **Step 5: Replace the Insights tab renderer** + +Replace `renderInsightsTab` with: ```ts private renderInsightsTab(w: number, lines: string[]): void { - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - return; - } - const categories = this.availableInsightCategories(); const active = this.activeInsightCategory(categories); if (!active) { @@ -345,9 +437,11 @@ private renderInsightsTab(w: number, lines: string[]): void { } ``` -Delete the stale period-filter comment. +The `active` check covers both an empty insights array and an array containing only unrecognized category strings. Do not retain the old period-filter comment. -- [ ] **Step 6: Replace period input with category input** +- [ ] **Step 6: Replace Insights period input with category input** + +Replace `handleInsightsInput` with: ```ts private handleInsightsInput(data: string): void { @@ -369,17 +463,22 @@ private handleInsightsInput(data: string): void { } ``` +If no recognized categories exist, `active` is undefined and arrow input does nothing. + --- -### Task 4: Update footer and verify the compact component +### Task 4: Update the Insights footer and verify the implementation **Files:** + - Modify: `src/shared/constants.ts` - Test: `tests/dashboard.test.ts` - Test: `tests/constants.test.ts` - [ ] **Step 1: Change the Insights footer constant** +Replace the `insights` entry in `UI_STRINGS.dashboardFooters` with: + ```ts insights: [ "[Tab/Shift-Tab] Switch tab", @@ -390,47 +489,92 @@ insights: [ - [ ] **Step 2: Run focused tests and typecheck** +Run: + ```sh pnpm test -- tests/dashboard.test.ts tests/constants.test.ts pnpm typecheck ``` -Expected: PASS. Width 36 renders at most 20 lines; widths 73 and 100 render at most 17 lines. +Expected: both commands pass. The line-budget test must report no failure at widths 36, 73, or 100. -- [ ] **Step 3: Commit the atomic UI change** +- [ ] **Step 3: Run the full project check** + +Run: + +```sh +pnpm check +``` + +Expected: Biome lint, TypeScript typecheck, and all Vitest tests pass. + +- [ ] **Step 4: Inspect the implementation diff** + +Run: + +```sh +git diff --check +git diff -- src/tui/dashboard.ts src/shared/constants.ts tests/dashboard.test.ts tests/constants.test.ts +``` + +Expected: only the four planned files are changed, with no whitespace errors, no dependency changes, no provider changes, and no stale `insightsPeriodIndex`, `renderInsightsByCategory`, or Insights `Period` footer references. + +- [ ] **Step 5: Commit the atomic UI change** + +Run: ```sh git add src/tui/dashboard.ts src/shared/constants.ts tests/dashboard.test.ts tests/constants.test.ts git commit -m "fix: keep Insights within the dashboard height" ``` +Expected: one commit contains the compact Insights behavior and its tests. + --- -### Phase verification +## Phase verification + +- [ ] **Step 1: Verify the 40×24 overlay in tmux** -- [ ] Run: +Run: ```sh -pnpm check +session="pi-usage-40" +tmux new-session -d -s "$session" -x 40 -y 24 +tmux send-keys -t "$session" "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t "$session" "/usage" Enter +sleep 2 +tmux send-keys -t "$session" Tab Tab +sleep 1 +tmux capture-pane -t "$session" -p +tmux kill-session -t "$session" ``` -Expected: PASS. +Expected: the capture shows Insights selected, populated category tabs, one selected category's rows, the Category footer, and the bottom frame. The footer and bottom border must not be sliced off. -- [ ] Verify at the minimum supported terminal size: +- [ ] **Step 2: Verify the normal 80×24 overlay** + +Run: ```sh -tmux new-session -d -s pi-usage-40 -x 40 -y 24 -tmux send-keys -t pi-usage-40 "cd $(pwd) && pi -e ." Enter +session="pi-usage-80" +tmux new-session -d -s "$session" -x 80 -y 24 +tmux send-keys -t "$session" "cd $(pwd) && pi -e ." Enter sleep 3 -tmux send-keys -t pi-usage-40 "/usage" Enter +tmux send-keys -t "$session" "/usage" Enter sleep 2 -tmux capture-pane -t pi-usage-40 -p -tmux kill-session -t pi-usage-40 +tmux send-keys -t "$session" Tab Tab +sleep 1 +tmux capture-pane -t "$session" -p +tmux kill-session -t "$session" ``` -Expected: category tabs, selected rows, contextual footer, and bottom frame are visible; Pi has not sliced off the end of the component. +Expected: the available category tabs fit, one category renders, and the footer and bottom frame remain visible. + +- [ ] **Step 3: Verify final repository state** -- [ ] Verify the phase diff: +Run: ```sh git diff --check @@ -440,4 +584,4 @@ git log -1 --oneline Expected: no whitespace errors, no uncommitted Phase 4 files, and the latest commit is `fix: keep Insights within the dashboard height`. -**Stop here.** The compact all-time Insights UI is usable independently. Phase 5 documents both completed features and performs release-level verification. \ No newline at end of file +Stop here. Documentation and screenshot updates belong to Phase 5. diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md index 886b13e..2bcdace 100644 --- a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md @@ -8,7 +8,7 @@ **Architecture:** This phase changes no runtime behavior. It updates public setup and keyboard guidance, adds an Unreleased changelog entry, captures the compact UI, then runs focused, full, package, and live verification. -**Tech Stack:** Markdown, terminal screenshot tooling, tmux, pnpm, Vitest, Pi 0.82.0. +**Tech Stack:** Markdown, Herdr, pnpm, Vitest, Pi 0.82.1. **Phase dependency:** Phases 1–4 are committed and `pnpm check` passes. @@ -112,7 +112,7 @@ Insert: ### Changed -- Updated `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` to 0.82.0. +- Updated `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` to 0.82.1. - Migrated StepFun Step Plan tracking to `platform.stepfun.ai` browser-session credentials and monthly Credit usage. - Replaced the unsupported Insights period selector with compact all-time category navigation. @@ -138,35 +138,40 @@ Expected: exactly one matching line. **Files:** - Modify: `docs/assets/insights.png` -- [ ] **Step 1: Verify Insights at 40×24** +- [ ] **Step 1: Verify Insights at 40×24 and 80×24 with Herdr** ```sh -tmux new-session -d -s pi-usage-40 -x 40 -y 24 -tmux send-keys -t pi-usage-40 "cd $(pwd) && pi -e ." Enter -sleep 3 -tmux send-keys -t pi-usage-40 "/usage" Enter -sleep 2 -tmux capture-pane -t pi-usage-40 -p -tmux kill-session -t pi-usage-40 +verify_insights() { + width="$1" + workspace=$(herdr pane current | jq -r '.result.pane.workspace_id') + created=$(herdr tab create --workspace "$workspace" --cwd "$(pwd)" --label "verify-$width" --no-focus) + pane=$(printf '%s' "$created" | jq -r '.result.root_pane.pane_id') + tab=$(printf '%s' "$created" | jq -r '.result.tab.tab_id') + + herdr pane send-text "$pane" "stty rows 24 cols $width; pi -e ." + herdr pane send-keys "$pane" enter + sleep 4 + herdr pane send-text "$pane" '/usage:refresh' + herdr pane send-keys "$pane" enter + sleep 10 + herdr pane send-keys "$pane" q + sleep 1 + herdr pane send-text "$pane" '/usage' + herdr pane send-keys "$pane" enter + sleep 2 + herdr pane send-keys "$pane" tab tab + sleep 1 + herdr pane read "$pane" --source visible --lines 24 + herdr tab close "$tab" >/dev/null +} + +verify_insights 40 +verify_insights 80 ``` -Expected: category tabs, selected rows, contextual footer, and bottom frame are visible. +The refresh runs before capture so local Insight data is populated; reopening `/usage` reads the completed scan. Expected: both captures show Insights selected, populated category tabs, one selected category, the contextual Category footer, and the bottom frame. At 80 columns, all available category tabs fit on one row. -- [ ] **Step 2: Verify Insights at 80×24** - -```sh -tmux new-session -d -s pi-usage-80 -x 80 -y 24 -tmux send-keys -t pi-usage-80 "cd $(pwd) && pi -e ." Enter -sleep 3 -tmux send-keys -t pi-usage-80 "/usage" Enter -sleep 2 -tmux capture-pane -t pi-usage-80 -p -tmux kill-session -t pi-usage-80 -``` - -Expected: all available category tabs fit, one category renders, and the footer and bottom frame remain visible. - -- [ ] **Step 3: Verify a real StepFun Credit response** +- [ ] **Step 2: Verify a real StepFun Credit response** Start Pi with local `STEPFUN_TOKEN` and `STEPFUN_WEB_ID` values, run `/usage:refresh`, and open Current Usage. @@ -178,9 +183,9 @@ Expected: - a subscription reset only when `subscription_credit_reset_time` is present, - no token or Web ID in diagnostics, terminal capture, or logs. -- [ ] **Step 4: Refresh the Insights screenshot** +- [ ] **Step 3: Refresh the Insights screenshot** -At a normal terminal size, open `/usage`, switch to Insights, and select a representative populated category. Replace `docs/assets/insights.png` with a screenshot that shows: +At a normal terminal size, run `/usage:refresh`, wait for the scan to finish, close the overlay, reopen `/usage`, switch to Insights, and select a representative populated category. Replace `docs/assets/insights.png` with a screenshot that shows: - the main Insights tab selected, - populated category tabs, @@ -258,7 +263,7 @@ Expected: - no whitespace errors, - no uncommitted implementation or documentation files, -- separate commits for Pi 0.82.0, StepFun browser sessions, StepFun Credits, compact Insights, and documentation. +- separate commits for the Pi dependency update, StepFun browser sessions, StepFun Credits, compact Insights, and documentation. - [ ] **Step 5: Record any unavailable manual evidence** diff --git a/docs/superpowers/specs/2026-07-27-compact-insights-design.md b/docs/superpowers/specs/2026-07-27-compact-insights-design.md new file mode 100644 index 0000000..e59a017 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-compact-insights-design.md @@ -0,0 +1,87 @@ +# Compact Insights design + +## Purpose + +The Insights tab shows all-time data, but its period selector suggests unsupported filtering and the current all-category layout exceeds Pi's overlay height at small terminal sizes. The tab will instead show one populated category at a time. + +## Current state + +Phase 3 is merged, the worktree is clean, and `pnpm check` passes 258 tests. The project resolves `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` to 0.82.1 through the existing `^0.82.0` ranges. + +Pi 0.82.0 in `/Users/lanh/Developer/pi-packages/pi/packages/tui/src/tui.ts` provides the relevant overlay contract: + +- percentage widths and heights use `Math.floor`; +- a 40 by 24 terminal gives the dashboard a width of 36 and a maximum height of 20; +- Pi slices rendered lines after the maximum height, so an oversized component loses its bottom rows. + +The installed 0.82.1 packages remain unchanged. This phase only changes the dashboard UI and its tests. + +## Behavior + +Insight categories have this fixed order: + +1. Projects +2. Skills +3. MCP servers +4. Cost patterns + +Only populated categories appear. An item without a `category` remains a Cost patterns item. Unknown category strings remain excluded, matching the current renderer. + +The first populated category is the effective default. Left and Right cycle through populated categories with wraparound. This state is independent from the Statistics period. + +If an external state update removes the selected category, the dashboard stores the first populated category as the new selection. If the removed category later returns, the fallback remains selected. + +Insights remain all-time. The Insights tab does not render Today, This Week, Last Week, or All Time controls. + +## Architecture + +`UsageDashboardComponent` keeps the existing main tabs, frame, category row renderer, and input dispatch. `dashboard.ts` gains a fixed category descriptor list and one selected category ID in place of `insightsPeriodIndex`. + +A component method derives populated categories from the current `state.insights`. Both rendering and category input use that result. Rendering resolves the durable selected category, renders the available category labels with the existing `renderTabs` method, and renders only the active category's current rows. + +The existing category formatting stays intact: + +- Projects, Skills, and MCP servers use the label and percentage table. +- Cost patterns use the currency bullet list. +- Existing item caps and overflow rows remain the responsibility of `buildInsights`. + +No public types, dependencies, offline calculations, provider code, scrolling, or overlay options change. + +## Empty and changing state + +When `state.insights` is empty, or contains no recognized categories, the tab renders `No insights yet.` and category input does nothing. Category derivation reads current state on every render and input event, so asynchronous dashboard updates require no subscription changes. + +## Height contract + +The maximum populated category has six rows: five capped items plus one overflow item. Including the frame, main tab bar, category tabs, spacing, and footer: + +- width 36 must render at most 20 lines; +- widths 73 and 100 must render at most 17 lines. + +At width 36, the category tabs may occupy two rows. At widths 73 and 100, all four labels fit on one row. Staying within these budgets prevents Pi from slicing the footer or bottom frame. + +## Tests + +The dashboard tests will: + +- replace the first stale grouped-category test with populated-category and default-selection coverage; +- replace the obsolete independent-period test with category navigation and Statistics-period isolation coverage; +- rewrite the duplicate grouped-category test to inspect the active project format, navigate to Cost patterns, and inspect its bullet format; +- use one `switchToInsights` helper in every relevant test; +- exercise Left and Right navigation, wraparound, omitted categories, durable fallback, and fallback persistence when a category returns; +- verify missing categories still map to Cost patterns; +- enforce the line budgets at widths 36, 73, and 100; +- update dashboard and constant footer assertions from Period to Category. + +The focused tests must fail before implementation and pass afterward. Final automated verification runs `pnpm check`. + +## Live verification + +A 40 by 24 tmux session will start Pi and open `/usage`. The verification command must send Tab twice before capture because Usage Statistics is the default tab. The capture must show the Insights tab, available category tabs, selected rows, footer row, and bottom frame. + +The phase ends with `git diff --check`, a clean status, and one atomic UI commit touching only: + +- `src/tui/dashboard.ts` +- `src/shared/constants.ts` +- `tests/dashboard.test.ts` +- `tests/constants.test.ts` diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 65fd723..70fb18a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -60,7 +60,7 @@ export const UI_STRINGS = { ].join(" • "), insights: [ "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", + "[Left/Right] Category", "[q/Esc] Close", ].join(" • "), }, diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 5ce56ec..c9f86dd 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -73,6 +73,21 @@ const DASHBOARD_TABS: DashboardTab[] = [ { id: "insights", label: UI_STRINGS.dashboardBorderedSectionTitles.insights }, ]; +const INSIGHT_CATEGORIES = [ + { id: "project", label: "Projects" }, + { id: "skill", label: "Skills" }, + { id: "mcp", label: "MCP servers" }, + { id: "cost", label: "Cost patterns" }, +] as const; + +type InsightCategoryId = (typeof INSIGHT_CATEGORIES)[number]["id"]; + +type AvailableInsightCategory = { + id: InsightCategoryId; + label: string; + items: UsageCoreState["insights"]; +}; + function normalizePlan(provider: ProviderUsageSnapshot): string | undefined { const raw = provider.planName?.trim(); if (!raw) return undefined; @@ -143,7 +158,7 @@ export interface UsageDashboardOptions { export class UsageDashboardComponent implements Component { private activeTab: DashboardTabId = "statistics"; private periodIndex = DEFAULT_PERIOD_INDEX; - private insightsPeriodIndex = DEFAULT_PERIOD_INDEX; + private insightsCategory: InsightCategoryId = "project"; private rowIndex = 0; private expandedProvider: string | null = null; private currentUsageProviderIndex: number; @@ -387,76 +402,74 @@ export class UsageDashboardComponent implements Component { lines.push(...this.renderLegend(w)); } - private renderInsightsByCategory(_w: number): string[] { - const lines: string[] = []; - const categoryOrder = ["project", "skill", "mcp", "cost"]; - const categoryLabels: Record = { - project: "Projects", - skill: "Skills", - mcp: "MCP servers", - cost: "Cost patterns", - }; + private availableInsightCategories(): AvailableInsightCategory[] { + return INSIGHT_CATEGORIES.map((category) => ({ + ...category, + items: this.state.insights.filter( + (item) => (item.category ?? "cost") === category.id, + ), + })).filter((category) => category.items.length > 0); + } - const grouped = new Map(); - for (const item of this.state.insights) { - const cat = item.category ?? "cost"; - const list = grouped.get(cat) ?? []; - list.push(item); - grouped.set(cat, list); - } + private activeInsightCategory( + categories: AvailableInsightCategory[], + ): AvailableInsightCategory | undefined { + const active = + categories.find((category) => category.id === this.insightsCategory) ?? + categories[0]; + if (active) this.insightsCategory = active.id; + return active; + } - for (const cat of categoryOrder) { - const items = grouped.get(cat); - if (!items || items.length === 0) continue; - lines.push(""); - const header = categoryLabels[cat] ?? cat; - if (cat === "cost") { - lines.push(this.theme.dim(header)); - for (const item of items) { - lines.push( - this.theme.dim( - ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, - ), - ); - } - } else { - const pctHeader = "% of usage"; - const maxLabelLen = Math.max( - ...items.map((i) => i.label.length), - header.length, + private renderInsightCategory(category: AvailableInsightCategory): string[] { + const lines: string[] = []; + if (category.id === "cost") { + lines.push(this.theme.dim(category.label)); + for (const item of category.items) { + lines.push( + this.theme.dim( + ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, + ), ); - const headerLine = ` ${padVisible(this.theme.dim(header), maxLabelLen + 2, "left")} ${this.theme.dim(pctHeader)}`; - lines.push(headerLine); - for (const item of items) { - const label = padVisible( - this.theme.dim(item.label), - maxLabelLen + 2, - "left", - ); - lines.push(` ${label} ${this.theme.dim(item.detail)}`); - } } + return lines; } + const maxLabelLen = Math.max( + ...category.items.map((item) => item.label.length), + category.label.length, + ); + lines.push( + ` ${padVisible(this.theme.dim(category.label), maxLabelLen + 2, "left")} ${this.theme.dim("% of usage")}`, + ); + for (const item of category.items) { + const label = padVisible( + this.theme.dim(item.label), + maxLabelLen + 2, + "left", + ); + lines.push(` ${label} ${this.theme.dim(item.detail)}`); + } return lines; } private renderInsightsTab(w: number, lines: string[]): void { + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) { + lines.push(this.theme.dim("No insights yet.")); + return; + } + lines.push( ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.insightsPeriodIndex, + categories.map((category) => category.label), + categories.indexOf(active), w, ), ); lines.push(""); - - // TODO: filter insights by period when backend supports per-period data - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } + lines.push(...this.renderInsightCategory(active)); } private renderCurrentUsageTab(w: number, lines: string[]): void { @@ -633,10 +646,14 @@ export class UsageDashboardComponent implements Component { : matchesKey(data, Key.right) ? 1 : 0; - if (delta) { - this.insightsPeriodIndex = - (this.insightsPeriodIndex + delta + PERIODS.length) % PERIODS.length; - } + if (!delta) return; + + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) return; + const index = categories.indexOf(active); + this.insightsCategory = + categories[(index + delta + categories.length) % categories.length].id; } handleInput(data: string): void { diff --git a/tests/constants.test.ts b/tests/constants.test.ts index 2c57b9a..cd2b7c1 100644 --- a/tests/constants.test.ts +++ b/tests/constants.test.ts @@ -16,7 +16,7 @@ describe("UI_STRINGS.dashboardFooters", () => { it("insights footer matches expected format", () => { expect(UI_STRINGS.dashboardFooters.insights).toBe( - "[Tab/Shift-Tab] Switch tab \u2022 [Left/Right] Period \u2022 [q/Esc] Close", + "[Tab/Shift-Tab] Switch tab \u2022 [Left/Right] Category \u2022 [q/Esc] Close", ); }); }); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 1a6a5a2..b0a2a29 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -264,6 +264,11 @@ function mkState(): UsageCoreState { }; } +function switchToInsights(component: UsageDashboardComponent): void { + component.handleInput("\t"); + component.handleInput("\t"); +} + function setWindows(state: UsageCoreState, windows: LiveUsageWindow[]) { const cc = state.providers.find((p) => p.providerId === "command-code"); if (!cc) throw new Error("command-code provider not found in test state"); @@ -352,64 +357,150 @@ describe("dashboard render", () => { expect(out).not.toContain("[All Time]"); }); - it("renders Insights tab with insights grouped by category", () => { + it("shows only populated Insight categories and defaults to the first", () => { const state = mkState(); state.insights = [ - { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, - { category: "project", label: "dotfiles", cost: 1, detail: "10.0%" }, - { - category: "cost", - label: "Large context", - cost: 5, - detail: "50.0% over 150k context", - }, + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, ]; const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); - // Switch to Insights tab (Tab twice) - c.handleInput("\t"); - c.handleInput("\t"); - const out = c.render(100).join("\n"); + switchToInsights(c); - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); + const out = c.render(100).join("\n"); + expect(out).toContain("[Projects]"); expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); + expect(out).not.toContain("Skills"); + expect(out).not.toContain("MCP servers"); + expect(out).toContain("pi-usage"); + expect(out).not.toContain("Large context"); + expect(out).not.toContain("Today"); + expect(out).not.toContain("This Week"); + expect(out).not.toContain("Last Week"); + expect(out).not.toContain("All Time"); + }); - // Should have its own period selector - expect(out).toContain("[All Time]"); + it("keeps populated Insight categories in fixed order", () => { + const state = mkState(); + state.insights = [ + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + { category: "mcp", label: "playwright", cost: 1, detail: "10.0%" }, + { category: "skill", label: "/brainstorming", cost: 1, detail: "10.0%" }, + { category: "project", label: "pi-usage", cost: 7, detail: "70.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); - // Usage Statistics content should NOT be visible - expect(out).not.toContain("Provider / Model"); + const out = c.render(100).join("\n"); + const projects = out.indexOf("[Projects]"); + const skills = out.indexOf("Skills"); + const mcpServers = out.indexOf("MCP servers"); + const costPatterns = out.indexOf("Cost patterns"); + expect(projects).toBeGreaterThan(-1); + expect(skills).toBeGreaterThan(projects); + expect(mcpServers).toBeGreaterThan(skills); + expect(costPatterns).toBeGreaterThan(mcpServers); }); - it("has independent period selector for Insights tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { + it("excludes unknown Insight categories", () => { + const state = mkState(); + state.insights = [ + { category: "future", label: "Unknown category", cost: 1, detail: "100.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); + switchToInsights(c); - // Change Statistics tab period to Today - c.handleInput("\u001b[D"); // Left (All Time → Last Week) - c.handleInput("\u001b[D"); // Left (Last Week → This Week) - c.handleInput("\u001b[D"); // Left (This Week → Today) - expect(c.render(120).join("\n")).toContain("[Today]"); + const out = c.render(100).join("\n"); + expect(out).toContain("No insights yet."); + expect(out).not.toContain("Unknown category"); + }); - // Switch to Insights tab - c.handleInput("\t"); - c.handleInput("\t"); - const insightsOut = c.render(120).join("\n"); - // Insights should still be on All Time (independent period) - expect(insightsOut).toContain("[All Time]"); + it("cycles Insight categories without changing the Statistics period", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); - // Change Insights period - c.handleInput("\u001b[D"); // Left + c.handleInput("\u001b[D"); expect(c.render(120).join("\n")).toContain("[Last Week]"); - // Switch back to Statistics tab and verify its period is still Today - c.handleInput("\t"); // Insights → Statistics (wraps) - expect(c.render(120).join("\n")).toContain("[Today]"); + switchToInsights(c); + c.handleInput("\u001b[C"); + let out = c.render(100).join("\n"); + expect(out).toContain("[Cost patterns]"); + expect(out).toContain("Large context"); + expect(out).not.toContain("pi-usage"); + + c.handleInput("\u001b[D"); + out = c.render(100).join("\n"); + expect(out).toContain("[Projects]"); + + c.handleInput("\u001b[D"); + out = c.render(100).join("\n"); + expect(out).toContain("[Cost patterns]"); + + c.handleInput("\t"); + out = c.render(100).join("\n"); + expect(out).toContain("[Last Week]"); + }); + + it("falls back permanently when the selected Insight category disappears", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + c.handleInput("\u001b[C"); + expect(c.render(100).join("\n")).toContain("[Cost patterns]"); + + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "100.0%" }, + ]; + expect(c.render(100).join("\n")).toContain("[Projects]"); + + state.insights.push({ + category: "cost", + label: "Large context", + cost: 1, + detail: "10.0%", + }); + expect(c.render(100).join("\n")).toContain("[Projects]"); + }); + + it("keeps a maximum Insight category inside the supported overlay height", () => { + const state = mkState(); + state.insights = [ + ...Array.from({ length: 6 }, (_, index) => ({ + category: "project", + label: index === 5 ? "+20 more" : `project-${index + 1}`, + cost: 6 - index, + detail: `${30 - index * 4}.0%`, + })), + { category: "skill", label: "/brainstorming", cost: 1, detail: "5.0%" }, + { category: "mcp", label: "playwright", cost: 1, detail: "5.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "5.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + expect(c.render(36).length).toBeLessThanOrEqual(20); + expect(c.render(73).length).toBeLessThanOrEqual(17); + expect(c.render(100).length).toBeLessThanOrEqual(17); }); it("aligns quota bars by shared label width across available windows", () => { @@ -673,7 +764,7 @@ describe("dashboard render", () => { expect(c.render(120).join("\n")).toContain("openai-codex"); }); - it("renders insights grouped by category in Insights tab", () => { + it("renders the selected Insight category with its existing format", () => { const state = mkState(); state.insights = [ { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, @@ -688,23 +779,27 @@ describe("dashboard render", () => { const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); - // Switch to Insights tab (Tab twice) - c.handleInput("\t"); - c.handleInput("\t"); - const lines = c.render(100); - const out = lines.join("\n"); - expect(out).toContain("Projects"); + switchToInsights(c); + + let lines = c.render(100); + let out = lines.join("\n"); + expect(out).toContain("[Projects]"); + expect(out).toContain("Cost patterns"); expect(out).toContain("career-ops"); expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); - // Verify table structure for project category - const projectsIdx = lines.findIndex((l) => l.includes("Projects")); + expect(out).not.toContain("Large context"); + + const projectsIdx = lines.findIndex((line) => line.includes("% of usage")); expect(projectsIdx).toBeGreaterThan(-1); - expect(lines[projectsIdx]).toContain("% of usage"); expect(lines[projectsIdx + 1]).toContain("career-ops"); - // Verify bullet-list format for cost category + + c.handleInput("\u001b[C"); + lines = c.render(100); + out = lines.join("\n"); + expect(out).toContain("[Cost patterns]"); + expect(out).toContain("Large context"); expect(out).toContain(" - Large context:"); + expect(out).not.toContain("career-ops"); }); it("defaults insights without category to cost patterns", () => { @@ -713,9 +808,7 @@ describe("dashboard render", () => { const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); - // Switch to Insights tab - c.handleInput("\t"); - c.handleInput("\t"); + switchToInsights(c); const out = c.render(100).join("\n"); expect(out).toContain("Cost patterns"); expect(out).toContain(" - No category:"); @@ -972,7 +1065,8 @@ describe("dashboard themed styling", () => { out = c.render(160).join("\n"); stripped = stripAnsi(out); expect(stripped).toContain("[Tab/Shift-Tab] Switch tab"); - expect(stripped).toContain("[Left/Right] Period"); + expect(stripped).toContain("[Left/Right] Category"); + expect(stripped).not.toContain("[Left/Right] Period"); expect(stripped).not.toContain("[Up/Down] Row"); });