diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdc9e3..a16ebd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,51 +2,117 @@ All notable changes to `@pi-vault/pi-usage` are documented here. -## v0.5.1 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- updated `@earendil-works/pi-coding-agent` to `^0.80.2`, `@earendil-works/pi-tui` to `^0.80.2`, `@types/node` to `^26.0.0`, and `vitest` to `^4.1.9` +## [0.6.0] - 2026-07-05 -## v0.5.0 +### Added -- added `usage.json` provider toggles so you can disable live providers you do not want queried -- added dashboard insights for project, active skill, and MCP server breakdowns -- grouped insight rows by category and cap each insight section with an overflow summary to keep the dashboard readable -- improved offline session parsing to extract project names, active skills, MCP server names, and builtin tool usage more accurately -- fixed dashboard readability around usage statistics spacing and grouped insight rendering -- updated runtime requirements and tooling to Node `>=24.15.0`, refreshed dependencies, and aligned CI with Node 24 and pnpm 11.8.0 +- Tabbed overlay layout for the dashboard with three tabs: Usage Statistics, Current Usage, and Insights. The tab bar follows the same pattern as `@earendil-works/pi-extension-manager` for visual consistency. +- Independent period selector for the Insights tab so the Insights period no longer follows the Usage Statistics period. +- Per-tab contextual footer strings in `src/shared/constants.ts` so each tab shows only the keys that are relevant in that tab. +- `DashboardTheme.inverse()` and `DashboardTheme.bg()` methods to support the new overlay rendering primitives. +- New `src/tui/overlay-render.ts` module that owns `frame()`, `renderTabBar()`, and `pad()` utilities for drawing the overlay chrome. -## v0.4.0 +### Changed -- reorganized the extension around a dedicated `UsageCore`, with the package entrypoint reduced to a thin Pi adapter -- split shared provider runtime behavior into reusable utilities for timeouts, JSON parsing, and percentage handling -- broke the OpenCode Go provider into focused modules for dashboard scraping, SQLite reads, and quota window calculation -- extracted dashboard formatting and table layout logic into smaller TUI modules -- expanded automated coverage for orchestration, state projection, runtime utilities, dashboard formatting, and provider integrations -- kept the public usage flow stable: `/usage`, `/usage:refresh`, exported events, and exported types remain intact +- Refactored `src/tui/dashboard.ts` to render the new tabbed overlay layout. The `render()`, `handleInput()`, and `activeTab` state replace the previous stacked-section renderer. +- Dashboard description in `package.json` updated to "Pi extension that tracks Pi usage across your sessions in one dashboard". +- Updated `@biomejs/biome` to `^2.5.2`, `@earendil-works/pi-coding-agent` to `^0.80.3`, `@earendil-works/pi-tui` to `^0.80.3`, `@types/node` to `^26.1.0`, and `typebox` to `^1.3.3`. +- Moved `typebox` from `peerDependencies` to `devDependencies` since it is only used for compile-time schema construction in this package. -## v0.3.0 +### Removed -- added StepFun live usage support -- refreshed the README and provider setup guidance -- refactored the source tree into the current `core`, `providers`, `shared`, and `tui` layout -- updated dependencies, toolchain config, and Node requirements for the current runtime model +- `v` keyboard shortcut for toggling the Insights panel — Insights is now its own tab, reached via `Tab` / `Shift-Tab`. +- Legacy dashboard footer and border character constants from `src/shared/constants.ts` (replaced by per-tab footer strings and `overlay-render`). +- Unused helper code that was only consumed by the legacy single-scroll renderer. -## v0.2.0 +### Compatibility -- added the `/usage:refresh` command for explicit live refreshes -- added OpenRouter live usage and balance support -- reworked the dashboard UI with Pi-themed styling, improved quota formatting, and current-provider tabs -- improved MiniMax usage parsing and compatibility window handling +- Node.js requirement unchanged: `>=24.15.0`. +- Peer dependencies unchanged: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`. +- Public API unchanged: `/usage`, `/usage:refresh`, exported events, exported types all retain their previous surface. +- `biome.json` schema URL updated to `https://biomejs.dev/schemas/2.5.2/schema.json` to match the new biome version. -## v0.1.1 +## [0.5.1] - 2026-06-25 -- added the request/reply event API for consumers that need the latest usage snapshot -- exported the event and type modules through `package.json` -- added regression coverage for the event API surface +### Changed -## v0.1.0 +- Updated `@earendil-works/pi-coding-agent` to `^0.80.2`, `@earendil-works/pi-tui` to `^0.80.2`, `@types/node` to `^26.0.0`, and `vitest` to `^4.1.9`. -- introduced the initial Pi usage dashboard with offline aggregation across local sessions -- added live provider support for OpenAI/Codex, MiniMax, OpenCode Go, and Command Code -- added the interactive TUI dashboard, provider registry, packaging metadata, release workflow, and test/lint/typecheck automation -- published the first public package documentation and project license +## [0.5.0] - 2026-06-22 + +### Added + +- `usage.json` provider toggles so you can disable live providers you do not want queried. +- Dashboard insights for project, active skill, and MCP server breakdowns. +- Grouped insight rows by category, with an overflow summary per section to keep the dashboard readable. +- Improved offline session parsing to extract project names, active skills, MCP server names, and builtin tool usage more accurately. + +### Fixed + +- Dashboard readability around usage statistics spacing and grouped insight rendering. + +### Compatibility + +- Runtime requirement updated to Node `>=24.15.0`. +- CI aligned with Node 24 and pnpm 11.8.0. + +## [0.4.0] - 2026-06-14 + +### Added + +- Expanded automated coverage for orchestration, state projection, runtime utilities, dashboard formatting, and provider integrations. + +### Changed + +- Reorganized the extension around a dedicated `UsageCore`, with the package entrypoint reduced to a thin Pi adapter. +- Split shared provider runtime behavior into reusable utilities for timeouts, JSON parsing, and percentage handling. +- Broke the OpenCode Go provider into focused modules for dashboard scraping, SQLite reads, and quota window calculation. +- Extracted dashboard formatting and table layout logic into smaller TUI modules. + +### Compatibility + +- Public usage flow kept stable: `/usage`, `/usage:refresh`, exported events, and exported types remain intact. + +## [0.3.0] - 2026-06-08 + +### Added + +- StepFun live usage support. + +### Changed + +- Refreshed the README and provider setup guidance. +- Refactored the source tree into the current `core`, `providers`, `shared`, and `tui` layout. +- Updated dependencies, toolchain config, and Node requirements for the current runtime model. + +## [0.2.0] - 2026-06-01 + +### Added + +- The `/usage:refresh` command for explicit live refreshes. +- OpenRouter live usage and balance support. + +### Changed + +- Reworked the dashboard UI with Pi-themed styling, improved quota formatting, and current-provider tabs. +- Improved MiniMax usage parsing and compatibility window handling. + +## [0.1.1] - 2026-05-31 + +### Added + +- The request/reply event API for consumers that need the latest usage snapshot. +- Exported the event and type modules through `package.json`. +- Regression coverage for the event API surface. + +## [0.1.0] - 2026-05-31 + +### Added + +- Initial Pi usage dashboard with offline aggregation across local sessions. +- Live provider support for OpenAI/Codex, MiniMax, OpenCode Go, and Command Code. +- Interactive TUI dashboard, provider registry, packaging metadata, release workflow, and test/lint/typecheck automation. +- First public package documentation and project license. diff --git a/README.md b/README.md index 22ab9a1..2a67fed 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ Track Pi usage across your sessions in one dashboard. `@pi-vault/pi-usage` combines offline history with live provider snapshots so you can review costs, tokens, session activity, current quotas, and usage insights without leaving Pi. -![Pi usage dashboard showing the aggregated usage table for the "All Time" period, current usage quota bars for OpenAI/Codex, and the keyboard navigation hints at the bottom](docs/assets/dashboard-ui.png) - ## Install ```bash @@ -21,49 +19,89 @@ Then reload Pi: /reload ``` +## Quick Start + +Open the dashboard with cached data: + +```text +/usage +``` + +Force a live refresh, rescan local history, and reopen: + +```text +/usage:refresh +``` + ## Commands -- `/usage` opens the dashboard using cached live data when available. Use it for quick inspection. -- `/usage:refresh` forces a live refresh, rescans local history, and then opens the dashboard. +| Command | Purpose | +| ----------------- | ---------------------------------------------------------------------------------------- | +| `/usage` | Open the dashboard. Uses cached live data when available. Quick inspection. | +| `/usage:refresh` | Force a live refresh, rescan local session history, then open the dashboard. | + +## Dashboard Tabs + +The dashboard renders as a tabbed overlay. Switch tabs with `Tab` / `Shift-Tab`. + +### Usage Statistics (default) -## What the dashboard shows +![Usage Statistics tab](docs/assets/usage-statistics.png) -### Usage statistics +Aggregates local Pi session history for the selected period. -The top section aggregates local Pi session history for the selected period. +- Period selector: `Today`, `This Week`, `Last Week`, `All Time`. +- Aggregated provider/model table with expandable rows. +- Total row for everything currently shown. +- Per-row counts for sessions, messages, cost, total tokens, input, output, cache reads, and cache writes. -- switch between `Today`, `This Week`, `Last Week`, and `All Time` -- expand provider rows to inspect model-level usage -- compare sessions, messages, cost, total tokens, input, output, cache reads, and cache writes -- keep a running total row for everything currently shown +### Current Usage -### Current usage +![Current Usage tab](docs/assets/current-usage.png) -The lower section shows the supported providers. Configured providers can return live quota and balance data, while unconfigured ones may show `unavailable` or local fallback states. +Shows supported live providers. Configured providers return live quota and balance data; unconfigured ones may show `unavailable` or a local fallback. -- switch between `OpenAI/Codex`, `MiniMax`, `StepFun`, `OpenCode Go`, `Command Code`, and `OpenRouter` -- view rolling-window quota bars like `5h` and weekly usage -- see balance-style fields where the provider exposes them -- get inline status for live, cached, stale, local, or unavailable data +- Provider selector: `OpenAI/Codex`, `MiniMax`, `StepFun`, `OpenCode Go`, `Command Code`, `OpenRouter`. +- Rolling-window quota bars (e.g. `5h`, weekly). +- Balance-style fields where the provider exposes them. +- Inline status: `live`, `cached`, `stale`, `local`, `unavailable`. ### Insights -Press `v` to toggle insights for the selected period. +![Insights tab](docs/assets/insights.png) -- review the most expensive projects in your local session history -- see active skill and MCP server breakdowns when that data is present -- keep long sections readable through grouped insight categories and capped lists with overflow summaries +Breakdowns for the selected period. Toggle visibility by switching to this tab. -## How to use it +- Most expensive projects in your local session history. +- Active skill breakdown when that data is present. +- MCP server breakdown when that data is present. +- Grouped insight categories with capped lists and overflow summaries so long sections stay readable. -### Keyboard shortcuts +The Insights period selector is independent of the Usage Statistics period. -- `[Tab/Shift-Tab]` switch provider tabs -- `[Left/Right]` switch time period -- `[Up/Down]` move through rows -- `[Enter/Space]` expand or collapse provider rows -- `[v]` toggle insights -- `[q/Esc]` close the dashboard +## Keyboard Shortcuts + +Global: + +- `[Tab]` next tab. +- `[Shift-Tab]` previous tab. +- `[q]` / `[Esc]` close the dashboard. + +Usage Statistics tab: + +- `[Left/Right]` switch period. +- `[Up/Down]` move through rows. +- `[Enter]` / `[Space]` expand or collapse the selected provider row. + +Current Usage tab: + +- `[Left/Right]` switch provider. + +Insights tab: + +- `[Left/Right]` switch period. + +The footer at the bottom of the dashboard shows the contextual shortcuts for the active tab. ## Configuration @@ -72,10 +110,11 @@ Press `v` to toggle insights for the selected period. Create `$PI_CODING_AGENT_DIR/extensions/usage.json` to disable specific live providers. Default behavior: -- if the file is missing, all providers stay enabled -- if the file is `{}`, all providers stay enabled -- if a provider is omitted, that provider stays enabled -- if the JSON is malformed, `@pi-vault/pi-usage` ignores it and falls back to the default behavior + +- File missing: all providers stay enabled. +- File is `{}`: all providers stay enabled. +- Provider omitted: that provider stays enabled. +- JSON malformed: `@pi-vault/pi-usage` ignores it and falls back to the default behavior. Default example: @@ -110,7 +149,7 @@ Disable MiniMax only: ### Provider setup -Offline history works without extra setup. Provider cards appear for every supported live provider unless you disable them in `usage.json`. Providers you configure can return live data; others may show `unavailable` or local fallback states. +Offline history works without extra setup. Provider cards appear for every supported live provider unless you disable them in `usage.json`. Providers you configure can return live data; others may show `unavailable` or a local fallback state. #### OpenAI/Codex diff --git a/biome.json b/biome.json index a81c026..2b9db4d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/assets/current-usage.png b/docs/assets/current-usage.png new file mode 100644 index 0000000..4f0fef9 Binary files /dev/null and b/docs/assets/current-usage.png differ diff --git a/docs/assets/dashboard-ui.png b/docs/assets/dashboard-ui.png deleted file mode 100644 index 67acca3..0000000 Binary files a/docs/assets/dashboard-ui.png and /dev/null differ diff --git a/docs/assets/insights.png b/docs/assets/insights.png new file mode 100644 index 0000000..ea17042 Binary files /dev/null and b/docs/assets/insights.png differ diff --git a/docs/assets/usage-statistics.png b/docs/assets/usage-statistics.png new file mode 100644 index 0000000..b0b5aea Binary files /dev/null and b/docs/assets/usage-statistics.png differ diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-1.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-1.md deleted file mode 100644 index bbceac3..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-1.md +++ /dev/null @@ -1,205 +0,0 @@ -# Phase 1: Theme Foundation — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add `inverse` and `bg` methods to the `DashboardTheme` interface and its implementations, and add `"borderAccent"` and `"selectedBg"` to `DashboardColor`, so the theme API is ready for tab pill styling. - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** None -**Postconditions:** All tests pass. `DashboardTheme` exposes `inverse` and `bg`. `DashboardColor` includes `"borderAccent"` and `"selectedBg"`. `noTheme`, `fromPiTheme`, and the `makeAnsiTheme` test helper all implement the new methods. No behavior change to the dashboard. - ---- - -## Steps - -- [ ] **Step 1: Write failing tests for new theme methods** - -Create `tests/dashboard-theme.test.ts`: - -```typescript -import { describe, expect, it } from "vitest"; -import { - noTheme, - fromPiTheme, - type DashboardTheme, -} from "../src/tui/dashboard-theme.ts"; - -describe("DashboardTheme", () => { - describe("noTheme", () => { - it("inverse returns text unchanged", () => { - expect(noTheme.inverse("hello")).toBe("hello"); - }); - - it("bg returns text unchanged", () => { - expect(noTheme.bg("selectedBg", "hello")).toBe("hello"); - }); - }); - - describe("fromPiTheme", () => { - it("delegates inverse to theme.inverse", () => { - const piTheme = { - fg: (_color: string, text: string) => text, - bold: (text: string) => `${text}`, - inverse: (text: string) => `${text}`, - bg: (color: string, text: string) => `${text}`, - }; - const theme = fromPiTheme(piTheme as never); - expect(theme.inverse("test")).toBe("test"); - }); - - it("delegates bg to theme.bg", () => { - const piTheme = { - fg: (_color: string, text: string) => text, - bold: (text: string) => `${text}`, - inverse: (text: string) => `${text}`, - bg: (color: string, text: string) => `${text}`, - }; - const theme = fromPiTheme(piTheme as never); - expect(theme.bg("selectedBg", "test")).toBe("test"); - }); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -npx vitest run tests/dashboard-theme.test.ts -``` - -Expected: FAIL — `noTheme.inverse` is not a function, `noTheme.bg` is not a function. - -- [ ] **Step 3: Add `inverse` and `bg` to DashboardTheme interface, all implementations, and the test helper** - -This step updates the interface, both production implementations, and the `makeAnsiTheme` test helper atomically so that TypeScript is never in a broken state. - -**3a. In `src/tui/dashboard-theme.ts`:** - -**Update the `DashboardTheme` interface** (replace the existing interface): - -```typescript -export interface DashboardTheme { - /** Foreground color for a themed string. */ - fg: (color: DashboardColor, text: string) => string; - /** Background color for a themed string. */ - bg: (color: DashboardColor, text: string) => string; - /** Bold modifier. */ - bold: (text: string) => string; - /** Dim modifier. */ - dim: (text: string) => string; - /** Inverse modifier (swap fg/bg). */ - inverse: (text: string) => string; -} -``` - -**Update the `DashboardColor` JSDoc and type** (replace the existing comment and type): - -```typescript -/** - * Color roles referenced by the dashboard. Foreground names match - * `ThemeColor` entries; `"selectedBg"` maps to the `ThemeBg` palette - * and is only valid with `bg()`. - */ -export type DashboardColor = - | "accent" - | "border" - | "borderAccent" - | "borderMuted" - | "selectedBg" - | "muted" - | "dim" - | "text"; -``` - -**Update `noTheme`** (replace the existing const): - -```typescript -export const noTheme: DashboardTheme = { - fg: (_color, text) => text, - bg: (_color, text) => text, - bold: (text) => text, - dim: (text) => text, - inverse: (text) => text, -}; -``` - -**Update `fromPiTheme`** (replace the existing function body): - -```typescript -export function fromPiTheme(theme: Theme): DashboardTheme { - return { - fg: (color, text) => theme.fg(color, text), - bg: (color, text) => theme.bg(color as never, text), - bold: (text) => theme.bold(text), - dim: (text) => theme.fg("dim", text), - inverse: (text) => theme.inverse(text), - }; -} -``` - -Note: `bg` casts `color` to `never` because `DashboardColor` is a superset that includes fg-only colors. Only `"selectedBg"` is a valid `ThemeBg` key, but the cast keeps the adapter simple. The caller is responsible for passing valid bg colors. - -**3b. In `tests/dashboard.test.ts`:** - -Replace the `makeAnsiTheme` function with: - -```typescript -function makeAnsiTheme(): DashboardTheme & { - calls: { method: string; color?: string; text: string }[]; -} { - const calls: { method: string; color?: string; text: string }[] = []; - const wrap = (open: string) => (text: string) => { - if (text.length === 0) return text; - return `${open}${text}${ANSI_ESCAPE}[0m`; - }; - return { - calls, - fg: (color, text) => { - calls.push({ method: "fg", color, text }); - return wrap(`${ANSI_ESCAPE}[38;5;75m`)(text); - }, - bg: (color, text) => { - calls.push({ method: "bg", color, text }); - return wrap(`${ANSI_ESCAPE}[48;5;236m`)(text); - }, - bold: (text) => { - calls.push({ method: "bold", text }); - return `${ANSI_ESCAPE}[1m${text}${ANSI_ESCAPE}[22m`; - }, - dim: (text) => { - calls.push({ method: "dim", text }); - return wrap(`${ANSI_ESCAPE}[38;5;243m`)(text); - }, - inverse: (text) => { - calls.push({ method: "inverse", text }); - return `${ANSI_ESCAPE}[7m${text}${ANSI_ESCAPE}[27m`; - }, - }; -} -``` - -- [ ] **Step 4: Run all tests** - -```bash -npx vitest run -``` - -Expected: All tests pass — new theme tests green, existing dashboard tests unaffected. - -- [ ] **Step 5: Commit** - -```bash -git add src/tui/dashboard-theme.ts tests/dashboard-theme.test.ts tests/dashboard.test.ts -git commit -m "feat(tui): add inverse and bg methods to DashboardTheme - -Extend the theme adapter with inverse() for active tab pill styling -and bg() for inactive tab pill background. Add selectedBg and -borderAccent to DashboardColor. Update noTheme, fromPiTheme, and -test helper. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md deleted file mode 100644 index c0fb02c..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md +++ /dev/null @@ -1,380 +0,0 @@ -# Phase 2: Render Utilities — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Create `src/tui/overlay-render.ts` with `pad()`, `frame()`, `renderTabBar()`, `frameContentWidth()`, and a `DashboardTab` interface, replicated from the pi-extension-manager pattern using `DashboardTheme`. - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** Phase 1 complete (`DashboardTheme` has `inverse`, `bg`; `DashboardColor` has `"borderAccent"`, `"selectedBg"`) -**Postconditions:** All tests pass. `overlay-render.ts` exports `pad`, `frame`, `renderTabBar`, `frameContentWidth`, and `DashboardTab`. Dashboard is NOT modified. - ---- - -## Steps - -- [ ] **Step 1: Write failing tests for `pad`** - -Create `tests/overlay-render.test.ts` with the initial `pad` tests: - -```typescript -import { describe, expect, it } from "vitest"; -import { - pad, - frame, - renderTabBar, - frameContentWidth, -} from "../src/tui/overlay-render.ts"; -import { noTheme } from "../src/tui/dashboard-theme.ts"; - -describe("pad", () => { - it("pads short text with trailing spaces", () => { - expect(pad("hi", 10)).toBe("hi "); - }); - - it("returns text unchanged when it fills the width", () => { - expect(pad("hello", 5)).toBe("hello"); - }); - - it("truncates text that exceeds the width", () => { - const result = pad("hello world", 5); - expect(result.length).toBeLessThanOrEqual(5); - }); - - it("handles zero width", () => { - expect(pad("hi", 0)).toBe(""); - }); -}); -``` - -- [ ] **Step 2: Write failing tests for `frame`** - -Append to `tests/overlay-render.test.ts`: - -```typescript -describe("frameContentWidth", () => { - it("subtracts borders and padding from total width", () => { - // width - 2 (borders) - PADDING_X * 2 (4) = width - 6 - expect(frameContentWidth(20)).toBe(14); - expect(frameContentWidth(10)).toBe(4); - }); - - it("returns 1 for very small widths", () => { - expect(frameContentWidth(1)).toBe(1); - expect(frameContentWidth(0)).toBe(1); - }); -}); - -describe("frame", () => { - it("wraps content lines in a bordered box", () => { - const lines = frame(["hello", "world"], 20, noTheme); - // Top border - expect(lines[0]).toContain("┏"); - expect(lines[0]).toContain("┓"); - // Bottom border - expect(lines[lines.length - 1]).toContain("┗"); - expect(lines[lines.length - 1]).toContain("┛"); - // Content lines have vertical borders - const contentLine = lines.find((l) => l.includes("hello")); - expect(contentLine).toBeDefined(); - expect(contentLine).toContain("┃"); - }); - - it("includes padding rows above and below content", () => { - const lines = frame(["test"], 20, noTheme); - // Structure: top border, 1 padding row, content, 1 padding row, bottom border - expect(lines.length).toBe(5); // 1 top + 1 pad + 1 content + 1 pad + 1 bottom - }); - - it("truncates overflow with indicator when fixedInnerRows is set", () => { - const content = ["line1", "line2", "line3", "line4", "line5"]; - const lines = frame(content, 30, noTheme, 3); - const joined = lines.join("\n"); - expect(joined).toContain("line1"); - expect(joined).toContain("line2"); - expect(joined).toContain("more line(s)"); - expect(joined).not.toContain("line5"); - }); - - it("renders a title in the top border when provided", () => { - const lines = frame(["content"], 30, noTheme, undefined, "My Title"); - expect(lines[0]).toContain("My Title"); - expect(lines[0]).toContain("┏"); - }); - - it("pads content to frameContentWidth", () => { - const lines = frame(["hi"], 20, noTheme); - // Content line: ┃ + 2 pad + content padded to contentWidth + 2 pad + ┃ - // All lines should be exactly 20 chars wide (in visible width) - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(20); - } - }); -}); -``` - -- [ ] **Step 3: Write failing tests for `renderTabBar`** - -Append to `tests/overlay-render.test.ts`: - -```typescript -describe("renderTabBar", () => { - const tabs = [ - { id: "stats", label: "Usage Statistics" }, - { id: "current", label: "Current Usage" }, - { id: "insights", label: "Insights" }, - ]; - - it("renders all tabs with active tab highlighted", () => { - const result = renderTabBar(tabs, "stats", 80, noTheme); - expect(result).toContain("Usage Statistics"); - expect(result).toContain("Current Usage"); - expect(result).toContain("Insights"); - }); - - it("returns empty padding for no tabs", () => { - const result = renderTabBar([], "stats", 20, noTheme); - expect(result.trim()).toBe(""); - }); - - it("shows overflow indicators when tabs exceed width", () => { - const result = renderTabBar(tabs, "insights", 20, noTheme); - // At 20 chars wide, not all tabs can fit - expect(result).toContain("Insights"); - }); - - it("pads result to the requested width", () => { - const result = renderTabBar(tabs, "stats", 80, noTheme); - // Result should be padded to exactly 80 visible chars - expect(result.length).toBe(80); - }); -}); -``` - -- [ ] **Step 4: Run tests to verify they fail** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/overlay-render.test.ts -``` - -Expected: FAIL -- module `../src/tui/overlay-render.ts` not found. - -- [ ] **Step 5: Implement `overlay-render.ts`** - -Create `src/tui/overlay-render.ts`: - -```typescript -/** - * Overlay rendering utilities for the usage dashboard. - * - * Replicates patterns from the pi-extension-manager's render helpers using - * the dashboard's own DashboardTheme adapter so colors flow through Pi's - * live theme. - */ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { DashboardTheme, DashboardColor } from "./dashboard-theme.ts"; - -// ── Layout constants ──────────────────────────────────────────────────── - -const PADDING_X = 2; -const PADDING_Y = 1; - -// ── Frame glyphs ──────────────────────────────────────────────────────── - -const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const; - -// ── Tab types ─────────────────────────────────────────────────────────── - -export interface DashboardTab { - id: string; - label: string; -} - -// ── Utilities ─────────────────────────────────────────────────────────── - -/** - * Pad `text` to exactly `width` visible columns. Truncates if wider. - */ -export function pad(text: string, width: number): string { - if (width <= 0) return ""; - const truncated = truncateToWidth(text, width, ""); - return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`; -} - -/** - * Calculate the content width inside a frame (excluding borders and padding). - */ -export function frameContentWidth(width: number): number { - return Math.max(1, width - 2 - PADDING_X * 2); -} - -// ── Frame ─────────────────────────────────────────────────────────────── - -/** - * Wrap content `lines` in a bordered frame box. - * - * Matches the pi-extension-manager's frame rendering: - * - Top/bottom borders with optional title - * - PADDING_Y blank rows above/below content - * - PADDING_X space columns on each side of content - * - Overflow truncation with "↓ N more line(s)" when fixedInnerRows is set - */ -export function frame( - lines: string[], - width: number, - theme: DashboardTheme, - fixedInnerRows?: number, - title = "", -): string[] { - const inner = Math.max(1, width - 2); - const contentWidth = frameContentWidth(width); - const border = (s: string) => theme.fg("borderAccent", s); - - let body = lines; - if (fixedInnerRows !== undefined && body.length > fixedInnerRows) { - const hidden = body.length - fixedInnerRows + 1; - body = [ - ...body.slice(0, Math.max(0, fixedInnerRows - 1)), - theme.fg("dim", `↓ ${hidden} more line(s)`), - ].slice(0, fixedInnerRows); - } - - const blank = `${border(FRAME.v)}${" ".repeat(inner)}${border(FRAME.v)}`; - - const top = (): string => { - if (!title) { - return `${border(FRAME.tl)}${border(FRAME.h.repeat(inner))}${border(FRAME.tr)}`; - } - const titleText = ` ${truncateToWidth(title, Math.max(1, inner - 2), "…")} `; - const fill = Math.max(1, inner - visibleWidth(titleText)); - return `${border(FRAME.tl)}${theme.fg("accent", titleText)}${border(FRAME.h.repeat(fill))}${border(FRAME.tr)}`; - }; - - const out = [top()]; - for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); - for (const line of body) { - out.push( - `${border(FRAME.v)}${" ".repeat(PADDING_X)}${pad(line, contentWidth)}${" ".repeat(PADDING_X)}${border(FRAME.v)}`, - ); - } - for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); - out.push( - `${border(FRAME.bl)}${border(FRAME.h.repeat(inner))}${border(FRAME.br)}`, - ); - return out.map((line) => truncateToWidth(line, width, "")); -} - -// ── Tab bar ───────────────────────────────────────────────────────────── - -function activePill(theme: DashboardTheme, label: string): string { - return theme.fg("accent", theme.inverse(theme.bold(label))); -} - -function inactivePill(theme: DashboardTheme, label: string): string { - return theme.bg("selectedBg", theme.fg("accent", label)); -} - -/** - * Render a tab bar with pill-styled active/inactive tabs. - * - * Dynamic visibility: expands tabs around the active tab to fit `width`, - * showing ‹/› indicators when tabs overflow. - */ -export function renderTabBar( - tabs: DashboardTab[], - activeId: string, - width: number, - theme: DashboardTheme, -): string { - const safeWidth = Math.max(1, width); - if (tabs.length === 0) return " ".repeat(safeWidth); - - const activeIndex = Math.max( - 0, - tabs.findIndex((tab) => tab.id === activeId), - ); - const widths = tabs.map((tab) => visibleWidth(tab.label) + 2); // " label " - - const sliceWidth = (s: number, e: number): number => { - let total = 0; - for (let i = s; i < e; i += 1) total += widths[i]!; - total += Math.max(0, e - s - 1); // single-space gaps - total += s > 0 ? 2 : 0; // "‹ " - total += e < tabs.length ? 2 : 0; // " ›" - return total; - }; - - let start = activeIndex; - let end = activeIndex + 1; - let preferRight = true; - while (start > 0 || end < tabs.length) { - let progressed = false; - const tryRight = (): boolean => { - if (end < tabs.length && sliceWidth(start, end + 1) <= safeWidth) { - end += 1; - return true; - } - return false; - }; - const tryLeft = (): boolean => { - if (start > 0 && sliceWidth(start - 1, end) <= safeWidth) { - start -= 1; - return true; - } - return false; - }; - if (preferRight) { - if (tryRight()) progressed = true; - if (tryLeft()) progressed = true; - } else { - if (tryLeft()) progressed = true; - if (tryRight()) progressed = true; - } - if (!progressed) break; - preferRight = !preferRight; - } - - const cells = tabs.slice(start, end).map((tab) => { - const label = ` ${tab.label} `; - return tab.id === activeId - ? activePill(theme, label) - : inactivePill(theme, label); - }); - if (start > 0) cells.unshift(theme.fg("dim", "‹")); - if (end < tabs.length) cells.push(theme.fg("dim", "›")); - return pad(cells.join(" "), safeWidth); -} -``` - -- [ ] **Step 6: Run overlay-render tests to verify they pass** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/overlay-render.test.ts -``` - -Expected: All PASS. - -- [ ] **Step 7: Run full test suite** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run -``` - -Expected: All tests pass. The new file is additive -- nothing imports it yet. - -- [ ] **Step 8: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/tui/overlay-render.ts tests/overlay-render.test.ts -git commit -m "feat(tui): add overlay rendering utilities - -Add frame(), renderTabBar(), and pad() in overlay-render.ts. -Replicates pi-extension-manager patterns using DashboardTheme -for Pi theme integration. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-3.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-3.md deleted file mode 100644 index 1a86bbf..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-3.md +++ /dev/null @@ -1,102 +0,0 @@ -# Phase 3: Constants Prep — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add per-tab `dashboardFooters` to `UI_STRINGS` in `src/shared/constants.ts` alongside the legacy `dashboardFooter` and `dashboardBorderChars` (which are removed in Phase 5 after the dashboard refactor). - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** None -**Postconditions:** All tests pass. `UI_STRINGS.dashboardFooters` exists with `statistics`, `current`, and `insights` keys. Legacy `dashboardFooter` and `dashboardBorderChars` remain untouched for backward compatibility (removed in Phase 5). - ---- - -## Steps - -- [ ] **Step 1: Add `dashboardFooters` object to `UI_STRINGS`** - -In `src/shared/constants.ts`, add the `dashboardFooters` property to `UI_STRINGS` immediately after `dashboardTitle`. The legacy `dashboardFooter` (singular) and `dashboardBorderChars` must remain — they are still referenced by the current dashboard code and will be removed in Phase 5. - -Replace the `UI_STRINGS` declaration with: - -```typescript -export const UI_STRINGS = { - dashboardTitle: "Pi Usage Dashboard", - // New per-tab footers (used by the tabbed overlay starting Phase 4) - dashboardFooters: { - statistics: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter] Expand", - "[q/Esc] Close", - ].join(" • "), - current: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Provider", - "[q/Esc] Close", - ].join(" • "), - insights: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[q/Esc] Close", - ].join(" • "), - }, - // Legacy -- removed in Phase 5 when dashboard.ts stops referencing them - dashboardFooter: [ - "[Tab/Shift-Tab] Provider", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter/Space] Expand/Collapse", - "[v] Insights", - "[q/Esc] Close", - ].join(" • "), - dashboardBorderedSectionTitles: { - usageStatistics: "Usage Statistics", - currentUsage: "Current Usage", - insights: "Insights", - notes: "Notes", - }, - dashboardBorderChars: { - topLeft: "╭", - topRight: "╮", - bottomLeft: "╰", - bottomRight: "╯", - horizontal: "─", - separatorLeft: "├", - separatorRight: "┤", - }, - dashboardDefaultPeriod: "allTime" as UsageWindow, -} as const; -``` - -The resulting footer strings are: - -- `statistics`: `[Tab/Shift-Tab] Switch tab • [Left/Right] Period • [Up/Down] Row • [Enter] Expand • [q/Esc] Close` -- `current`: `[Tab/Shift-Tab] Switch tab • [Left/Right] Provider • [q/Esc] Close` -- `insights`: `[Tab/Shift-Tab] Switch tab • [Left/Right] Period • [q/Esc] Close` - -- [ ] **Step 2: Run all tests to verify no regressions** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run -``` - -Expected: All tests pass. The new `dashboardFooters` property is additive — no existing code references it yet, and the legacy properties are unchanged. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/shared/constants.ts -git commit -m "feat(constants): add per-tab footer strings - -Add dashboardFooters with context-aware key hints for each tab. -Legacy dashboardFooter and dashboardBorderChars retained until -the dashboard render pipeline is refactored. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-4.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-4.md deleted file mode 100644 index ed9ec16..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-4.md +++ /dev/null @@ -1,1444 +0,0 @@ -# Phase 4: Core Refactor + Tests — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Refactor the dashboard component from stacked layout to tabbed overlay, and update all tests to match. - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** Phases 1-3 complete (DashboardTheme has `inverse`/`bg`, `overlay-render.ts` exists with `frame`/`renderTabBar`/`pad`/`frameContentWidth`, `UI_STRINGS.dashboardFooters` exists with per-tab strings, `makeAnsiTheme` test helper has `inverse`/`bg`) -**Postconditions:** All tests pass. Dashboard renders as tabbed overlay. `v` key removed. Tab/Shift-Tab switches tabs. Left/Right is contextual per tab. - -**Files modified:** - -| File | Action | -| ------------------------- | ------------------------------------ | -| `src/tui/dashboard.ts` | Refactor to tabbed overlay | -| `tests/dashboard.test.ts` | Update all assertions for new layout | - ---- - -## Source Changes — `src/tui/dashboard.ts` - -### Step 1: Add imports and tab type/constants - -- [ ] Add the overlay-render import alongside existing imports: - -```typescript -import { - type DashboardTab, - frame, - frameContentWidth, - renderTabBar, -} from "./overlay-render.ts"; -``` - -Then add the tab type and tab definition constants after the existing `SHIFT_TAB_KEY` / `DEFAULT_PERIOD_INDEX` block (before `normalizePlan`): - -```typescript -type DashboardTabId = "statistics" | "current" | "insights"; - -const DASHBOARD_TABS: DashboardTab[] = [ - { - id: "statistics", - label: UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - }, - { - id: "current", - label: UI_STRINGS.dashboardBorderedSectionTitles.currentUsage, - }, - { id: "insights", label: UI_STRINGS.dashboardBorderedSectionTitles.insights }, -]; -``` - ---- - -### Step 2: Replace class state fields - -- [ ] In the `UsageDashboardComponent` class, remove the `showInsights` field and add `activeTab` + `insightsPeriodIndex`. - -**Remove:** - -```typescript - private showInsights = false; -``` - -**Replace the field block with:** - -```typescript - private activeTab: DashboardTabId = "statistics"; - private periodIndex = DEFAULT_PERIOD_INDEX; - private insightsPeriodIndex = DEFAULT_PERIOD_INDEX; - private rowIndex = 0; - private expandedProvider: string | null = null; - private currentUsageProviderIndex: number; -``` - -(The `periodIndex`, `rowIndex`, `expandedProvider`, and `currentUsageProviderIndex` fields stay; `showInsights` is removed; `activeTab` and `insightsPeriodIndex` are added.) - ---- - -### Step 3: Add switchTab helper - -- [ ] Add a `switchTab` method to the component class, after `moveProvider`: - -```typescript - private switchTab(delta: number): void { - const ids = DASHBOARD_TABS.map((t) => t.id); - const currentIndex = ids.indexOf(this.activeTab); - const next = (currentIndex + delta + ids.length) % ids.length; - this.activeTab = ids[next] as DashboardTabId; - } -``` - ---- - -### Step 4: Add per-tab input handlers - -- [ ] Add three private methods for per-tab contextual key handling. Place them after `switchTab`: - -```typescript - private handleStatisticsInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.movePeriod(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.movePeriod(1); - return; - } - const period = this.currentPeriod(); - if (!period) return; - if (matchesKey(data, Key.down)) { - this.rowIndex = Math.min( - this.rowIndex + 1, - Math.max(0, period.providers.length - 1), - ); - } - if (matchesKey(data, Key.up)) { - this.rowIndex = Math.max(0, this.rowIndex - 1); - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - const provider = period.providers[this.rowIndex]?.key; - if (!provider) return; - this.expandedProvider = - this.expandedProvider === provider ? null : provider; - } - } - - private handleCurrentUsageInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.moveProvider(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.moveProvider(1); - return; - } - } - - private handleInsightsInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.moveInsightsPeriod(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.moveInsightsPeriod(1); - return; - } - } - - private moveInsightsPeriod(delta: number): void { - this.insightsPeriodIndex = - (this.insightsPeriodIndex + delta + PERIODS.length) % PERIODS.length; - } -``` - ---- - -### Step 5: Replace handleInput with tab-based routing - -- [ ] Replace the entire `handleInput` method body: - -```typescript - handleInput(data: string): void { - // Global keys - if (data === "q" || matchesKey(data, Key.escape)) { - this.invalidate(); - this.cancelScan?.(); - this.done(); - return; - } - if (matchesKey(data, Key.tab)) { - this.switchTab(1); - return; - } - if (matchesKey(data, SHIFT_TAB_KEY)) { - this.switchTab(-1); - return; - } - - // Per-tab contextual keys - switch (this.activeTab) { - case "statistics": - this.handleStatisticsInput(data); - break; - case "current": - this.handleCurrentUsageInput(data); - break; - case "insights": - this.handleInsightsInput(data); - break; - } - } -``` - -The old `handleInput` body is fully replaced. The `v` key handler is gone. `Tab`/`Shift-Tab` now call `switchTab` instead of `moveProvider`. `Left`/`Right`/`Up`/`Down`/`Enter`/`Space` are routed through the per-tab handlers. - ---- - -### Step 6: Add renderInsightsTab method - -- [ ] Add after `renderInsightsByCategory`: - -```typescript - private renderInsightsTab(w: number, lines: string[]): void { - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.insightsPeriodIndex, - w, - ), - ); - lines.push(""); - - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } - } -``` - ---- - -### Step 7: Rename renderUsageStatistics -> renderUsageStatisticsTab - -- [ ] Rename the method and remove the section title and the `showInsights` branch. The full replacement: - -```typescript - private renderUsageStatisticsTab(w: number, lines: string[]): void { - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); - if (this.state.loading) { - lines.push(this.theme.dim("Loading session history...")); - } - lines.push(""); - - const period = this.currentPeriod(); - if (!period || period.total.messageCount === 0) { - lines.push(this.theme.dim("No local session usage found.")); - return; - } - const columns = tableColumns(w); - const providerWidth = labelWidth(columns, w); - lines.push(tableLine("Provider / Model", columns, providerWidth)); - lines.push(this.theme.fg("borderMuted", separator(columns, providerWidth))); - period.providers.forEach((row, index) => { - const expanded = this.expandedProvider === row.key; - lines.push( - this.renderProviderRow( - row, - index === this.rowIndex, - expanded, - columns, - providerWidth, - ), - ); - if (expanded) { - for (const model of period.modelsByProvider[row.key] ?? []) { - lines.push(this.renderModelRow(model, columns, providerWidth)); - } - } - }); - lines.push(this.theme.fg("borderMuted", separator(columns, providerWidth))); - lines.push(tableLine("Total", columns, providerWidth, period.total)); - lines.push(""); - lines.push(...this.renderLegend(w)); - } -``` - -**Removed vs. old `renderUsageStatistics`:** - -- Removed `lines.push(this.sectionTitle(...))` and the blank line after it (title is now in the tab bar) -- Removed the entire `if (this.showInsights) { ... }` branch (Insights is a separate tab) - ---- - -### Step 8: Rename renderCurrentUsage -> renderCurrentUsageTab - -- [ ] Rename the method, remove the section title and header separator, and add `renderDiagnostics` call at the end. Full replacement: - -```typescript - private renderCurrentUsageTab(w: number, lines: string[]): void { - const providers = liveProviders(this.state); - if (providers.length === 0) { - lines.push(this.theme.dim("No live usage details.")); - return; - } - this.currentUsageProviderIndex = Math.min( - this.currentUsageProviderIndex, - Math.max(0, providers.length - 1), - ); - lines.push( - ...this.renderTabs( - providers.map((provider) => provider.providerLabel), - this.currentUsageProviderIndex, - w, - ), - ); - lines.push(""); - - const referenceTime = Math.max( - this.state.generatedAt, - ...providers.map((provider) => provider.fetchedAt), - 0, - ); - const selected = providers[this.currentUsageProviderIndex]; - lines.push( - this.theme.fg( - "accent", - this.theme.bold(providerHeading(selected, referenceTime)), - ), - ); - if (selected.windows.length === 0 && selected.balances.length === 0) { - lines.push(this.theme.dim("No live usage details.")); - } else { - lines.push(...this.renderQuotaWindows(selected.windows)); - for (const balance of selected.balances) { - const value = - balance.unit === "USD" - ? formatCurrency(balance.remaining ?? undefined) - : formatAbbrev(balance.remaining ?? undefined); - const unitSuffix = balance.unit === "USD" ? "" : ` ${balance.unit}`; - const labelStyled = this.theme.dim(`${balance.label}:`); - lines.push(`${labelStyled} ${value}${unitSuffix}`); - } - } - - // Diagnostics (previously a separate section, now housed in Current Usage) - this.renderDiagnostics(lines); - } -``` - -**Removed vs. old `renderCurrentUsage`:** - -- Removed `lines.push(this.sectionTitle(...))` (title is in the tab bar) -- Removed `lines.push(this.currentUsageHeaderSeparator(w))` (no separator in tab layout) -- Added `this.renderDiagnostics(lines)` at the end (was called separately in old `render()`) - ---- - -### Step 9: Replace render() to use frame + tab bar + per-tab content - -- [ ] Replace the entire `render` method: - -```typescript - render(width: number): string[] { - const w = Math.max(8, width); - const contentWidth = frameContentWidth(w); - const lines: string[] = []; - - // Tab bar - lines.push( - renderTabBar(DASHBOARD_TABS, this.activeTab, contentWidth, this.theme), - ); - lines.push(""); - - // Active tab content - switch (this.activeTab) { - case "statistics": - this.renderUsageStatisticsTab(contentWidth, lines); - break; - case "current": - this.renderCurrentUsageTab(contentWidth, lines); - break; - case "insights": - this.renderInsightsTab(contentWidth, lines); - break; - } - - // Footer - lines.push(""); - lines.push(this.renderFooter(contentWidth)); - - return frame(lines, w, this.theme); - } -``` - ---- - -### Step 10: Update renderFooter to be context-aware - -- [ ] Replace the `renderFooter` method: - -```typescript - private renderFooter(width: number): string { - const footerKey = - this.activeTab === "statistics" - ? "statistics" - : this.activeTab === "current" - ? "current" - : "insights"; - return this.theme.dim( - truncateVisible(UI_STRINGS.dashboardFooters[footerKey], width), - ); - } -``` - ---- - -### Step 11: Remove dead code - -- [ ] Remove the following methods and free functions that are no longer called: - -**Remove free functions** (before the `UsageDashboardOptions` interface): - -```typescript -function horizontalBorder(width: number, left: string, right: string): string { - if (width <= 2) return left + right; - return `${left}${"─".repeat(width - 2)}${right}`; -} - -function borderSeparator(width: number): string { - if (width <= 2) return "├┤"; - return `├${"─".repeat(width - 2)}┤`; -} -``` - -**Remove private methods** from `UsageDashboardComponent`: - -```typescript - private sectionTitle(text: string): string { - return this.theme.fg("accent", this.theme.bold(text)); - } - - private borderLine(width: number): string { - return this.theme.fg( - "border", - horizontalBorder( - width, - UI_STRINGS.dashboardBorderChars.topLeft, - UI_STRINGS.dashboardBorderChars.topRight, - ), - ); - } - - private currentUsageHeaderSeparator(width: number): string { - return this.theme.fg("borderMuted", borderSeparator(width)); - } - - private bottomBorder(width: number): string { - return this.theme.fg( - "border", - horizontalBorder( - width, - UI_STRINGS.dashboardBorderChars.bottomLeft, - UI_STRINGS.dashboardBorderChars.bottomRight, - ), - ); - } -``` - -**Update `renderDiagnostics`** to inline the old `sectionTitle` call (since `sectionTitle` is removed): - -```typescript - private renderDiagnostics(lines: string[]): void { - const providers = liveProviders(this.state); - const diagnosticNotes = providers.flatMap((provider) => - providerDiagnostics(provider).map( - (diagnostic) => `* ${provider.providerLabel}: ${diagnostic}`, - ), - ); - if (diagnosticNotes.length === 0) return; - lines.push(""); - lines.push( - this.theme.fg( - "accent", - this.theme.bold(UI_STRINGS.dashboardBorderedSectionTitles.notes), - ), - ); - for (const note of diagnosticNotes) { - lines.push(this.theme.dim(note)); - } - } -``` - -The only change is replacing `this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.notes)` with the inlined `this.theme.fg("accent", this.theme.bold(...))`. - ---- - -### Step 12: Update openDashboard with overlay options - -- [ ] Replace the `openDashboard` function to pass overlay options: - -```typescript -export async function openDashboard( - ctx: ExtensionCommandContext, - state: UsageCoreState, - cancelScan?: () => void, -): Promise { - await ctx.ui.custom( - (tui, theme, _keys, done) => { - const piTheme = theme as unknown as Parameters[0]; - const dashboardTheme: DashboardTheme = - piTheme && - typeof (piTheme as { fg?: unknown }).fg === "function" && - typeof (piTheme as { bold?: unknown }).bold === "function" - ? fromPiTheme(piTheme) - : noTheme; - return new UsageDashboardComponent(state, done, { - tui, - theme: dashboardTheme, - cancelScan, - }); - }, - { - overlay: true, - overlayOptions: { - anchor: "center", - maxHeight: "85%", - width: "92%", - }, - }, - ); -} -``` - -The callback body is unchanged. The second argument `{ overlay: true, overlayOptions: { ... } }` is new. - ---- - -### Step 13: Verify build compiles - -- [ ] Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit -``` - -Expected: No type errors. If references to removed methods or old constants remain, fix them before proceeding. - ---- - -## Test Changes — `tests/dashboard.test.ts` - -> **Convention:** Content is now inside a `frame()` box. Lines look like `┃ content ┃`. Tests that used `l.startsWith("text")` must change to `l.includes("text")`. The `indexOf("[")` alignment checks still work because frame padding is uniform across all content lines. - -### Step 14: Update test -- "renders Usage Statistics tab by default" - -- [ ] Replace the `"renders usage statistics + current usage with selected provider details"` test: - -```typescript -it("renders Usage Statistics tab by default with table and legend", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - const out = c.render(140).join("\n"); - - // Frame borders - expect(out).toContain("\u2501"); // ━ - expect(out).toContain("\u250F"); // ┏ - expect(out).toContain("\u251B"); // ┛ - - // Tab bar shows all three tabs - expect(out).toContain("Usage Statistics"); - expect(out).toContain("Current Usage"); - expect(out).toContain("Insights"); - - // Default period is All Time - expect(out).toContain("[All Time]"); - expect(out).toContain("Provider / Model"); - expect(out).toContain("openai-codex"); - expect(out).toContain("428k"); - - // Legend - expect(out).toContain( - "Tokens = Input + Output + CacheW \u2022 \u2191In = Input + CacheW \u2022 \u2193Out = Output \u2022 CacheR = Cache Read \u2022 CacheW = Cache Write", - ); - - // Current Usage content should NOT be visible on the Statistics tab - expect(out).not.toContain("Command Code (Go) \u2022 live \u2022 4s old"); - expect(out).not.toContain("57% left"); - - // No legacy layout artifacts - expect(out).not.toContain("\u256D"); // ╭ old border - expect(out).not.toContain("\u256F"); // ╯ old border -}); -``` - ---- - -### Step 15: Add test -- "renders Current Usage tab with provider details and diagnostics" - -- [ ] Add after the previous test: - -```typescript -it("renders Current Usage tab with provider details and diagnostics", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - // Provider details - expect(out).toContain("Command Code (Go) \u2022 live \u2022 4s old"); - expect(out).toContain("57% left"); - expect(out).toContain(expectedResetText(Date.parse("2026-06-07T11:47:00"))); - expect(out).toContain("$4.29/$10.00"); - - // Diagnostics appear in Current Usage tab - expect(out).toContain("Subscription endpoint unavailable."); - expect(out).toContain("Live cache is unavailable."); - - // Usage Statistics table should NOT be visible - expect(out).not.toContain("Provider / Model"); - expect(out).not.toContain("[All Time]"); -}); -``` - ---- - -### Step 16: Add test -- "renders Insights tab with insights grouped by category" - -- [ ] Add after the previous test: - -```typescript -it("renders Insights tab with insights grouped by category", () => { - 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, - }); - // Switch to Insights tab (Tab twice) - c.handleInput("\t"); - c.handleInput("\t"); - const out = c.render(100).join("\n"); - - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); - - // Should have its own period selector - expect(out).toContain("[All Time]"); - - // Usage Statistics content should NOT be visible - expect(out).not.toContain("Provider / Model"); -}); -``` - ---- - -### Step 16b: Add test -- "Insights tab period selector is independent from Statistics tab" - -- [ ] Add after the previous test: - -```typescript -it("has independent period selector for Insights tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - - // 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]"); - - // 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]"); - - // Change Insights period - c.handleInput("\u001b[D"); // Left - 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]"); -}); -``` - ---- - -### Step 17: Update test -- quota bar alignment (add tab switch) - -- [ ] Replace `"aligns quota bars by shared label width across available windows"`: - -```typescript -it("aligns quota bars by shared label width across available windows", () => { - const state = mkState(); - setWindows(state, [ - { - key: "5h", - label: "5h", - usedPercent: 50, - resetAt: Date.now() + 3600000, - }, - { - key: "weekly", - label: "Weekly", - usedPercent: 10, - resetAt: Date.now() + 86400000 * 7, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const lines = c.render(200); - - const line5h = lines.find( - (l) => l.includes("5h") && l.includes("% left") && l.includes("["), - ); - const lineWeekly = lines.find( - (l) => l.includes("Weekly") && l.includes("% left") && l.includes("["), - ); - - expect(line5h).toBeDefined(); - expect(lineWeekly).toBeDefined(); - - // Opening brackets must align vertically - const bracket5h = line5h?.indexOf("[") ?? -1; - const bracketWeekly = lineWeekly?.indexOf("[") ?? -1; - expect(bracket5h).toBe(bracketWeekly); - - // Shorter label is padded to match the longest available-window label - expect(line5h).toMatch(/5h\s+:/); -}); -``` - ---- - -### Step 18: Update test -- fractional usedPercent (add tab switch) - -- [ ] Replace `"rounds fractional usedPercent to integer remaining percentage"`: - -```typescript -it("rounds fractional usedPercent to integer remaining percentage", () => { - const state = mkState(); - setWindows(state, [ - { - key: "cycle", - label: "Cycle", - usedPercent: 43.7, - resetAt: Date.now() + 3600000, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - // 100 - 43.7 = 56.3, rounded to 56 - expect(out).toContain("56% left"); - expect(out).not.toContain("56.3%"); -}); -``` - ---- - -### Step 19: Update test -- same-day reset (add tab switch) - -- [ ] Replace `"formats same-day reset as HH:mm only"`: - -```typescript -it("formats same-day reset as HH:mm only", () => { - const now = new Date(); - const sameDayReset = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - 14, - 30, - ).getTime(); - - const state = mkState(); - setWindows(state, [ - { - key: "cycle", - label: "Cycle", - usedPercent: 50, - resetAt: sameDayReset, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - expect(out).toContain("(resets 14:30)"); - expect(out).not.toContain(" on "); -}); -``` - ---- - -### Step 20: Update test -- cross-day reset (add tab switch) - -- [ ] Replace `"formats cross-day reset as HH:mm on D MMM"`: - -```typescript -it("formats cross-day reset as HH:mm on D MMM", () => { - const state = mkState(); - setWindows(state, [ - { - key: "cycle", - label: "Cycle", - usedPercent: 50, - resetAt: Date.parse("2026-06-07T11:47:00"), - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - expect(out).toContain(expectedResetText(Date.parse("2026-06-07T11:47:00"))); -}); -``` - ---- - -### Step 21: Update test -- reset unavailable (add tab switch) - -- [ ] Replace `"renders reset unavailable when resetAt is absent"`: - -```typescript -it("renders reset unavailable when resetAt is absent", () => { - const state = mkState(); - setWindows(state, [ - { - key: "cycle", - label: "Cycle", - usedPercent: 50, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - expect(out).toContain("(reset unavailable)"); -}); -``` - ---- - -### Step 22: Update test -- unavailable windows (add tab switch) - -- [ ] Replace `"renders unavailable windows without bar and does not affect alignment"`: - -```typescript -it("renders unavailable windows without bar and does not affect alignment", () => { - const state = mkState(); - setWindows(state, [ - { - key: "5h", - label: "5h", - usedPercent: 50, - resetAt: Date.now() + 3600000, - }, - { - key: "daily", - label: "Daily", - usedPercent: 30, - resetAt: Date.now() + 86400000, - }, - { - key: "verylong", - label: "VeryLongName", - usedPercent: 10, - unavailableReason: "Not applicable", - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const lines = c.render(200); - - const line5h = lines.find( - (l) => l.includes("5h") && l.includes("% left") && l.includes("["), - ); - const lineDaily = lines.find( - (l) => l.includes("Daily") && l.includes("% left") && l.includes("["), - ); - const lineLong = lines.find( - (l) => l.includes("VeryLongName") && l.includes("Not applicable"), - ); - - expect(line5h).toBeDefined(); - expect(lineDaily).toBeDefined(); - expect(lineLong).toBeDefined(); - - // Unavailable window has no bar or percentage - expect(lineLong).not.toContain("% left"); - - // Available windows' bars align (maxLabelWidth from "5h" and "Daily" only) - const bracket5h = line5h?.indexOf("[") ?? -1; - const bracketDaily = lineDaily?.indexOf("[") ?? -1; - expect(bracket5h).toBe(bracketDaily); - - // "5h" is padded to "Daily" width (5 chars), not "VeryLongName" width - expect(line5h).toMatch(/5h\s+:/); -}); -``` - ---- - -### Step 23: Update test -- quota row without ratio (add tab switch) - -- [ ] Replace `"renders quota row without ratio when used/limit/unit are incomplete"`: - -```typescript -it("renders quota row without ratio when used/limit/unit are incomplete", () => { - const state = mkState(); - setWindows(state, [ - { - key: "cycle", - label: "Cycle", - usedPercent: 50, - resetAt: Date.now() + 3600000, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - expect(out).toContain("50% left"); - // No ratio suffix should appear - expect(out).not.toContain(" - $"); - expect(out).not.toContain(" requests"); -}); -``` - ---- - -### Step 24: Update test -- provider navigation (Current Usage tab) - -- [ ] Replace `"wraps joined legend and supports tab-based provider navigation"`: - -```typescript -it("supports provider navigation with left/right in Current Usage tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - - // Left arrow cycles providers backward: Command Code (4) -> OpenCode Go (3) - c.handleInput("\u001b[D"); // Left - let out = c.render(120).join("\n"); - expect(out).toContain("[OpenCode Go]"); - expect(out).toContain("Credits: $12.50"); - - // Right arrow cycles forward: OpenCode Go (3) -> Command Code (4) - c.handleInput("\u001b[C"); // Right - out = c.render(120).join("\n"); - expect(out).toContain("[Command Code]"); -}); -``` - ---- - -### Step 25: Update test -- enter/space expand and period changes (remove v) - -- [ ] Replace `"uses enter/space for expand, v for insights, and left/right for period changes"`: - -```typescript -it("uses enter/space for expand and left/right for period changes in Statistics tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - - // Enter expands the selected provider row to reveal its model rows. - c.handleInput("\r"); - expect(c.render(120).join("\n")).toContain("gpt-5"); - - // Left/Right change the period. Default is All Time (index 3); one Left - // press moves to Last Week. - c.handleInput("\u001b[D"); - expect(c.render(120).join("\n")).toContain("[Last Week]"); - - // Two more Right presses move through This Week back to Today. - c.handleInput("\u001b[C"); - c.handleInput("\u001b[C"); - expect(c.render(120).join("\n")).toContain("[Today]"); - - // Period changes reset the selected row back to 0. - expect(c.render(120).join("\n")).toContain("openai-codex"); -}); -``` - ---- - -### Step 26: Update test -- insights grouped by category (tab switch instead of v) - -- [ ] Replace `"renders insights grouped by category"`: - -```typescript -it("renders insights grouped by category in Insights tab", () => { - 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, - }); - // 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"); - 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(projectsIdx).toBeGreaterThan(-1); - expect(lines[projectsIdx]).toContain("% of usage"); - expect(lines[projectsIdx + 1]).toContain("career-ops"); - // Verify bullet-list format for cost category - expect(out).toContain(" - Large context:"); -}); -``` - ---- - -### Step 27: Update test -- defaults insights without category (tab switch instead of v) - -- [ ] Replace `"defaults insights without category to cost patterns"`: - -```typescript -it("defaults insights without category to cost patterns", () => { - const state = mkState(); - state.insights = [{ label: "No category", cost: 1, detail: "test" }]; - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Insights tab - c.handleInput("\t"); - c.handleInput("\t"); - const out = c.render(100).join("\n"); - expect(out).toContain("Cost patterns"); - expect(out).toContain(" - No category:"); -}); -``` - ---- - -### Step 28: Delete test -- "renders empty line between Usage Statistics title and period tabs" - -- [ ] Remove the entire `it("renders empty line between Usage Statistics title and period tabs", ...)` test block. The section title is now in the tab bar, not a standalone line. - ---- - -### Step 29: Update themed test -- frame borders and tab bar styling - -- [ ] Replace `"wraps section titles, borders, and dimmed helpers with ANSI escape codes"`: - -```typescript -it("renders frame borders and tab bar with themed styling", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - const lines = c.render(140); - const out = lines.join("\n"); - - // Frame uses ┏ and ┛ (from overlay-render frame glyphs) - expect(out).toContain("\u250F"); // ┏ - expect(out).toContain("\u251B"); // ┛ - - // Tab bar active pill uses inverse+bold for Usage Statistics - expect( - theme.calls.some( - (c) => c.method === "bold" && c.text.includes("Usage Statistics"), - ), - ).toBe(true); - expect( - theme.calls.some( - (c) => c.method === "inverse" && c.text.includes("Usage Statistics"), - ), - ).toBe(true); - - // Footer should be dimmed with per-tab content - expect(out).toContain("[Tab/Shift-Tab] Switch tab"); - expect( - theme.calls.some( - (c) => - c.method === "dim" && c.text.includes("[Tab/Shift-Tab] Switch tab"), - ), - ).toBe(true); -}); -``` - ---- - -### Step 30: Update themed test -- inactive tabs with bg styling - -- [ ] Replace `"dims the inactive provider tabs in Current Usage"`: - -```typescript -it("renders inactive main tabs with bg styling", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - c.render(140); - - // Inactive tabs use bg("selectedBg", fg("accent", label)) - const bgCalls = theme.calls - .filter((c) => c.method === "bg") - .map((c) => c.text); - // Current Usage and Insights should have bg calls (they're inactive) - expect(bgCalls.some((t) => t.includes("Current Usage"))).toBe(true); - expect(bgCalls.some((t) => t.includes("Insights"))).toBe(true); -}); -``` - ---- - -### Step 31: Update themed test -- disclosure arrow (adjust for frame) - -- [ ] Replace `"highlights the selected disclosure arrow + provider label and dims the rest"`: - -```typescript -it("highlights the selected disclosure arrow and dims the rest", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - const lines = c.render(140); - - const providerLine = lines.find( - (l) => l.includes("openai-codex") && l.includes("\u25B8"), - ); - expect(providerLine).toBeDefined(); - - const plain = stripAnsi(providerLine ?? ""); - // Line is inside a frame (\u2503...\u2503); verify no stray > cursor in content - const content = plain.replace(/^\u2503\s*/, ""); - expect(content.startsWith(">")).toBe(false); - expect(plain).toContain("\u25B8"); // ▸ - expect(plain).toContain("openai-codex"); - expect( - theme.calls.some( - (c) => - c.method === "fg" && - c.color === "accent" && - c.text.includes("openai-codex"), - ), - ).toBe(true); -}); -``` - ---- - -### Step 32: Update themed test -- quota bar alignment by visible width (add tab switch) - -- [ ] Replace `"aligns themed quota bars by visible width"`: - -```typescript -it("aligns themed quota bars by visible width", () => { - const theme = makeAnsiTheme(); - const state = mkState(); - setWindows(state, [ - { - key: "5h", - label: "5h", - usedPercent: 50, - resetAt: Date.now() + 3600000, - }, - { - key: "weekly", - label: "Weekly", - usedPercent: 10, - resetAt: Date.now() + 86400000 * 7, - }, - ]); - - const c = new UsageDashboardComponent(state, () => undefined, { theme }); - // Switch to Current Usage tab - c.handleInput("\t"); - const lines = c.render(200); - const line5h = lines.find( - (l) => - stripAnsi(l).includes("5h") && l.includes("[") && l.includes("% left"), - ); - const lineWeekly = lines.find( - (l) => - stripAnsi(l).includes("Weekly") && - l.includes("[") && - l.includes("% left"), - ); - - expect(line5h).toBeDefined(); - expect(lineWeekly).toBeDefined(); - - // The opening bracket (after padding) aligns vertically; frame adds - // uniform padding so relative alignment is preserved. - const bracketIndex = (line: string) => stripAnsi(line).indexOf("["); - expect(bracketIndex(line5h ?? "")).toBe(bracketIndex(lineWeekly ?? "")); -}); -``` - ---- - -### Step 33: Update themed test -- quota remaining-bar fill (add tab switch) - -- [ ] Replace `"highlights the quota remaining-bar fill and percentage"`: - -```typescript -it("highlights the quota remaining-bar fill and percentage", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - c.render(140); - - // The fill glyphs should be wrapped in accent styling. - const filledAccent = theme.calls.find( - (c) => - c.method === "fg" && c.color === "accent" && c.text.includes("\u2588"), - ); - expect(filledAccent).toBeDefined(); - - // The percentage text should be accent-wrapped. - const percentAccent = theme.calls.find( - (c) => c.method === "fg" && c.color === "accent" && c.text === "57% left", - ); - expect(percentAccent).toBeDefined(); -}); -``` - ---- - -### Step 34: Update themed test -- dims formula legend, reset, ratio (split across tabs) - -- [ ] Replace `"dims the formula legend, reset text, and ratio text"`: - -```typescript -it("dims the formula legend on Statistics tab and reset/ratio on Current Usage tab", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - - // Statistics tab: legend segments should be dimmed - c.render(140); - const dimmed = theme.calls - .filter((c) => c.method === "dim") - .map((c) => c.text); - expect(dimmed).toContain("Tokens = Input + Output + CacheW"); - expect(dimmed).toContain("CacheR = Cache Read"); - - // Switch to Current Usage tab: reset and ratio should be dimmed - c.handleInput("\t"); - c.render(140); - const allDimmed = theme.calls - .filter((c) => c.method === "dim") - .map((c) => c.text); - expect( - allDimmed.some( - (text) => - text.startsWith("(resets ") || text.includes("reset unavailable"), - ), - ).toBe(true); - expect(allDimmed).toContain("$4.29/$10.00"); -}); -``` - ---- - -### Step 35: Update themed test -- footer context-aware per tab - -- [ ] Replace `"renders the footer in [Shortcut] Action format with dimmed styling"`: - -```typescript -it("renders context-aware footer per tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - - // Statistics tab footer - let out = c.render(160).join("\n"); - let stripped = stripAnsi(out); - expect(stripped).toContain("[Tab/Shift-Tab] Switch tab"); - expect(stripped).toContain("[Left/Right] Period"); - expect(stripped).toContain("[Up/Down] Row"); - expect(stripped).toContain("[Enter] Expand"); - expect(stripped).toContain("[q/Esc] Close"); - - // Current Usage tab footer - c.handleInput("\t"); - out = c.render(160).join("\n"); - stripped = stripAnsi(out); - expect(stripped).toContain("[Tab/Shift-Tab] Switch tab"); - expect(stripped).toContain("[Left/Right] Provider"); - expect(stripped).not.toContain("[Up/Down] Row"); - - // Insights tab footer - c.handleInput("\t"); - 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).not.toContain("[Up/Down] Row"); -}); -``` - ---- - -### Step 36: Update themed test -- ANSI truncation (frame handles it now) - -- [ ] Replace `"strips ANSI before applying final truncation so visible width is preserved"`: - -```typescript -it("strips ANSI before applying final truncation so visible width is preserved", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - // Render with a narrow width -- every visible line must not exceed it - // even when ANSI escapes are present. frame() handles truncation. - const lines = c.render(40); - for (const line of lines) { - const visible = stripAnsi(line).length; - expect(visible).toBeLessThanOrEqual(40); - } -}); -``` - ---- - -### Step 37: Update responsive test -- narrow widths (check frame borders) - -- [ ] Replace `"renders at very narrow widths without breaking the table"`: - -```typescript -it("renders at very narrow widths without breaking the frame", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - const lines = c.render(30); - for (const line of lines) { - const visible = line.replace(ANSI_PATTERN, "").length; - expect(visible).toBeLessThanOrEqual(30); - } - // Frame borders should be present - const out = lines.join("\n"); - expect(out).toContain("\u250F"); // ┏ - expect(out).toContain("\u251B"); // ┛ -}); -``` - ---- - -## Verification - -### Step 38: Run all tests - -- [ ] Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run -``` - -Expected: All tests pass. - ---- - -### Step 39: Fix any remaining failures - -- [ ] If any tests fail, diagnose and fix. Common issues: - - **Line-finding assertions:** Tests using `startsWith` or `indexOf` may need updating for frame-wrapped lines. Use `includes` instead. - - **Width calculations:** Content width is now `frameContentWidth(w) = w - 6`, so table breakpoints may shift at boundary widths. Adjust render width or assertion if needed. - - **Tab state:** Tests that render Current Usage content must call `c.handleInput("\t")` first. Tests that render Insights content must call `c.handleInput("\t")` twice. - - **Missing `inverse`/`bg` on theme:** If `makeAnsiTheme` doesn't have `inverse`/`bg`, Phase 1 was not applied correctly. Verify preconditions. - ---- - -### Step 40: Commit - -- [ ] Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/tui/dashboard.ts tests/dashboard.test.ts -git commit -m "feat(tui): refactor dashboard to tabbed overlay + update tests - -Replace vertical stacking with 3 tabs: Usage Statistics, Current -Usage, Insights. Use frame() and renderTabBar() from overlay-render. -Tab/Shift-Tab switches tabs, Left/Right is contextual per tab. -Diagnostics moved into Current Usage tab. Enable overlay mode with -centered anchor, 85% maxHeight, 92% width. Update all dashboard -tests for new layout. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-5.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-5.md deleted file mode 100644 index 8bf2d4c..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-5.md +++ /dev/null @@ -1,125 +0,0 @@ -# Phase 5: Legacy Cleanup — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Remove the legacy `dashboardFooter` (singular) and `dashboardBorderChars` properties from `UI_STRINGS`, which were kept for backward compatibility during Phase 3 and are no longer referenced after Phase 4. - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** Phase 4 (core refactor + test updates) is complete. The dashboard no longer references `UI_STRINGS.dashboardFooter` or `UI_STRINGS.dashboardBorderChars`. All tests pass. -**Postconditions:** `UI_STRINGS` contains only the new per-tab `dashboardFooters` object. No dead constants remain. All tests and type checks pass. - ---- - -## Step 1: Remove `dashboardFooter` and `dashboardBorderChars` from `UI_STRINGS` - -- [ ] Edit `src/shared/constants.ts` and remove the `dashboardFooter` and `dashboardBorderChars` properties from the `UI_STRINGS` object. - -The final `UI_STRINGS` must be exactly: - -```typescript -export const UI_STRINGS = { - dashboardTitle: "Pi Usage Dashboard", - dashboardFooters: { - statistics: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter] Expand", - "[q/Esc] Close", - ].join(" \u2022 "), - current: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Provider", - "[q/Esc] Close", - ].join(" \u2022 "), - insights: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[q/Esc] Close", - ].join(" \u2022 "), - }, - dashboardBorderedSectionTitles: { - usageStatistics: "Usage Statistics", - currentUsage: "Current Usage", - insights: "Insights", - notes: "Notes", - }, - dashboardDefaultPeriod: "allTime" as UsageWindow, -} as const; -``` - -Properties to remove: - -1. `dashboardFooter` (singular) — the old single-string footer with `[Tab/Shift-Tab] Provider`, `[Enter/Space] Expand/Collapse`, `[v] Insights` hints. Superseded by `dashboardFooters` (plural, per-tab). -2. `dashboardBorderChars` — the old `╭╮╰╯─├┤` border characters. Superseded by `frame()` in `src/tui/overlay-render.ts` which uses `┏┓┗┛━┃`. - ---- - -## Step 2: Grep for stale references - -- [ ] Verify no code still references the removed properties. - -Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -grep -rn "dashboardFooter[^s]" src/ tests/ || echo "No stale dashboardFooter refs" -grep -rn "dashboardBorderChars" src/ tests/ || echo "No stale dashboardBorderChars refs" -``` - -Expected: Both commands print the "No stale" message. No files reference either property. - -If references are found, fix them before proceeding: -- References to `UI_STRINGS.dashboardFooter` should be replaced with `UI_STRINGS.dashboardFooters[tabId]` (this should already have been done in Phase 4). -- References to `UI_STRINGS.dashboardBorderChars` should be removed entirely (border rendering is now handled by `frame()` in `overlay-render.ts`). - ---- - -## Step 3: Type check - -- [ ] Verify the project compiles with no type errors. - -Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit -``` - -Expected: Clean exit, no type errors. - ---- - -## Step 4: Run all tests - -- [ ] Verify all tests pass after the removal. - -Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run -``` - -Expected: All tests pass. No test should reference the removed properties (they were updated in Phase 4's Task 6). - ---- - -## Step 5: Commit - -- [ ] Stage and commit the change. - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/shared/constants.ts -git commit -m "chore(constants): remove legacy dashboard footer and border chars - -Remove dashboardFooter (singular) and dashboardBorderChars from -UI_STRINGS. These were kept for backward compatibility during Phase 3 -and are superseded by dashboardFooters (per-tab) and the frame() -utility in overlay-render.ts. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-6.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-6.md deleted file mode 100644 index d140bba..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-6.md +++ /dev/null @@ -1,103 +0,0 @@ -# Phase 6: Verification — Dashboard Overlay & Tabs - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Final verification sweep confirming no stale references, no type errors, and all tests pass after the full dashboard overlay refactor. - -**Parent plan:** `docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md` -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - -**Preconditions:** Phases 1-5 are complete. The dashboard is a tabbed overlay using `frame()` and `renderTabBar()`. Legacy constants have been removed. -**Postconditions:** The codebase is confirmed clean — no dead references, no type errors, all tests green. The refactor is fully complete. - ---- - -## Step 1: Run full test suite - -- [ ] Run all tests and confirm they pass. - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run -``` - -Expected: All tests pass with exit code 0. No skipped or failing tests. - ---- - -## Step 2: Type check - -- [ ] Run the TypeScript compiler in check-only mode. - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit -``` - -Expected: Clean exit, no type errors. - ---- - -## Step 3: Grep for stale references to removed identifiers - -- [ ] Verify no code references identifiers that were removed during the refactor. - -Run each command and confirm no matches are found: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage - -# Legacy singular footer (should be dashboardFooters plural) -grep -rn "dashboardFooter[^s]" src/ tests/ || echo "OK: No stale dashboardFooter refs" - -# Legacy border characters (replaced by frame() in overlay-render.ts) -grep -rn "dashboardBorderChars" src/ tests/ || echo "OK: No stale dashboardBorderChars refs" - -# Legacy insights toggle field (replaced by activeTab === "insights") -grep -rn "showInsights" src/ tests/ || echo "OK: No stale showInsights refs" - -# Legacy "v" key handler in dashboard (insights is now a tab, not a toggle) -grep -rn '"v"' src/tui/dashboard.ts || echo "OK: No v-key handler in dashboard" -``` - -Expected: All four commands print their "OK" message. No matches found. - -If any stale references are found, fix them before proceeding to Step 4. - ---- - -## Step 4: Verify overlay options are present in `dashboard.ts` - -- [ ] Confirm the overlay configuration is correctly set in the `openDashboard` function. - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -grep -A5 "overlay:" src/tui/dashboard.ts -``` - -Expected output should include: - -``` -overlay: true, -overlayOptions: { - anchor: "center", - maxHeight: "85%", - width: "92%", -} -``` - ---- - -## Step 5: Commit any remaining fixes (if needed) - -- [ ] If any issues were found and fixed in Steps 1-4, stage and commit them. - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add -A -git commit -m "fix(tui): address remaining issues from tabbed overlay refactor - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - -If no fixes were needed, skip this step — the refactor is complete. diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md deleted file mode 100644 index 385fbff..0000000 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md +++ /dev/null @@ -1,1614 +0,0 @@ -# Dashboard Overlay & Tabs 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:** Refactor the pi-usage dashboard from a vertically-stacked custom component into a centered overlay with 3 tabbed panes (Usage Statistics, Current Usage, Insights). - -**Architecture:** Single `UsageDashboardComponent` class restructured around tab state. New `overlay-render.ts` provides `frame()`, `renderTabBar()`, and `pad()` utilities replicated from the pi-extension-manager pattern. The overlay uses `ctx.ui.custom()` with `{ overlay: true, overlayOptions: { anchor: "center", maxHeight: "85%", width: "92%" } }`. - -**Tech Stack:** TypeScript, `@earendil-works/pi-tui` (Component, Key, matchesKey, visibleWidth, truncateToWidth), `@earendil-works/pi-coding-agent` (Theme), Vitest for tests. - -**Reference repos:** - -- pi-extension-manager: `/Users/lanh/Developer/pi-packages/vanillagreencom-vstack/pi-extensions/pi-extension-manager` -- overlay pattern, frame/tab bar rendering, pill styling -- pi TUI framework: `/Users/lanh/Developer/pi-packages/pi` -- Component/Theme/TUI/OverlayOptions interfaces - -**Spec:** `docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md` - ---- - -## Execution Phases - -Each phase is atomic -- all tests pass after completion. Phases 1-3 touch independent files and can run in parallel. - -| Phase | Tasks | Description | Result | -|-------|-------|-------------|--------| -| **1. Theme foundation** | Task 1 | Add `inverse` + `bg` to `DashboardTheme`, update adapters and test helper | Theme API ready for tab pills | -| **2. Render utilities** | Task 2 | Create `overlay-render.ts` with `frame()`, `renderTabBar()`, `pad()` + tests | Utilities exist, dashboard unchanged | -| **3. Constants prep** | Task 3 | Add per-tab `dashboardFooters` alongside legacy `dashboardFooter` | New constants available, legacy kept | -| **4. Core refactor + tests** | Tasks 4 + 6 | Refactor dashboard state/render/input to tabs + overlay. Update all dashboard tests. | Dashboard is tabbed overlay, tests green | -| **5. Legacy cleanup** | Task 5 | Remove old `dashboardFooter`, `dashboardBorderChars` | No dead code | -| **6. Verification** | Task 7 | Full suite, type check, grep for stale references | Confirmed clean | - ---- - -## File Map - -| File | Action | Responsibility | -| ------------------------------- | ------ | ------------------------------------------------------------------------ | -| `src/tui/dashboard-theme.ts` | Modify | Add `inverse`, `bg` methods; add `"borderAccent"`, `"selectedBg"` colors | -| `src/tui/overlay-render.ts` | Create | `frame()`, `renderTabBar()`, `pad()` utilities | -| `src/shared/constants.ts` | Modify | Per-tab footer strings, updated border chars | -| `src/tui/dashboard.ts` | Modify | Tab state, refactored `render()` and `handleInput()`, overlay options | -| `tests/dashboard-theme.test.ts` | Create | Tests for new theme methods | -| `tests/overlay-render.test.ts` | Create | Tests for frame, tab bar, pad | -| `tests/dashboard.test.ts` | Modify | Update all assertions for tabbed overlay output | - ---- - -## Task 1: Extend DashboardTheme with `inverse` and `bg` - -**Files:** - -- Modify: `src/tui/dashboard-theme.ts` -- Create: `tests/dashboard-theme.test.ts` - -- [ ] **Step 1: Write failing tests for new theme methods** - -Create `tests/dashboard-theme.test.ts`: - -```typescript -import { describe, expect, it } from "vitest"; -import { - noTheme, - fromPiTheme, - type DashboardTheme, -} from "../src/tui/dashboard-theme.ts"; - -describe("DashboardTheme", () => { - describe("noTheme", () => { - it("inverse returns text unchanged", () => { - expect(noTheme.inverse("hello")).toBe("hello"); - }); - - it("bg returns text unchanged", () => { - expect(noTheme.bg("selectedBg", "hello")).toBe("hello"); - }); - }); - - describe("fromPiTheme", () => { - it("delegates inverse to theme.inverse", () => { - const piTheme = { - fg: (_color: string, text: string) => text, - bold: (text: string) => `${text}`, - inverse: (text: string) => `${text}`, - bg: (color: string, text: string) => `${text}`, - }; - const theme = fromPiTheme(piTheme as never); - expect(theme.inverse("test")).toBe("test"); - }); - - it("delegates bg to theme.bg", () => { - const piTheme = { - fg: (_color: string, text: string) => text, - bold: (text: string) => `${text}`, - inverse: (text: string) => `${text}`, - bg: (color: string, text: string) => `${text}`, - }; - const theme = fromPiTheme(piTheme as never); - expect(theme.bg("selectedBg", "test")).toBe("test"); - }); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/dashboard-theme.test.ts` - -Expected: FAIL -- `noTheme.inverse` is not a function, `noTheme.bg` is not a function. - -- [ ] **Step 3: Add `inverse` and `bg` to DashboardTheme interface and implementations** - -In `src/tui/dashboard-theme.ts`, update the `DashboardTheme` interface: - -```typescript -export interface DashboardTheme { - /** Foreground color for a themed string. */ - fg: (color: DashboardColor, text: string) => string; - /** Background color for a themed string. */ - bg: (color: DashboardColor, text: string) => string; - /** Bold modifier. */ - bold: (text: string) => string; - /** Dim modifier. */ - dim: (text: string) => string; - /** Inverse modifier (swap fg/bg). */ - inverse: (text: string) => string; -} -``` - -Update `DashboardColor` to include the new color roles: - -```typescript -export type DashboardColor = - | "accent" - | "border" - | "borderAccent" - | "borderMuted" - | "selectedBg" - | "muted" - | "dim" - | "text"; -``` - -Update `noTheme`: - -```typescript -export const noTheme: DashboardTheme = { - fg: (_color, text) => text, - bg: (_color, text) => text, - bold: (text) => text, - dim: (text) => text, - inverse: (text) => text, -}; -``` - -Update `fromPiTheme`: - -```typescript -export function fromPiTheme(theme: Theme): DashboardTheme { - return { - fg: (color, text) => theme.fg(color, text), - bg: (color, text) => theme.bg(color as never, text), - bold: (text) => theme.bold(text), - dim: (text) => theme.fg("dim", text), - inverse: (text) => theme.inverse(text), - }; -} -``` - -Note: `bg` casts to `never` because `DashboardColor` is a superset that includes fg-only colors. Only `"selectedBg"` is a valid `ThemeBg` key, but the cast keeps the adapter simple. The caller is responsible for passing valid bg colors. - -- [ ] **Step 4: Run new tests to verify they pass** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/dashboard-theme.test.ts` - -Expected: PASS - -- [ ] **Step 5: Run all existing tests to verify no regressions** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass. The existing code still compiles because the new methods are additive. - -- [ ] **Step 6: Update `makeAnsiTheme` test helper for the new methods** - -In `tests/dashboard.test.ts`, update the `makeAnsiTheme` function to include `inverse` and `bg`: - -```typescript -function makeAnsiTheme(): DashboardTheme & { - calls: { method: string; color?: string; text: string }[]; -} { - const calls: { method: string; color?: string; text: string }[] = []; - const wrap = (open: string) => (text: string) => { - if (text.length === 0) return text; - return `${open}${text}${ANSI_ESCAPE}[0m`; - }; - return { - calls, - fg: (color, text) => { - calls.push({ method: "fg", color, text }); - return wrap(`${ANSI_ESCAPE}[38;5;75m`)(text); - }, - bg: (color, text) => { - calls.push({ method: "bg", color, text }); - return wrap(`${ANSI_ESCAPE}[48;5;236m`)(text); - }, - bold: (text) => { - calls.push({ method: "bold", text }); - return `${ANSI_ESCAPE}[1m${text}${ANSI_ESCAPE}[22m`; - }, - dim: (text) => { - calls.push({ method: "dim", text }); - return wrap(`${ANSI_ESCAPE}[38;5;243m`)(text); - }, - inverse: (text) => { - calls.push({ method: "inverse", text }); - return `${ANSI_ESCAPE}[7m${text}${ANSI_ESCAPE}[27m`; - }, - }; -} -``` - -- [ ] **Step 7: Run all tests again** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass. - -- [ ] **Step 8: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/tui/dashboard-theme.ts tests/dashboard-theme.test.ts tests/dashboard.test.ts -git commit -m "feat(tui): add inverse and bg methods to DashboardTheme - -Extend the theme adapter with inverse() for active tab pill styling -and bg() for inactive tab pill background. Add selectedBg and -borderAccent to DashboardColor. Update noTheme, fromPiTheme, and -test helper. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 2: Create overlay-render.ts with frame, tab bar, and pad utilities - -**Files:** - -- Create: `src/tui/overlay-render.ts` -- Create: `tests/overlay-render.test.ts` - -**Reference:** The pi-extension-manager's `extensions/manager/render.ts` (frame, renderTabBar, pad, pill helpers) and `extensions/manager/types.ts` (POPUP_PADDING_X/Y constants). Replicate the patterns using `DashboardTheme` instead of Pi's `Theme` directly. - -- [ ] **Step 1: Write failing tests for `pad`** - -Create `tests/overlay-render.test.ts`: - -```typescript -import { describe, expect, it } from "vitest"; -import { pad, frame, renderTabBar } from "../src/tui/overlay-render.ts"; -import { noTheme } from "../src/tui/dashboard-theme.ts"; - -describe("pad", () => { - it("pads short text with trailing spaces", () => { - expect(pad("hi", 10)).toBe("hi "); - }); - - it("returns text unchanged when it fills the width", () => { - expect(pad("hello", 5)).toBe("hello"); - }); - - it("truncates text that exceeds the width", () => { - const result = pad("hello world", 5); - expect(result.length).toBeLessThanOrEqual(5); - }); - - it("handles zero width", () => { - expect(pad("hi", 0)).toBe(""); - }); -}); -``` - -- [ ] **Step 2: Write failing tests for `frame`** - -Append to `tests/overlay-render.test.ts`: - -```typescript -describe("frame", () => { - it("wraps content lines in a bordered box", () => { - const lines = frame(["hello", "world"], 20, noTheme); - // Top border - expect(lines[0]).toContain("┏"); - expect(lines[0]).toContain("┓"); - // Bottom border - expect(lines[lines.length - 1]).toContain("┗"); - expect(lines[lines.length - 1]).toContain("┛"); - // Content lines have vertical borders - const contentLine = lines.find((l) => l.includes("hello")); - expect(contentLine).toBeDefined(); - expect(contentLine).toContain("┃"); - }); - - it("includes padding rows above and below content", () => { - const lines = frame(["test"], 20, noTheme); - // Structure: top border, 1 padding row, content, 1 padding row, bottom border - expect(lines.length).toBe(5); // 1 top + 1 pad + 1 content + 1 pad + 1 bottom - }); - - it("truncates overflow with indicator when fixedInnerRows is set", () => { - const content = ["line1", "line2", "line3", "line4", "line5"]; - const lines = frame(content, 30, noTheme, 3); - const joined = lines.join("\n"); - expect(joined).toContain("line1"); - expect(joined).toContain("line2"); - expect(joined).toContain("more line(s)"); - expect(joined).not.toContain("line5"); - }); - - it("renders a title in the top border when provided", () => { - const lines = frame(["content"], 30, noTheme, undefined, "My Title"); - expect(lines[0]).toContain("My Title"); - expect(lines[0]).toContain("┏"); - }); - - it("pads content to frameContentWidth", () => { - const lines = frame(["hi"], 20, noTheme); - // Content line: ┃ + 2 pad + content padded to contentWidth + 2 pad + ┃ - // All lines should be exactly 20 chars wide (in visible width) - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(20); - } - }); -}); -``` - -- [ ] **Step 3: Write failing tests for `renderTabBar`** - -Append to `tests/overlay-render.test.ts`: - -```typescript -describe("renderTabBar", () => { - const tabs = [ - { id: "stats", label: "Usage Statistics" }, - { id: "current", label: "Current Usage" }, - { id: "insights", label: "Insights" }, - ]; - - it("renders all tabs with active tab highlighted", () => { - const result = renderTabBar(tabs, "stats", 80, noTheme); - expect(result).toContain("Usage Statistics"); - expect(result).toContain("Current Usage"); - expect(result).toContain("Insights"); - }); - - it("returns empty padding for no tabs", () => { - const result = renderTabBar([], "stats", 20, noTheme); - expect(result.trim()).toBe(""); - }); - - it("shows overflow indicators when tabs exceed width", () => { - const result = renderTabBar(tabs, "insights", 20, noTheme); - // At 20 chars wide, not all tabs can fit - expect(result).toContain("Insights"); - }); - - it("pads result to the requested width", () => { - const result = renderTabBar(tabs, "stats", 80, noTheme); - // Result should be padded to exactly 80 visible chars - expect(result.length).toBe(80); - }); -}); -``` - -- [ ] **Step 4: Run tests to verify they fail** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/overlay-render.test.ts` - -Expected: FAIL -- module `../src/tui/overlay-render.ts` not found. - -- [ ] **Step 5: Implement `overlay-render.ts`** - -Create `src/tui/overlay-render.ts`: - -```typescript -/** - * Overlay rendering utilities for the usage dashboard. - * - * Replicates patterns from the pi-extension-manager's render helpers using - * the dashboard's own DashboardTheme adapter so colors flow through Pi's - * live theme. - */ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { DashboardTheme, DashboardColor } from "./dashboard-theme.ts"; - -// ── Layout constants ──────────────────────────────────────────────────── - -const PADDING_X = 2; -const PADDING_Y = 1; - -// ── Frame glyphs ──────────────────────────────────────────────────────── - -const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const; - -// ── Tab types ─────────────────────────────────────────────────────────── - -export interface DashboardTab { - id: string; - label: string; -} - -// ── Utilities ─────────────────────────────────────────────────────────── - -/** - * Pad `text` to exactly `width` visible columns. Truncates if wider. - */ -export function pad(text: string, width: number): string { - if (width <= 0) return ""; - const truncated = truncateToWidth(text, width, ""); - return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`; -} - -/** - * Calculate the content width inside a frame (excluding borders and padding). - */ -export function frameContentWidth(width: number): number { - return Math.max(1, width - 2 - PADDING_X * 2); -} - -// ── Frame ─────────────────────────────────────────────────────────────── - -/** - * Wrap content `lines` in a bordered frame box. - * - * Matches the pi-extension-manager's frame rendering: - * - Top/bottom borders with optional title - * - PADDING_Y blank rows above/below content - * - PADDING_X space columns on each side of content - * - Overflow truncation with "↓ N more line(s)" when fixedInnerRows is set - */ -export function frame( - lines: string[], - width: number, - theme: DashboardTheme, - fixedInnerRows?: number, - title = "", -): string[] { - const inner = Math.max(1, width - 2); - const contentWidth = frameContentWidth(width); - const border = (s: string) => theme.fg("borderAccent", s); - - let body = lines; - if (fixedInnerRows !== undefined && body.length > fixedInnerRows) { - const hidden = body.length - fixedInnerRows + 1; - body = [ - ...body.slice(0, Math.max(0, fixedInnerRows - 1)), - theme.fg("dim", `↓ ${hidden} more line(s)`), - ].slice(0, fixedInnerRows); - } - - const blank = `${border(FRAME.v)}${" ".repeat(inner)}${border(FRAME.v)}`; - - const top = (): string => { - if (!title) { - return `${border(FRAME.tl)}${border(FRAME.h.repeat(inner))}${border(FRAME.tr)}`; - } - const titleText = ` ${truncateToWidth(title, Math.max(1, inner - 2), "…")} `; - const fill = Math.max(1, inner - visibleWidth(titleText)); - return `${border(FRAME.tl)}${theme.fg("accent", titleText)}${border(FRAME.h.repeat(fill))}${border(FRAME.tr)}`; - }; - - const out = [top()]; - for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); - for (const line of body) { - out.push( - `${border(FRAME.v)}${" ".repeat(PADDING_X)}${pad(line, contentWidth)}${" ".repeat(PADDING_X)}${border(FRAME.v)}`, - ); - } - for (let i = 0; i < PADDING_Y; i += 1) out.push(blank); - out.push( - `${border(FRAME.bl)}${border(FRAME.h.repeat(inner))}${border(FRAME.br)}`, - ); - return out.map((line) => truncateToWidth(line, width, "")); -} - -// ── Tab bar ───────────────────────────────────────────────────────────── - -function activePill(theme: DashboardTheme, label: string): string { - return theme.fg("accent", theme.inverse(theme.bold(label))); -} - -function inactivePill(theme: DashboardTheme, label: string): string { - return theme.bg("selectedBg", theme.fg("accent", label)); -} - -/** - * Render a tab bar with pill-styled active/inactive tabs. - * - * Dynamic visibility: expands tabs around the active tab to fit `width`, - * showing ‹/› indicators when tabs overflow. - */ -export function renderTabBar( - tabs: DashboardTab[], - activeId: string, - width: number, - theme: DashboardTheme, -): string { - const safeWidth = Math.max(1, width); - if (tabs.length === 0) return " ".repeat(safeWidth); - - const activeIndex = Math.max( - 0, - tabs.findIndex((tab) => tab.id === activeId), - ); - const widths = tabs.map((tab) => visibleWidth(tab.label) + 2); // " label " - - const sliceWidth = (s: number, e: number): number => { - let total = 0; - for (let i = s; i < e; i += 1) total += widths[i]!; - total += Math.max(0, e - s - 1); // single-space gaps - total += s > 0 ? 2 : 0; // "‹ " - total += e < tabs.length ? 2 : 0; // " ›" - return total; - }; - - let start = activeIndex; - let end = activeIndex + 1; - let preferRight = true; - while (start > 0 || end < tabs.length) { - let progressed = false; - const tryRight = (): boolean => { - if (end < tabs.length && sliceWidth(start, end + 1) <= safeWidth) { - end += 1; - return true; - } - return false; - }; - const tryLeft = (): boolean => { - if (start > 0 && sliceWidth(start - 1, end) <= safeWidth) { - start -= 1; - return true; - } - return false; - }; - if (preferRight) { - if (tryRight()) progressed = true; - if (tryLeft()) progressed = true; - } else { - if (tryLeft()) progressed = true; - if (tryRight()) progressed = true; - } - if (!progressed) break; - preferRight = !preferRight; - } - - const cells = tabs.slice(start, end).map((tab) => { - const label = ` ${tab.label} `; - return tab.id === activeId - ? activePill(theme, label) - : inactivePill(theme, label); - }); - if (start > 0) cells.unshift(theme.fg("dim", "‹")); - if (end < tabs.length) cells.push(theme.fg("dim", "›")); - return pad(cells.join(" "), safeWidth); -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run tests/overlay-render.test.ts` - -Expected: All PASS. - -- [ ] **Step 7: Run full test suite** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass. The new file is additive -- nothing imports it yet. - -- [ ] **Step 8: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/tui/overlay-render.ts tests/overlay-render.test.ts -git commit -m "feat(tui): add overlay rendering utilities - -Add frame(), renderTabBar(), and pad() in overlay-render.ts. -Replicates pi-extension-manager patterns using DashboardTheme -for Pi theme integration. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 3: Update constants for tabbed layout - -**Files:** - -- Modify: `src/shared/constants.ts` - -- [ ] **Step 1: Update `UI_STRINGS` with per-tab footers and frame chars** - -In `src/shared/constants.ts`, replace the footer and border definitions: - -```typescript -export const UI_STRINGS = { - dashboardTitle: "Pi Usage Dashboard", - dashboardFooters: { - statistics: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter] Expand", - "[q/Esc] Close", - ].join(" \u2022 "), - current: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Provider", - "[q/Esc] Close", - ].join(" \u2022 "), - insights: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[q/Esc] Close", - ].join(" \u2022 "), - }, - dashboardBorderedSectionTitles: { - usageStatistics: "Usage Statistics", - currentUsage: "Current Usage", - insights: "Insights", - notes: "Notes", - }, - dashboardDefaultPeriod: "allTime" as UsageWindow, -} as const; -``` - -Note: The old `dashboardFooter` (singular) and `dashboardBorderChars` are removed. Border rendering is now handled by `frame()` in `overlay-render.ts`. - -- [ ] **Step 2: Fix any compile errors from the removal** - -Check `src/tui/dashboard.ts` for references to `UI_STRINGS.dashboardFooter` and `UI_STRINGS.dashboardBorderChars`. These will cause type errors -- that's expected. They will be fixed in Task 4 when the dashboard render pipeline is refactored. For now, temporarily keep the old properties alongside the new ones to avoid breaking the build: - -```typescript -export const UI_STRINGS = { - dashboardTitle: "Pi Usage Dashboard", - // New per-tab footers - dashboardFooters: { - statistics: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter] Expand", - "[q/Esc] Close", - ].join(" \u2022 "), - current: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Provider", - "[q/Esc] Close", - ].join(" \u2022 "), - insights: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[q/Esc] Close", - ].join(" \u2022 "), - }, - // Legacy -- removed in Task 4 when dashboard.ts stops referencing them - dashboardFooter: [ - "[Tab/Shift-Tab] Provider", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter/Space] Expand/Collapse", - "[v] Insights", - "[q/Esc] Close", - ].join(" \u2022 "), - dashboardBorderedSectionTitles: { - usageStatistics: "Usage Statistics", - currentUsage: "Current Usage", - insights: "Insights", - notes: "Notes", - }, - dashboardBorderChars: { - topLeft: "\u256D", - topRight: "\u256E", - bottomLeft: "\u2570", - bottomRight: "\u256F", - horizontal: "\u2500", - separatorLeft: "\u251C", - separatorRight: "\u2524", - }, - dashboardDefaultPeriod: "allTime" as UsageWindow, -} as const; -``` - -- [ ] **Step 3: Run all tests** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass (legacy properties kept for backward compatibility). - -- [ ] **Step 4: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/shared/constants.ts -git commit -m "feat(constants): add per-tab footer strings - -Add dashboardFooters with context-aware key hints for each tab. -Legacy dashboardFooter and dashboardBorderChars retained until -the dashboard render pipeline is refactored. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 4: Refactor dashboard component to tabbed overlay - -This is the main refactor. The component gains tab state, the render pipeline uses `frame()` + `renderTabBar()`, and `handleInput()` is restructured for tab-based navigation. - -**Files:** - -- Modify: `src/tui/dashboard.ts` - -- [ ] **Step 1: Add tab state and type** - -At the top of `src/tui/dashboard.ts`, add the tab type and tab definitions: - -```typescript -import { - type DashboardTab, - frame, - frameContentWidth, - renderTabBar, -} from "./overlay-render.ts"; -import { UI_STRINGS } from "../shared/constants.ts"; - -type DashboardTabId = "statistics" | "current" | "insights"; - -const DASHBOARD_TABS: DashboardTab[] = [ - { - id: "statistics", - label: UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - }, - { - id: "current", - label: UI_STRINGS.dashboardBorderedSectionTitles.currentUsage, - }, - { id: "insights", label: UI_STRINGS.dashboardBorderedSectionTitles.insights }, -]; -``` - -In the `UsageDashboardComponent` class, replace the `showInsights` field and add `activeTab` + `insightsPeriodIndex`: - -```typescript -export class UsageDashboardComponent implements Component { - private activeTab: DashboardTabId = "statistics"; - private periodIndex = DEFAULT_PERIOD_INDEX; - private insightsPeriodIndex = DEFAULT_PERIOD_INDEX; - private rowIndex = 0; - private expandedProvider: string | null = null; - private currentUsageProviderIndex: number; - // ... rest unchanged -``` - -Remove the `private showInsights = false;` line. - -- [ ] **Step 2: Add tab switching helper** - -Add a `switchTab` method to the component class: - -```typescript -private switchTab(delta: number): void { - const ids = DASHBOARD_TABS.map((t) => t.id); - const currentIndex = ids.indexOf(this.activeTab); - const next = (currentIndex + delta + ids.length) % ids.length; - this.activeTab = ids[next] as DashboardTabId; -} -``` - -- [ ] **Step 3: Refactor `handleInput` for tab-based navigation** - -Replace the `handleInput` method: - -```typescript -handleInput(data: string): void { - // Global keys - if (data === "q" || matchesKey(data, Key.escape)) { - this.invalidate(); - this.cancelScan?.(); - this.done(); - return; - } - if (matchesKey(data, Key.tab)) { - this.switchTab(1); - return; - } - if (matchesKey(data, SHIFT_TAB_KEY)) { - this.switchTab(-1); - return; - } - - // Per-tab contextual keys - switch (this.activeTab) { - case "statistics": - this.handleStatisticsInput(data); - break; - case "current": - this.handleCurrentUsageInput(data); - break; - case "insights": - this.handleInsightsInput(data); - break; - } -} - -private handleStatisticsInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.movePeriod(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.movePeriod(1); - return; - } - const period = this.currentPeriod(); - if (!period) return; - if (matchesKey(data, Key.down)) { - this.rowIndex = Math.min( - this.rowIndex + 1, - Math.max(0, period.providers.length - 1), - ); - } - if (matchesKey(data, Key.up)) { - this.rowIndex = Math.max(0, this.rowIndex - 1); - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - const provider = period.providers[this.rowIndex]?.key; - if (!provider) return; - this.expandedProvider = - this.expandedProvider === provider ? null : provider; - } -} - -private handleCurrentUsageInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.moveProvider(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.moveProvider(1); - return; - } -} - -private handleInsightsInput(data: string): void { - if (matchesKey(data, Key.left)) { - this.moveInsightsPeriod(-1); - return; - } - if (matchesKey(data, Key.right)) { - this.moveInsightsPeriod(1); - return; - } -} - -private moveInsightsPeriod(delta: number): void { - this.insightsPeriodIndex = - (this.insightsPeriodIndex + delta + PERIODS.length) % PERIODS.length; -} -``` - -- [ ] **Step 4: Refactor `render()` to use frame + tab bar + per-tab content** - -Replace the `render` method: - -```typescript -render(width: number): string[] { - const w = Math.max(8, width); - const contentWidth = frameContentWidth(w); - const lines: string[] = []; - - // Tab bar - lines.push(renderTabBar(DASHBOARD_TABS, this.activeTab, contentWidth, this.theme)); - lines.push(""); - - // Active tab content - switch (this.activeTab) { - case "statistics": - this.renderUsageStatisticsTab(contentWidth, lines); - break; - case "current": - this.renderCurrentUsageTab(contentWidth, lines); - break; - case "insights": - this.renderInsightsTab(contentWidth, lines); - break; - } - - // Footer - lines.push(""); - lines.push(this.renderFooter(contentWidth)); - - return frame(lines, w, this.theme); -} -``` - -- [ ] **Step 5: Rename section renderers to tab renderers** - -Rename `renderUsageStatistics` to `renderUsageStatisticsTab` and remove the insights toggle: - -```typescript -private renderUsageStatisticsTab(w: number, lines: string[]): void { - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.periodIndex, - w, - ), - ); - if (this.state.loading) { - lines.push(this.theme.dim("Loading session history...")); - } - lines.push(""); - - const period = this.currentPeriod(); - if (!period || period.total.messageCount === 0) { - lines.push(this.theme.dim("No local session usage found.")); - return; - } - const columns = tableColumns(w); - const providerWidth = labelWidth(columns, w); - lines.push(tableLine("Provider / Model", columns, providerWidth)); - lines.push(this.theme.fg("borderMuted", separator(columns, providerWidth))); - period.providers.forEach((row, index) => { - const expanded = this.expandedProvider === row.key; - lines.push( - this.renderProviderRow( - row, - index === this.rowIndex, - expanded, - columns, - providerWidth, - ), - ); - if (expanded) { - for (const model of period.modelsByProvider[row.key] ?? []) { - lines.push(this.renderModelRow(model, columns, providerWidth)); - } - } - }); - lines.push(this.theme.fg("borderMuted", separator(columns, providerWidth))); - lines.push(tableLine("Total", columns, providerWidth, period.total)); - lines.push(""); - lines.push(...this.renderLegend(w)); -} -``` - -Rename `renderCurrentUsage` to `renderCurrentUsageTab` and move diagnostics into it: - -```typescript -private renderCurrentUsageTab(w: number, lines: string[]): void { - const providers = liveProviders(this.state); - if (providers.length === 0) { - lines.push(this.theme.dim("No live usage details.")); - return; - } - this.currentUsageProviderIndex = Math.min( - this.currentUsageProviderIndex, - Math.max(0, providers.length - 1), - ); - lines.push( - ...this.renderTabs( - providers.map((provider) => provider.providerLabel), - this.currentUsageProviderIndex, - w, - ), - ); - lines.push(""); - - const referenceTime = Math.max( - this.state.generatedAt, - ...providers.map((provider) => provider.fetchedAt), - 0, - ); - const selected = providers[this.currentUsageProviderIndex]; - lines.push( - this.theme.fg( - "accent", - this.theme.bold(providerHeading(selected, referenceTime)), - ), - ); - if (selected.windows.length === 0 && selected.balances.length === 0) { - lines.push(this.theme.dim("No live usage details.")); - } else { - lines.push(...this.renderQuotaWindows(selected.windows)); - for (const balance of selected.balances) { - const value = - balance.unit === "USD" - ? formatCurrency(balance.remaining ?? undefined) - : formatAbbrev(balance.remaining ?? undefined); - const unitSuffix = balance.unit === "USD" ? "" : ` ${balance.unit}`; - const labelStyled = this.theme.dim(`${balance.label}:`); - lines.push(`${labelStyled} ${value}${unitSuffix}`); - } - } - - // Diagnostics (previously a separate section) - this.renderDiagnostics(lines); -} -``` - -Add a new `renderInsightsTab`: - -```typescript -private renderInsightsTab(w: number, lines: string[]): void { - lines.push( - ...this.renderTabs( - PERIODS.map((period) => PERIOD_LABELS[period]), - this.insightsPeriodIndex, - w, - ), - ); - lines.push(""); - - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } -} -``` - -- [ ] **Step 6: Update `renderFooter` to be context-aware** - -Replace the `renderFooter` method: - -```typescript -private renderFooter(width: number): string { - const footerKey = this.activeTab === "statistics" - ? "statistics" - : this.activeTab === "current" - ? "current" - : "insights"; - return this.theme.dim(truncateVisible(UI_STRINGS.dashboardFooters[footerKey], width)); -} -``` - -- [ ] **Step 7: Remove dead code** - -Remove these methods/fields that are no longer needed: - -- `borderLine(width)` method -- `currentUsageHeaderSeparator(width)` method -- `bottomBorder(width)` method -- `sectionTitle(text)` method (section titles are now tab names in the tab bar) -- The old `render()` method body (already replaced in step 4) -- Remove imports of `horizontalBorder` and `borderSeparator` helper functions -- Remove the `horizontalBorder` and `borderSeparator` free functions - -- [ ] **Step 8: Update `openDashboard` to use overlay options** - -In the `openDashboard` function at the bottom of `dashboard.ts`: - -```typescript -export async function openDashboard( - ctx: ExtensionCommandContext, - state: UsageCoreState, - cancelScan?: () => void, -): Promise { - await ctx.ui.custom( - (tui, theme, _keys, done) => { - const piTheme = theme as unknown as Parameters[0]; - const dashboardTheme: DashboardTheme = - piTheme && - typeof (piTheme as { fg?: unknown }).fg === "function" && - typeof (piTheme as { bold?: unknown }).bold === "function" - ? fromPiTheme(piTheme) - : noTheme; - return new UsageDashboardComponent(state, done, { - tui, - theme: dashboardTheme, - cancelScan, - }); - }, - { - overlay: true, - overlayOptions: { - anchor: "center", - maxHeight: "85%", - width: "92%", - }, - }, - ); -} -``` - -- [ ] **Step 9: Verify the build compiles** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit` - -Expected: No type errors. If any references to the old `dashboardFooter` or `dashboardBorderChars` remain, fix them. - -- [ ] **Step 10: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/tui/dashboard.ts -git commit -m "feat(tui): refactor dashboard to tabbed overlay - -Replace vertical stacking with 3 tabs: Usage Statistics, Current -Usage, Insights. Use frame() and renderTabBar() from overlay-render. -Tab/Shift-Tab switches tabs, Left/Right is contextual per tab. -Diagnostics moved into Current Usage tab. Enable overlay mode with -centered anchor, 85% maxHeight, 92% width. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 5: Remove legacy constants - -**Files:** - -- Modify: `src/shared/constants.ts` - -- [ ] **Step 1: Remove legacy footer and border char properties** - -In `src/shared/constants.ts`, remove the `dashboardFooter` and `dashboardBorderChars` properties from `UI_STRINGS` that were kept for backward compatibility in Task 3: - -```typescript -export const UI_STRINGS = { - dashboardTitle: "Pi Usage Dashboard", - dashboardFooters: { - statistics: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[Up/Down] Row", - "[Enter] Expand", - "[q/Esc] Close", - ].join(" \u2022 "), - current: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Provider", - "[q/Esc] Close", - ].join(" \u2022 "), - insights: [ - "[Tab/Shift-Tab] Switch tab", - "[Left/Right] Period", - "[q/Esc] Close", - ].join(" \u2022 "), - }, - dashboardBorderedSectionTitles: { - usageStatistics: "Usage Statistics", - currentUsage: "Current Usage", - insights: "Insights", - notes: "Notes", - }, - dashboardDefaultPeriod: "allTime" as UsageWindow, -} as const; -``` - -- [ ] **Step 2: Verify no remaining references** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && grep -r "dashboardFooter[^s]" src/ tests/ || echo "No references"` and `grep -r "dashboardBorderChars" src/ tests/ || echo "No references"` - -Expected: No references found (tests not yet updated may still reference them -- if so, do not remove yet and defer to Task 6). - -- [ ] **Step 3: Verify build compiles** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit` - -Expected: No type errors. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add src/shared/constants.ts -git commit -m "chore(constants): remove legacy dashboard footer and border chars - -These are superseded by dashboardFooters (per-tab) and the frame() -utility in overlay-render.ts. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 6: Update dashboard tests - -**Files:** - -- Modify: `tests/dashboard.test.ts` - -The tests need to account for: - -1. Output is now wrapped in a `frame()` -- top/bottom borders are `┏━┓` / `┗━┛` instead of `╭─╮` / `╰─╯` -2. A tab bar appears at the top with `Usage Statistics`, `Current Usage`, `Insights` -3. Only the active tab's content is rendered (no stacking of all sections) -4. `Tab`/`Shift-Tab` switches tabs instead of cycling providers -5. Provider cycling uses `Left`/`Right` within the Current Usage tab -6. `v` key no longer toggles insights -7. Insights is a separate tab with its own period selector -8. Footer is context-aware per tab -9. Diagnostics appear inside the Current Usage tab - -- [ ] **Step 1: Update the main render test** - -Replace the `"renders usage statistics + current usage with selected provider details"` test: - -```typescript -it("renders Usage Statistics tab by default with table and legend", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - const out = c.render(140).join("\n"); - - // Frame borders - expect(out).toContain("┏"); - expect(out).toContain("┛"); - - // Tab bar with Usage Statistics active - expect(out).toContain("Usage Statistics"); - expect(out).toContain("Current Usage"); - expect(out).toContain("Insights"); - - // Default period is All Time - expect(out).toContain("[All Time]"); - expect(out).toContain("Provider / Model"); - expect(out).toContain("openai-codex"); - expect(out).toContain("428k"); - - expect(out).toContain("Tokens = Input + Output + CacheW"); - - // Current Usage content should NOT be visible on the Statistics tab - expect(out).not.toContain("Command Code (Go) \u2022 live \u2022 4s old"); - expect(out).not.toContain("57% left"); -}); -``` - -- [ ] **Step 2: Add test for Current Usage tab** - -```typescript -it("renders Current Usage tab with provider details and diagnostics", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - const out = c.render(140).join("\n"); - - expect(out).toContain("Command Code (Go) \u2022 live \u2022 4s old"); - expect(out).toContain("57% left"); - expect(out).toContain(expectedResetText(Date.parse("2026-06-07T11:47:00"))); - - // Diagnostics should appear in Current Usage tab - expect(out).toContain("Subscription endpoint unavailable."); - - // Usage Statistics table should NOT be visible - expect(out).not.toContain("Provider / Model"); - expect(out).not.toContain("[All Time]"); -}); -``` - -- [ ] **Step 3: Add test for Insights tab** - -```typescript -it("renders Insights tab with insights grouped by category", () => { - 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, - }); - // Switch to Insights tab (Tab twice) - c.handleInput("\t"); - c.handleInput("\t"); - const out = c.render(100).join("\n"); - - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); - - // Should have its own period selector - expect(out).toContain("[All Time]"); -}); -``` - -- [ ] **Step 4: Update tab/provider navigation tests** - -Replace the `"wraps joined legend and supports tab-based provider navigation"` test: - -```typescript -it("supports provider navigation with left/right in Current Usage tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - // Switch to Current Usage tab - c.handleInput("\t"); - - // Left arrow cycles providers - c.handleInput("\u001b[D"); // Left - let out = c.render(120).join("\n"); - expect(out).toContain("OpenRouter"); - - // Right arrow cycles back - c.handleInput("\u001b[C"); // Right - out = c.render(120).join("\n"); - expect(out).toContain("Command Code (Go)"); -}); -``` - -- [ ] **Step 5: Update the insights toggle test** - -Replace the `"uses enter/space for expand, v for insights, and left/right for period changes"` test: - -```typescript -it("uses enter/space for expand and left/right for period changes in Statistics tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - - // Enter expands the selected provider row to reveal its model rows. - c.handleInput("\r"); - expect(c.render(120).join("\n")).toContain("gpt-5"); - - // Left/Right change the period. Default is All Time (index 3); one Left - // press moves to Last Week. - c.handleInput("\u001b[D"); - expect(c.render(120).join("\n")).toContain("[Last Week]"); - - // Two more Right presses move through This Week back to Today. - c.handleInput("\u001b[C"); - c.handleInput("\u001b[C"); - expect(c.render(120).join("\n")).toContain("[Today]"); -}); -``` - -- [ ] **Step 6: Update the insights grouping test** - -Replace the `"renders insights grouped by category"` test. This now uses tab switching instead of `v`: - -```typescript -it("renders insights grouped by category in Insights tab", () => { - 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, - }); - // Switch to Insights tab - c.handleInput("\t"); - c.handleInput("\t"); - const lines = c.render(100); - const out = lines.join("\n"); - expect(out).toContain("Projects"); - expect(out).toContain("career-ops"); - expect(out).toContain("90.0%"); - expect(out).toContain("Cost patterns"); - expect(out).toContain("Large context"); - expect(out).toContain(" - Large context:"); -}); -``` - -- [ ] **Step 7: Update the "defaults insights without category" test** - -```typescript -it("defaults insights without category to cost patterns", () => { - const state = mkState(); - state.insights = [{ label: "No category", cost: 1, detail: "test" }]; - const c = new UsageDashboardComponent(state, () => undefined, { - theme: noTheme, - }); - // Switch to Insights tab - c.handleInput("\t"); - c.handleInput("\t"); - const out = c.render(100).join("\n"); - expect(out).toContain("Cost patterns"); - expect(out).toContain(" - No category:"); -}); -``` - -- [ ] **Step 8: Update themed styling tests** - -In the `"dashboard themed styling"` describe block: - -Update `"wraps section titles, borders, and dimmed helpers with ANSI escape codes"`: - -```typescript -it("renders frame borders and tab bar with themed styling", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - const lines = c.render(140); - const out = lines.join("\n"); - - // Frame uses ┏ and ┛ (from overlay-render frame glyphs) - expect(out).toContain("┏"); - expect(out).toContain("┛"); - - // Tab bar active pill uses inverse+bold for Usage Statistics - expect( - theme.calls.some( - (c) => c.method === "bold" && c.text.includes("Usage Statistics"), - ), - ).toBe(true); - expect( - theme.calls.some( - (c) => c.method === "inverse" && c.text.includes("Usage Statistics"), - ), - ).toBe(true); - - // Footer should be dimmed with per-tab content - expect(out).toContain("[Tab/Shift-Tab] Switch tab"); - expect( - theme.calls.some( - (c) => - c.method === "dim" && c.text.includes("[Tab/Shift-Tab] Switch tab"), - ), - ).toBe(true); -}); -``` - -Update `"dims the inactive provider tabs in Current Usage"`: - -```typescript -it("renders inactive main tabs with bg styling", () => { - const theme = makeAnsiTheme(); - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, - }); - c.render(140); - - // Inactive tabs use bg("selectedBg", fg("accent", label)) - const bgCalls = theme.calls - .filter((c) => c.method === "bg") - .map((c) => c.text); - // Current Usage and Insights should have bg calls (they're inactive) - expect(bgCalls.some((t) => t.includes("Current Usage"))).toBe(true); - expect(bgCalls.some((t) => t.includes("Insights"))).toBe(true); -}); -``` - -- [ ] **Step 9: Update the footer test** - -Replace `"renders the footer in [Shortcut] Action format with dimmed styling"`: - -```typescript -it("renders context-aware footer per tab", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - - // Statistics tab footer - let out = c.render(160).join("\n"); - let stripped = stripAnsi(out); - expect(stripped).toContain("[Tab/Shift-Tab] Switch tab"); - expect(stripped).toContain("[Left/Right] Period"); - expect(stripped).toContain("[Up/Down] Row"); - expect(stripped).toContain("[Enter] Expand"); - expect(stripped).toContain("[q/Esc] Close"); - - // Current Usage tab footer - c.handleInput("\t"); - out = c.render(160).join("\n"); - stripped = stripAnsi(out); - expect(stripped).toContain("[Tab/Shift-Tab] Switch tab"); - expect(stripped).toContain("[Left/Right] Provider"); - expect(stripped).not.toContain("[Up/Down] Row"); - - // Insights tab footer - c.handleInput("\t"); - 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).not.toContain("[Up/Down] Row"); -}); -``` - -- [ ] **Step 10: Update responsive layout tests** - -Update `"renders at very narrow widths without breaking the table"`: - -```typescript -it("renders at very narrow widths without breaking the frame", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme: noTheme, - }); - const lines = c.render(30); - for (const line of lines) { - const visible = line.replace(ANSI_PATTERN, "").length; - expect(visible).toBeLessThanOrEqual(30); - } - // Frame borders should be present - expect(lines.join("\n")).toContain("┏"); - expect(lines.join("\n")).toContain("┛"); -}); -``` - -- [ ] **Step 11: Remove or update tests that reference the `v` key or old layout** - -Remove the test `"renders empty line between Usage Statistics title and period tabs"` -- the section title is now in the tab bar, not a separate line. - -Remove the `"dims the inactive provider tabs in Current Usage"` test that checks for provider tab dimming on the Statistics tab -- provider tabs are now only visible on the Current Usage tab. - -Update any other tests that assert the old stacked layout (both `"Usage Statistics"` and `"Current Usage"` visible simultaneously). - -- [ ] **Step 12: Run all tests** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass. If any fail, fix the assertions to match the new tabbed output structure. - -- [ ] **Step 13: Commit** - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add tests/dashboard.test.ts -git commit -m "test(tui): update dashboard tests for tabbed overlay - -Update all render and input assertions for the new tab-based layout. -Remove v-key toggle tests. Add tab switching, per-tab content, and -context-aware footer tests. - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` - ---- - -## Task 7: Final verification and cleanup - -**Files:** - -- All modified files - -- [ ] **Step 1: Run full test suite** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx vitest run` - -Expected: All tests pass. - -- [ ] **Step 2: Type check** - -Run: `cd /Users/lanh/Developer/pi-vault/pi-usage && npx tsc --noEmit` - -Expected: No type errors. - -- [ ] **Step 3: Verify no dead references to removed constants** - -Run: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -grep -rn "dashboardFooter[^s]" src/ tests/ || echo "No stale footer refs" -grep -rn "dashboardBorderChars" src/ tests/ || echo "No stale border refs" -grep -rn "showInsights" src/ tests/ || echo "No stale showInsights refs" -grep -rn '"v"' src/tui/dashboard.ts || echo "No v-key handler" -``` - -Expected: No references found for any of these. - -- [ ] **Step 4: Verify overlay options are present** - -Run: `grep -A5 "overlay:" src/tui/dashboard.ts` - -Expected: Shows `overlay: true` with `overlayOptions: { anchor: "center", maxHeight: "85%", width: "92%" }`. - -- [ ] **Step 5: Commit any remaining fixes** - -If any fixes were needed, commit them: - -```bash -cd /Users/lanh/Developer/pi-vault/pi-usage -git add -A -git commit -m "fix(tui): address remaining issues from tabbed overlay refactor - -Generated with [Devin](https://devin.ai) - -Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>" -``` diff --git a/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md b/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md deleted file mode 100644 index 6e4d117..0000000 --- a/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md +++ /dev/null @@ -1,197 +0,0 @@ -# Dashboard Overlay & Tabs Refactor - -**Date:** 2026-07-05 -**Status:** Approved -**Scope:** `src/tui/` layer only -- no public API changes - -## Summary - -Refactor the pi-usage dashboard from a vertically-stacked full-custom component into a centered overlay with 3 tabbed panes: Usage Statistics, Current Usage, and Insights. Adopt the overlay and rendering patterns from pi-extension-manager (replicated locally, not imported). - -## Motivation - -The current dashboard renders all sections stacked vertically in a single scroll, with Insights toggled via the `v` key replacing Usage Statistics. This makes the UI dense and requires the user to mentally separate unrelated data. The pi-extension-manager already establishes an overlay + tab pattern that users are familiar with. Aligning pi-usage to this pattern improves consistency and usability. - -## Architecture - -### Component Structure - -Single `UsageDashboardComponent` class retained (no multi-component decomposition). Internal restructuring around tab state: - -**State changes:** - -- Add `activeTab: 'statistics' | 'current' | 'insights'` (default: `'statistics'`) -- Add `insightsPeriodIndex: number` -- independent period selector for the Insights tab -- Remove `showInsights: boolean` (replaced by tab state) -- Retain `periodIndex`, `rowIndex`, `expandedProvider`, `currentUsageProviderIndex` unchanged - -**Render pipeline:** - -``` -render(width) - -> frame(contentLines, width, theme, fixedInnerRows) - -> renderTabBar(tabs, activeTab, innerWidth, theme) - -> renderActiveTabContent(innerWidth) - -> renderUsageStatisticsTab(w, lines) // tab 0 - -> renderCurrentUsageTab(w, lines) // tab 1 - -> renderInsightsTab(w, lines) // tab 2 - -> renderFooter(innerWidth) // context-aware keys -``` - -### Overlay Configuration - -The `openDashboard` function passes overlay options to `ctx.ui.custom()`: - -```typescript -await ctx.ui.custom( - (tui, theme, _keys, done) => { ... }, - { - overlay: true, - overlayOptions: { - anchor: "center", - maxHeight: "85%", - width: "92%", - }, - }, -); -``` - -These dimensions match the pi-extension-manager for visual consistency. - -## Tab Definitions - -### Tab 1: Usage Statistics (default) - -Content: - -- Period selector tabs (Today, This Week, Last Week, All Time) -- Aggregated provider/model table with expandable rows -- Total row -- Token legend - -Contextual keys: - -- Left/Right: switch period -- Up/Down: navigate provider rows -- Enter/Space: expand/collapse selected provider - -### Tab 2: Current Usage - -Content: - -- Provider selector tabs (live providers only) -- Provider heading (name, plan, status, age) -- Quota windows with progress bars -- Balances -- Diagnostics/Notes (previously a separate section, now housed here) - -Contextual keys: - -- Left/Right: switch provider - -### Tab 3: Insights - -Content: - -- Period selector tabs (same as Usage Statistics, independent state) -- Insights grouped by category (Projects, Skills, MCP servers, Cost patterns) - -Contextual keys: - -- Left/Right: switch period - -## Key Bindings - -### Global (all tabs) - -| Key | Action | -| --------- | ----------------------------- | -| Tab | Next tab | -| Shift-Tab | Previous tab | -| q / Esc | Close dashboard (cancel scan) | - -### Per-tab contextual keys - -| Key | Usage Statistics | Current Usage | Insights | -| ----------- | ---------------- | --------------- | ------------- | -| Left/Right | Switch period | Switch provider | Switch period | -| Up/Down | Navigate rows | (unused) | (unused) | -| Enter/Space | Expand/collapse | (unused) | (unused) | - -### Removed keys - -- `v` -- no longer needed; Insights is its own tab - -### Footer text (context-aware) - -- Usage Statistics: `[Tab/Shift-Tab] Switch tab [Left/Right] Period [Up/Down] Row [Enter] Expand [q] Close` -- Current Usage: `[Tab/Shift-Tab] Switch tab [Left/Right] Provider [q] Close` -- Insights: `[Tab/Shift-Tab] Switch tab [Left/Right] Period [q] Close` - -## New File: `src/tui/overlay-render.ts` - -Local replications of pi-extension-manager rendering utilities. These do not import from the manager -- they replicate the patterns using pi-usage's existing `DashboardTheme` adapter. - -### `frame(lines, width, theme, fixedInnerRows?, title?)` - -Draws a bordered frame around content lines. Uses the manager's box-drawing characters (`┏━┓┃┗┛`) for visual consistency with other Pi overlays. The existing `UI_STRINGS.dashboardBorderChars` (`╭╮╰╯`) are replaced. Configurable padding: 2px horizontal, 1px vertical. Truncates overflow with `"down-arrow N more line(s)"` indicator when content exceeds `fixedInnerRows`. - -### `renderTabBar(tabs, activeTab, width, theme)` - -Renders pill-styled tabs within available width: - -- Active tab: `theme.fg("accent", theme.inverse(theme.bold(label)))` -- inverted pill -- Inactive tabs: `theme.bg("selectedBg", theme.fg("accent", label))` -- subtle background pill -- Dynamic visibility with `‹` / `›` overflow indicators (handles narrow terminals) - -These pull colors through the `DashboardTheme` adapter, which delegates to Pi's live theme, ensuring visual consistency with the Pi theme in use. - -### `pad(text, width)` - -Pads text to exact visible width, ANSI-aware. Wraps `truncateToWidth` + space padding. - -## Theme Changes - -### `DashboardTheme` interface - -Add two methods: - -- `inverse(text: string): string` -- for active tab pill styling -- `bg(color: DashboardColor, text: string): string` -- for inactive tab pill background - -### `fromPiTheme()` adapter - -Add `inverse` and `bg` delegations to Pi's `theme.inverse()` and `theme.bg()`. Colors flow through Pi's live theme -- no hardcoded values. The `DashboardColor` type gains `"selectedBg"` for the inactive pill background. - -### `noTheme` passthrough - -Add `inverse` and `bg` as identity functions for unit tests. - -## Files Changed - -| File | Change | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `src/tui/overlay-render.ts` | New file -- `frame()`, `renderTabBar()`, `pad()` | -| `src/tui/dashboard.ts` | Refactor `render()`, `handleInput()`, add tab state, use `frame()` and `renderTabBar()` | -| `src/tui/dashboard-theme.ts` | Add `inverse` and `bg` to `DashboardTheme`, `fromPiTheme`, `noTheme`; add `"selectedBg"` to `DashboardColor` | -| `src/shared/constants.ts` | Update `UI_STRINGS.dashboardFooter` to per-tab footers, remove `v` key reference | -| `tests/` | Update rendering and input tests for new tab structure | - -## Files Unchanged - -| File | Reason | -| ---------------------------- | -------------------------------------------------------------------------- | -| `src/index.ts` | `openDashboard` signature unchanged; only overlay options added internally | -| `src/tui/formatters.ts` | All formatters reused as-is | -| `src/tui/table-layout.ts` | Table layout reused as-is within Usage Statistics tab | -| `src/tui/dashboard-model.ts` | Data model unchanged | -| `src/core/*` | Core logic untouched | -| `src/providers/*` | Provider implementations untouched | -| `src/shared/types.ts` | Types unchanged | - -## Testing - -- Existing rendering tests updated to assert per-tab output (tab bar present, only active tab's content rendered) -- Existing `handleInput` tests updated for new key semantics (Tab switches tabs, `v` removed) -- `noTheme` passthrough extended with `inverse` so unit tests work without a real Pi theme -- No new test infrastructure needed -- same `render(width)` + `handleInput(data)` testing pattern diff --git a/package.json b/package.json index f47f23f..f11dcb4 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@pi-vault/pi-usage", - "version": "0.5.1", + "version": "0.6.0", "type": "module", - "description": "Pi extension that displays aggregated usage statistics across all sessions", + "description": "Pi extension that tracks Pi usage across your sessions in one dashboard", "author": "Lanh Hoang ", "license": "MIT", "homepage": "https://github.com/pi-vault/pi-usage#readme", @@ -47,16 +47,17 @@ "CHANGELOG.md", "README.md" ], - "peerDependencies": { - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*" - }, "devDependencies": { - "@biomejs/biome": "^2.5.0", - "@earendil-works/pi-coding-agent": "^0.80.2", - "@earendil-works/pi-tui": "^0.80.2", - "@types/node": "^26.0.0", + "@biomejs/biome": "^2.5.2", + "@earendil-works/pi-coding-agent": "^0.80.3", + "@earendil-works/pi-tui": "^0.80.3", + "@types/node": "^26.1.0", + "typebox": "^1.3.3", "typescript": "^6.0.3", "vitest": "^4.1.9" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0312663..08063d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,23 +9,26 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.5.0 - version: 2.5.0 + specifier: ^2.5.2 + version: 2.5.2 '@earendil-works/pi-coding-agent': - specifier: ^0.80.2 - version: 0.80.2(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.3 + version: 0.80.3(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.80.2 - version: 0.80.2 + specifier: ^0.80.3 + version: 0.80.3 '@types/node': - specifier: ^26.0.0 - version: 26.0.0 + specifier: ^26.1.0 + version: 26.1.0 + typebox: + specifier: ^1.3.3 + version: 1.3.3 typescript: specifier: ^6.0.3 version: 6.0.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.0)(vite@8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -143,79 +146,79 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.0': - resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} + '@biomejs/biome@2.5.2': + resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.0': - resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==} + '@biomejs/cli-darwin-arm64@2.5.2': + resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.0': - resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==} + '@biomejs/cli-darwin-x64@2.5.2': + resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.0': - resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==} + '@biomejs/cli-linux-arm64-musl@2.5.2': + resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.0': - resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==} + '@biomejs/cli-linux-arm64@2.5.2': + resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.0': - resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==} + '@biomejs/cli-linux-x64-musl@2.5.2': + resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.0': - resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==} + '@biomejs/cli-linux-x64@2.5.2': + resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.0': - resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==} + '@biomejs/cli-win32-arm64@2.5.2': + resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.0': - resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==} + '@biomejs/cli-win32-x64@2.5.2': + resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@0.80.2': - resolution: {integrity: sha512-BF9WPhixIFjT6Kp3Iz3H6ugkU+4AWotM8py96XE5pIK0arJbQKMWbR+dXSWWDEmR5yc/aFQODnuyowOEzMGO7Q==} + '@earendil-works/pi-agent-core@0.80.3': + resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.2': - resolution: {integrity: sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ==} + '@earendil-works/pi-ai@0.80.3': + resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.2': - resolution: {integrity: sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A==} + '@earendil-works/pi-coding-agent@0.80.3': + resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.2': - resolution: {integrity: sha512-OvOAMIbXiC9OSse17YMiXIsI9AS5XM/ZV8N/k+UzdlRpPILDQYmLElevgGW92kkXR8qHBClIdzhCjuzlBGvphA==} + '@earendil-works/pi-tui@0.80.3': + resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -521,12 +524,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@25.9.4': - resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} - '@types/node@26.0.0': resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -975,14 +978,14 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typebox@1.3.3: + resolution: {integrity: sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -1350,44 +1353,44 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.0': + '@biomejs/biome@2.5.2': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.0 - '@biomejs/cli-darwin-x64': 2.5.0 - '@biomejs/cli-linux-arm64': 2.5.0 - '@biomejs/cli-linux-arm64-musl': 2.5.0 - '@biomejs/cli-linux-x64': 2.5.0 - '@biomejs/cli-linux-x64-musl': 2.5.0 - '@biomejs/cli-win32-arm64': 2.5.0 - '@biomejs/cli-win32-x64': 2.5.0 - - '@biomejs/cli-darwin-arm64@2.5.0': + '@biomejs/cli-darwin-arm64': 2.5.2 + '@biomejs/cli-darwin-x64': 2.5.2 + '@biomejs/cli-linux-arm64': 2.5.2 + '@biomejs/cli-linux-arm64-musl': 2.5.2 + '@biomejs/cli-linux-x64': 2.5.2 + '@biomejs/cli-linux-x64-musl': 2.5.2 + '@biomejs/cli-win32-arm64': 2.5.2 + '@biomejs/cli-win32-x64': 2.5.2 + + '@biomejs/cli-darwin-arm64@2.5.2': optional: true - '@biomejs/cli-darwin-x64@2.5.0': + '@biomejs/cli-darwin-x64@2.5.2': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.0': + '@biomejs/cli-linux-arm64-musl@2.5.2': optional: true - '@biomejs/cli-linux-arm64@2.5.0': + '@biomejs/cli-linux-arm64@2.5.2': optional: true - '@biomejs/cli-linux-x64-musl@2.5.0': + '@biomejs/cli-linux-x64-musl@2.5.2': optional: true - '@biomejs/cli-linux-x64@2.5.0': + '@biomejs/cli-linux-x64@2.5.2': optional: true - '@biomejs/cli-win32-arm64@2.5.0': + '@biomejs/cli-win32-arm64@2.5.2': optional: true - '@biomejs/cli-win32-x64@2.5.0': + '@biomejs/cli-win32-x64@2.5.2': optional: true - '@earendil-works/pi-agent-core@0.80.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.2(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -1399,7 +1402,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -1420,11 +1423,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.2(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.80.3(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.2(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.2(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.2 + '@earendil-works/pi-agent-core': 0.80.3(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -1450,7 +1453,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.2': + '@earendil-works/pi-tui@0.80.3': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -1700,11 +1703,11 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@25.9.4': + '@types/node@26.0.0': dependencies: - undici-types: 7.24.6 + undici-types: 8.3.0 - '@types/node@26.0.0': + '@types/node@26.1.0': dependencies: undici-types: 8.3.0 @@ -1719,13 +1722,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -2048,7 +2051,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.4 + '@types/node': 26.0.0 long: 5.3.2 retry@0.12.0: {} @@ -2115,15 +2118,15 @@ snapshots: typebox@1.1.38: {} - typescript@6.0.3: {} + typebox@1.3.3: {} - undici-types@7.24.6: {} + typescript@6.0.3: {} undici-types@8.3.0: {} undici@8.5.0: {} - vite@8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0): + vite@8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -2131,15 +2134,15 @@ snapshots: rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 26.0.0 + '@types/node': 26.1.0 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.0)(vite@8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -2156,11 +2159,11 @@ snapshots: tinyexec: 1.2.3 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@26.0.0)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 26.0.0 + '@types/node': 26.1.0 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 598bc8a..1a6b9d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,3 @@ allowBuilds: "@google/genai": true protobufjs: true -minimumReleaseAgeExclude: - - "@earendil-works/pi-agent-core@0.79.0" - - "@earendil-works/pi-ai@0.79.0" - - "@earendil-works/pi-coding-agent@0.79.0" - - "@earendil-works/pi-tui@0.79.0"