From 9225b453b8ad4ec776c44dc1515b98551ccce3a7 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 17:29:27 -0400 Subject: [PATCH 1/4] docs: add design spec for dashboard overlay & tabs refactor --- ...026-07-05-dashboard-overlay-tabs-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md 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 new file mode 100644 index 0000000..3c91ad2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md @@ -0,0 +1,197 @@ +# 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 From 667f3c32afe6908c06c0f82ec78cb5819b8f4514 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 17:41:04 -0400 Subject: [PATCH 2/4] docs: add implementation plan for dashboard overlay & tabs refactor --- .../2026-07-05-dashboard-overlay-tabs.md | 1599 +++++++++++++++++ ...026-07-05-dashboard-overlay-tabs-design.md | 2 +- 2 files changed, 1600 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md new file mode 100644 index 0000000..6094972 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md @@ -0,0 +1,1599 @@ +# 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` + +--- + +## 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 index 3c91ad2..6e4d117 100644 --- a/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md +++ b/docs/superpowers/specs/2026-07-05-dashboard-overlay-tabs-design.md @@ -142,7 +142,7 @@ 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) +- 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. From f03eca538e1ff0557413ca0b4d0176907fd95b30 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 17:46:55 -0400 Subject: [PATCH 3/4] docs: add execution phases to implementation plan --- .../plans/2026-07-05-dashboard-overlay-tabs.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md index 6094972..385fbff 100644 --- a/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs.md @@ -17,6 +17,21 @@ --- +## 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 | From 517487c63bffee4d39ee358ed6ec28a55e6c5cd4 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 18:03:26 -0400 Subject: [PATCH 4/4] docs: add 6 standalone phase plans for dashboard overlay refactor --- ...26-07-05-dashboard-overlay-tabs-phase-1.md | 215 +++ ...26-07-05-dashboard-overlay-tabs-phase-2.md | 375 +++++ ...26-07-05-dashboard-overlay-tabs-phase-3.md | 101 ++ ...26-07-05-dashboard-overlay-tabs-phase-4.md | 1443 +++++++++++++++++ ...26-07-05-dashboard-overlay-tabs-phase-5.md | 125 ++ ...26-07-05-dashboard-overlay-tabs-phase-6.md | 103 ++ 6 files changed, 2362 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-1.md create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-3.md create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-4.md create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-5.md create mode 100644 docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-6.md 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 new file mode 100644 index 0000000..c6504a1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-1.md @@ -0,0 +1,215 @@ +# 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 +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`, apply the following changes: + +**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 `DashboardColor`** to include the new color roles (replace the existing type): + +```typescript +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. + +- [ ] **Step 4: Run new tests to verify they pass** + +```bash +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** + +```bash +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`, 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 7: Run all tests again** + +```bash +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>" +``` 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 new file mode 100644 index 0000000..6634110 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-2.md @@ -0,0 +1,375 @@ +# 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 new file mode 100644 index 0000000..f970d39 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-3.md @@ -0,0 +1,101 @@ +# 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 new file mode 100644 index 0000000..b03f2da --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-4.md @@ -0,0 +1,1443 @@ +# 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 new file mode 100644 index 0000000..8bf2d4c --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-5.md @@ -0,0 +1,125 @@ +# 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 new file mode 100644 index 0000000..d140bba --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dashboard-overlay-tabs-phase-6.md @@ -0,0 +1,103 @@ +# 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.