From be8a875e0b1aabad6371c3bd5d5bc69693cf324e Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 21:52:32 -0400 Subject: [PATCH 1/2] docs: update phase 4 plan --- ...26-07-05-dashboard-overlay-tabs-phase-4.md | 1189 +++++++++-------- 1 file changed, 595 insertions(+), 594 deletions(-) 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 index b03f2da..ed9ec16 100644 --- 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 @@ -12,9 +12,9 @@ **Files modified:** -| File | Action | -|------|--------| -| `src/tui/dashboard.ts` | Refactor to tabbed overlay | +| File | Action | +| ------------------------- | ------------------------------------ | +| `src/tui/dashboard.ts` | Refactor to tabbed overlay | | `tests/dashboard.test.ts` | Update all assertions for new layout | --- @@ -546,41 +546,41 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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", - ); +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"); + // 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 - }); + // No legacy layout artifacts + expect(out).not.toContain("\u256D"); // ╭ old border + expect(out).not.toContain("\u256F"); // ╯ old border +}); ``` --- @@ -590,28 +590,28 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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]"); +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]"); +}); ``` --- @@ -621,38 +621,38 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +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"); +}); ``` --- @@ -662,32 +662,32 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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]"); +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]"); +}); ``` --- @@ -697,48 +697,48 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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, - }, - ]); +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 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("["), - ); + 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(); + 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); + // 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+:/); - }); + // Shorter label is padded to match the longest available-window label + expect(line5h).toMatch(/5h\s+:/); +}); ``` --- @@ -748,28 +748,28 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +it("rounds fractional usedPercent to integer remaining percentage", () => { + const state = mkState(); + setWindows(state, [ + { + key: "cycle", + label: "Cycle", + usedPercent: 43.7, + resetAt: Date.now() + 3600000, + }, + ]); - // 100 - 43.7 = 56.3, rounded to 56 - expect(out).toContain("56% left"); - expect(out).not.toContain("56.3%"); + 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%"); +}); ``` --- @@ -779,36 +779,36 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +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, + }, + ]); - expect(out).toContain("(resets 14:30)"); - expect(out).not.toContain(" on "); + 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 "); +}); ``` --- @@ -818,26 +818,26 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +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"), + }, + ]); - expect(out).toContain(expectedResetText(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"))); +}); ``` --- @@ -847,25 +847,25 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +it("renders reset unavailable when resetAt is absent", () => { + const state = mkState(); + setWindows(state, [ + { + key: "cycle", + label: "Cycle", + usedPercent: 50, + }, + ]); - expect(out).toContain("(reset unavailable)"); + 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)"); +}); ``` --- @@ -875,61 +875,61 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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", - }, - ]); +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 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"), - ); + 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(); + expect(line5h).toBeDefined(); + expect(lineDaily).toBeDefined(); + expect(lineLong).toBeDefined(); - // Unavailable window has no bar or percentage - expect(lineLong).not.toContain("% left"); + // 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); + // 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+:/); - }); + // "5h" is padded to "Daily" width (5 chars), not "VeryLongName" width + expect(line5h).toMatch(/5h\s+:/); +}); ``` --- @@ -939,29 +939,29 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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, - }, - ]); +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"); + 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"); +}); ``` --- @@ -971,24 +971,24 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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]"); +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]"); +}); ``` --- @@ -998,28 +998,28 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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, - }); +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"); + // 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]"); + // 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]"); + // 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"); - }); + // Period changes reset the selected row back to 0. + expect(c.render(120).join("\n")).toContain("openai-codex"); +}); ``` --- @@ -1029,39 +1029,39 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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:"); +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:"); +}); ``` --- @@ -1071,19 +1071,19 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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:"); +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:"); +}); ``` --- @@ -1099,40 +1099,39 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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); +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); +}); ``` --- @@ -1142,21 +1141,21 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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); +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); +}); ``` --- @@ -1166,33 +1165,33 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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); +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); +}); ``` --- @@ -1202,43 +1201,47 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"), - ); +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(); + 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 ?? "")); - }); + // 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 ?? "")); +}); ``` --- @@ -1248,29 +1251,28 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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(); +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(); +}); ``` --- @@ -1280,34 +1282,34 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +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"); +}); ``` --- @@ -1317,36 +1319,36 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); +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"); +}); ``` --- @@ -1356,19 +1358,19 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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); - } +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); + } +}); ``` --- @@ -1378,20 +1380,20 @@ Expected: No type errors. If references to removed methods or old constants rema - [ ] 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"); // ┛ +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"); // ┛ +}); ``` --- @@ -1413,7 +1415,6 @@ 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. From 376bec4a1fbed74f44a329f60df192ac038453ab Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sun, 5 Jul 2026 22:01:49 -0400 Subject: [PATCH 2/2] feat(dashboard): refactor to tabbed overlay layout (Phase 4) --- src/tui/dashboard.ts | 266 ++++++++++++++++++------------ tests/dashboard.test.ts | 352 +++++++++++++++++++++++++--------------- 2 files changed, 381 insertions(+), 237 deletions(-) diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 23676ea..5ce56ec 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -22,6 +22,12 @@ import { truncateVisible, wrapVisible, } from "./dashboard-theme.ts"; +import { + type DashboardTab, + frame, + frameContentWidth, + renderTabBar, +} from "./overlay-render.ts"; import { formatAge, formatAbbrev, @@ -53,6 +59,20 @@ const DEFAULT_PERIOD_INDEX = (() => { return idx >= 0 ? idx : 0; })(); +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 }, +]; + function normalizePlan(provider: ProviderUsageSnapshot): string | undefined { const raw = provider.planName?.trim(); if (!raw) return undefined; @@ -103,16 +123,6 @@ function initialLiveProviderIndex(state: UsageCoreState): number { return withData >= 0 ? withData : 0; } -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)}┤`; -} - export interface UsageDashboardOptions { /** * Optional TUI instance. When provided, the dashboard subscribes to live @@ -131,10 +141,11 @@ export interface UsageDashboardOptions { } 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 showInsights = false; private currentUsageProviderIndex: number; private readonly theme: DashboardTheme; private readonly cancelScan?: () => void; @@ -331,44 +342,7 @@ export class UsageDashboardComponent implements Component { ]; } - 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, - ), - ); - } - - private renderUsageStatistics(w: number, lines: string[]): void { - lines.push( - this.sectionTitle( - UI_STRINGS.dashboardBorderedSectionTitles.usageStatistics, - ), - ); - lines.push(""); - + private renderUsageStatisticsTab(w: number, lines: string[]): void { lines.push( ...this.renderTabs( PERIODS.map((period) => PERIOD_LABELS[period]), @@ -381,18 +355,6 @@ export class UsageDashboardComponent implements Component { } lines.push(""); - if (this.showInsights) { - lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.insights), - ); - if (this.state.insights.length === 0) { - lines.push(this.theme.dim("No insights yet.")); - } else { - lines.push(...this.renderInsightsByCategory(w)); - } - return; - } - const period = this.currentPeriod(); if (!period || period.total.messageCount === 0) { lines.push(this.theme.dim("No local session usage found.")); @@ -479,12 +441,25 @@ export class UsageDashboardComponent implements Component { return lines; } - private renderCurrentUsage(w: number, lines: string[]): void { + private renderInsightsTab(w: number, lines: string[]): void { lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.currentUsage), + ...this.renderTabs( + PERIODS.map((period) => PERIOD_LABELS[period]), + this.insightsPeriodIndex, + w, + ), ); - lines.push(this.currentUsageHeaderSeparator(w)); + lines.push(""); + // TODO: filter insights by period when backend supports per-period data + if (this.state.insights.length === 0) { + lines.push(this.theme.dim("No insights yet.")); + } else { + lines.push(...this.renderInsightsByCategory(w)); + } + } + + 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.")); @@ -529,6 +504,9 @@ export class UsageDashboardComponent implements Component { lines.push(`${labelStyled} ${value}${unitSuffix}`); } } + + // Diagnostics (previously a separate section, now housed in Current Usage) + this.renderDiagnostics(lines); } private renderDiagnostics(lines: string[]): void { @@ -541,7 +519,10 @@ export class UsageDashboardComponent implements Component { if (diagnosticNotes.length === 0) return; lines.push(""); lines.push( - this.sectionTitle(UI_STRINGS.dashboardBorderedSectionTitles.notes), + this.theme.fg( + "accent", + this.theme.bold(UI_STRINGS.dashboardBorderedSectionTitles.notes), + ), ); for (const note of diagnosticNotes) { lines.push(this.theme.dim(note)); @@ -549,20 +530,40 @@ export class UsageDashboardComponent implements Component { } private renderFooter(width: number): string { - return this.theme.dim(truncateVisible(UI_STRINGS.dashboardFooter, width)); + return this.theme.dim( + truncateVisible(UI_STRINGS.dashboardFooters[this.activeTab], width), + ); } render(width: number): string[] { const w = Math.max(8, width); - const lines: string[] = [this.borderLine(w)]; - this.renderUsageStatistics(w, lines); + const contentWidth = frameContentWidth(w); + const lines: string[] = []; + + // Tab bar + lines.push( + renderTabBar(DASHBOARD_TABS, this.activeTab, contentWidth, this.theme), + ); lines.push(""); - this.renderCurrentUsage(w, lines); - this.renderDiagnostics(lines); + + // 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(w)); - lines.push(this.bottomBorder(w)); - return lines.map((line) => truncateVisible(line, w)); + lines.push(this.renderFooter(contentWidth)); + + return frame(lines, w, this.theme); } private movePeriod(delta: number): void { @@ -580,25 +581,14 @@ export class UsageDashboardComponent implements Component { providers.length; } - handleInput(data: string): void { - if (data === "q" || matchesKey(data, Key.escape)) { - this.invalidate(); - this.cancelScan?.(); - this.done(); - return; - } - if (data === "v") { - this.showInsights = !this.showInsights; - return; - } - if (matchesKey(data, Key.tab)) { - this.moveProvider(1); - return; - } - if (matchesKey(data, SHIFT_TAB_KEY)) { - this.moveProvider(-1); - return; - } + private switchTab(delta: number): void { + const i = DASHBOARD_TABS.findIndex((t) => t.id === this.activeTab); + this.activeTab = DASHBOARD_TABS[ + (i + delta + DASHBOARD_TABS.length) % DASHBOARD_TABS.length + ].id as DashboardTabId; + } + + private handleStatisticsInput(data: string): void { if (matchesKey(data, Key.left)) { this.movePeriod(-1); return; @@ -626,6 +616,60 @@ export class UsageDashboardComponent implements Component { } } + 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 { + const delta = matchesKey(data, Key.left) + ? -1 + : matchesKey(data, Key.right) + ? 1 + : 0; + if (delta) { + this.insightsPeriodIndex = + (this.insightsPeriodIndex + delta + PERIODS.length) % PERIODS.length; + } + } + + 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; + } + } + invalidate(): void { this.unsubscribeUpdate?.(); this.unsubscribeUpdate = undefined; @@ -670,18 +714,28 @@ export async function openDashboard( 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, - }); - }); + 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%", + }, + }, + ); } diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 8f3da5b..1a6a5a2 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -293,77 +293,123 @@ function expectedResetText(resetAt: number | undefined): string { } describe("dashboard render", () => { - it("renders usage statistics + current usage with selected provider details", () => { + 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"); - // Default period is All Time per the spec. + 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 • ↑In = Input + CacheW • ↓Out = Output • CacheR = Cache Read • CacheW = Cache Write", + "Tokens = Input + Output + CacheW \u2022 \u2191In = Input + CacheW \u2022 \u2193Out = Output \u2022 CacheR = Cache Read \u2022 CacheW = Cache Write", ); - expect(out).toContain("Current Usage"); - expect(out).toContain( - "OpenAI/Codex MiniMax StepFun OpenCode Go [Command Code] OpenRouter", - ); - expect(out).toContain("Command Code (Go) • live • 4s old"); + // 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 + }); + + 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"); - expect(out).not.toContain("• Resets"); - - expect(out).not.toContain("Pi Usage Dashboard"); - expect(out).not.toContain(">_ Pi Usage"); - expect(out).not.toContain("Provider:"); - expect(out).not.toContain("Model:"); - expect(out).not.toContain("Offline:"); - expect(out).not.toContain("Command Code web usage API"); + + // 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]"); }); - it("renders empty line between Usage Statistics title and period tabs", () => { - const c = new UsageDashboardComponent(mkState(), () => undefined, { + 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, }); - const lines = c.render(140); + // 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]"); - const titleIdx = lines.findIndex((l) => l.includes("Usage Statistics")); - expect(titleIdx).toBeGreaterThan(-1); - expect(lines[titleIdx + 1]).toBe(""); - expect(lines[titleIdx + 2]).toContain("All Time"); + // Usage Statistics content should NOT be visible + expect(out).not.toContain("Provider / Model"); }); - it("wraps joined legend and supports tab-based provider navigation", () => { + it("has independent period selector for Insights tab", () => { const c = new UsageDashboardComponent(mkState(), () => undefined, { theme: noTheme, }); - const narrow = c.render(80).join("\n"); - expect(narrow).toContain( - "Tokens = Input + Output + CacheW • ↑In = Input + CacheW", - ); - expect(narrow).toContain("CacheR = Cache Read • CacheW = Cache Write"); - const wrappedTabs = c.render(36).join("\n"); - expect(wrappedTabs).toContain("OpenAI/Codex MiniMax StepFun"); - expect(wrappedTabs).toContain("OpenCode Go [Command Code]"); - expect(wrappedTabs).toContain("OpenRouter"); + // 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]"); - // Provider navigation uses Tab/Shift-Tab per the new mapping. - c.handleInput("\u001b[Z"); // Shift-Tab - let out = c.render(120).join("\n"); - expect(out).toContain("[OpenCode Go]"); - expect(out).toContain("Credits: $12.50"); + // 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]"); - c.handleInput("\t"); // Tab - out = c.render(120).join("\n"); - expect(out).toContain("[Command Code]"); + // 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]"); }); it("aligns quota bars by shared label width across available windows", () => { @@ -386,11 +432,15 @@ describe("dashboard render", () => { 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.startsWith("5h") && l.includes("[")); + const line5h = lines.find( + (l) => l.includes("5h") && l.includes("% left") && l.includes("["), + ); const lineWeekly = lines.find( - (l) => l.startsWith("Weekly") && l.includes("["), + (l) => l.includes("Weekly") && l.includes("% left") && l.includes("["), ); expect(line5h).toBeDefined(); @@ -419,6 +469,8 @@ describe("dashboard render", () => { 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 @@ -449,6 +501,8 @@ describe("dashboard render", () => { 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)"); @@ -469,6 +523,8 @@ describe("dashboard render", () => { 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"))); @@ -487,6 +543,8 @@ describe("dashboard render", () => { 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)"); @@ -518,11 +576,15 @@ describe("dashboard render", () => { 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.startsWith("5h") && l.includes("[")); + const line5h = lines.find( + (l) => l.includes("5h") && l.includes("% left") && l.includes("["), + ); const lineDaily = lines.find( - (l) => l.startsWith("Daily") && l.includes("["), + (l) => l.includes("Daily") && l.includes("% left") && l.includes("["), ); const lineLong = lines.find( (l) => l.includes("VeryLongName") && l.includes("Not applicable"), @@ -533,15 +595,15 @@ describe("dashboard render", () => { expect(lineLong).toBeDefined(); // Unavailable window has no bar or percentage - expect(lineLong).not.toContain("["); expect(lineLong).not.toContain("% left"); + expect(lineLong).not.toContain("["); // 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 (12 chars) + // "5h" is padded to "Daily" width (5 chars), not "VeryLongName" width expect(line5h).toMatch(/5h\s+:/); }); @@ -559,6 +621,8 @@ describe("dashboard render", () => { 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"); @@ -567,13 +631,29 @@ describe("dashboard render", () => { expect(out).not.toContain(" requests"); }); - it("uses enter/space for expand, v for insights, and left/right for period changes", () => { + 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]"); + }); + + it("uses enter/space for expand and left/right for period changes in Statistics tab", () => { const c = new UsageDashboardComponent(mkState(), () => undefined, { theme: noTheme, }); - c.handleInput("v"); - expect(c.render(100).join("\n")).toContain("Insights"); - c.handleInput("v"); // Enter expands the selected provider row to reveal its model rows. c.handleInput("\r"); @@ -593,7 +673,7 @@ describe("dashboard render", () => { expect(c.render(120).join("\n")).toContain("openai-codex"); }); - it("renders insights grouped by category", () => { + it("renders insights grouped by category in Insights tab", () => { const state = mkState(); state.insights = [ { category: "project", label: "career-ops", cost: 9, detail: "90.0%" }, @@ -608,7 +688,9 @@ describe("dashboard render", () => { const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); - c.handleInput("v"); + // 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"); @@ -631,7 +713,9 @@ describe("dashboard render", () => { const c = new UsageDashboardComponent(state, () => undefined, { theme: noTheme, }); - c.handleInput("v"); + // 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:"); @@ -659,7 +743,7 @@ describe("dashboard render", () => { }); describe("dashboard themed styling", () => { - it("wraps section titles, borders, and dimmed helpers with ANSI escape codes", () => { + it("renders frame borders and tab bar with themed styling", () => { const theme = makeAnsiTheme(); const c = new UsageDashboardComponent(mkState(), () => undefined, { theme, @@ -667,46 +751,33 @@ describe("dashboard themed styling", () => { const lines = c.render(140); const out = lines.join("\n"); - // Top and bottom borders should be present with box-drawing characters. - expect(out).toContain("╭"); - expect(out).toContain("╯"); + // Frame uses ┏ and ┛ (from overlay-render frame glyphs) + expect(out).toContain("\u250F"); // ┏ + expect(out).toContain("\u251B"); // ┛ - // Section titles should be themed bold + accent. The component composes - // these as `fg("accent", bold(text))`, so the inner `bold` call receives - // the plain title string and the outer `fg` call receives the bold - // wrapped output. + // Tab bar active pill uses inverse+bold for Usage Statistics expect( theme.calls.some( - (c) => c.method === "bold" && c.text === "Usage Statistics", + (c) => c.method === "bold" && c.text.includes("Usage Statistics"), ), ).toBe(true); expect( theme.calls.some( - (c) => - c.method === "fg" && - c.color === "accent" && - c.text.includes("Usage Statistics"), + (c) => c.method === "inverse" && c.text.includes("Usage Statistics"), ), ).toBe(true); - // The current usage header should be followed by a separator line styled - // with the muted-border color. - const usageIndex = lines.findIndex((line) => line.includes("Current Usage")); - expect(usageIndex).toBeGreaterThan(-1); - const separator = lines[usageIndex + 1] ?? ""; - expect(separator).toContain("├"); - expect(separator).toContain("┤"); - - // The footer should be dimmed. - expect(out).toContain("[Tab/Shift-Tab] Provider"); + // 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] Provider"), + (c) => + c.method === "dim" && c.text.includes("[Tab/Shift-Tab] Switch tab"), ), ).toBe(true); }); - it("highlights the selected disclosure arrow + provider label and dims the rest", () => { + it("highlights the selected disclosure arrow and dims the rest", () => { const theme = makeAnsiTheme(); const c = new UsageDashboardComponent(mkState(), () => undefined, { theme, @@ -714,16 +785,14 @@ describe("dashboard themed styling", () => { const lines = c.render(140); const providerLine = lines.find( - (l) => l.includes("openai-codex") && l.includes("▸"), + (l) => l.includes("openai-codex") && l.includes("\u25B8"), ); expect(providerLine).toBeDefined(); const plain = stripAnsi(providerLine ?? ""); - // No leading `>` cursor anywhere on the line. + // Line starts with frame border ┃, never with > expect(plain.startsWith(">")).toBe(false); - // The selected disclosure arrow + label is rendered, the row carries the - // arrow and provider key. - expect(plain).toContain("▸"); + expect(plain).toContain("\u25B8"); // ▸ expect(plain).toContain("openai-codex"); expect( theme.calls.some( @@ -735,24 +804,20 @@ describe("dashboard themed styling", () => { ).toBe(true); }); - it("dims the inactive provider tabs in Current Usage", () => { + it("renders inactive main tabs with bg styling", () => { const theme = makeAnsiTheme(); const c = new UsageDashboardComponent(mkState(), () => undefined, { theme, }); c.render(140); - const dimmedLabels = theme.calls - .filter((c) => c.method === "dim") + // Inactive tabs use bg("selectedBg", fg("accent", label)) + const bgCalls = theme.calls + .filter((c) => c.method === "bg") .map((c) => c.text); - // Inactive tab labels should appear in the dim list. - expect(dimmedLabels).toContain("OpenAI/Codex"); - expect(dimmedLabels).toContain("MiniMax"); - expect(dimmedLabels).toContain("StepFun"); - expect(dimmedLabels).toContain("OpenCode Go"); - expect(dimmedLabels).toContain("OpenRouter"); - // The active tab is bold + accent, NOT dim. - expect(dimmedLabels).not.toContain("Command Code"); + // 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); }); it("splits CacheR and CacheW columns in wide layouts", () => { @@ -805,18 +870,25 @@ describe("dashboard themed styling", () => { ]); 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).startsWith("5h") && l.includes("[")); + const line5h = lines.find( + (l) => + stripAnsi(l).includes("5h") && l.includes("[") && l.includes("% left"), + ); const lineWeekly = lines.find( - (l) => stripAnsi(l).startsWith("Weekly") && l.includes("["), + (l) => + stripAnsi(l).includes("Weekly") && + l.includes("[") && + l.includes("% left"), ); expect(line5h).toBeDefined(); expect(lineWeekly).toBeDefined(); - // The opening bracket (after padding) is what we align on, not the - // visual label start. Find the first `[` in each line and ensure the - // visible-width columns match. + // 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 ?? "")); }); @@ -826,11 +898,14 @@ describe("dashboard themed styling", () => { 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("█"), + (c) => + c.method === "fg" && c.color === "accent" && c.text.includes("\u2588"), ); expect(filledAccent).toBeDefined(); @@ -841,49 +916,64 @@ describe("dashboard themed styling", () => { expect(percentAccent).toBeDefined(); }); - it("dims the formula legend, reset text, and ratio text", () => { + 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, }); - c.render(140); - const dimmed = theme.calls.filter((c) => c.method === "dim").map((c) => c.text); - // Legend segments are wrapped via `dim` for each segment. + // 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"); - // Reset text gets dimmed. + + // 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( - dimmed.some((text) => text.startsWith("(resets ") || text.includes("reset unavailable")), + allDimmed.some( + (text) => + text.startsWith("(resets ") || text.includes("reset unavailable"), + ), ).toBe(true); - // Ratio is dimmed. - expect(dimmed).toContain("$4.29/$10.00"); + expect(allDimmed).toContain("$4.29/$10.00"); }); - it("renders the footer in [Shortcut] Action format with dimmed styling", () => { - const theme = makeAnsiTheme(); + it("renders context-aware footer per tab", () => { const c = new UsageDashboardComponent(mkState(), () => undefined, { - theme, + theme: noTheme, }); - const out = c.render(160).join("\n"); - const stripped = stripAnsi(out); - // Each shortcut is wrapped in []. - expect(stripped).toContain("[Tab/Shift-Tab] Provider"); + // 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/Space] Expand/Collapse"); - expect(stripped).toContain("[v] Insights"); + expect(stripped).toContain("[Enter] Expand"); expect(stripped).toContain("[q/Esc] Close"); - // The full footer is a single string passed to `dim` (one call), not six. - const footerDimCall = theme.calls.find( - (c) => - c.method === "dim" && - c.text.includes("[Tab/Shift-Tab] Provider") && - c.text.includes("[q/Esc] Close"), - ); - expect(footerDimCall).toBeDefined(); + // 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"); }); it("strips ANSI before applying final truncation so visible width is preserved", () => { @@ -891,8 +981,8 @@ describe("dashboard themed styling", () => { 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. + // 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; @@ -949,7 +1039,7 @@ describe("dashboard repaint subscription", () => { }); describe("dashboard responsive layout", () => { - it("renders at very narrow widths without breaking the table", () => { + it("renders at very narrow widths without breaking the frame", () => { const c = new UsageDashboardComponent(mkState(), () => undefined, { theme: noTheme, }); @@ -958,8 +1048,8 @@ describe("dashboard responsive layout", () => { const visible = line.replace(ANSI_PATTERN, "").length; expect(visible).toBeLessThanOrEqual(30); } - expect(lines.join("\n")).toContain("Usage Statistics"); - expect(lines.join("\n")).toContain("Current Usage"); + // Frame top-left corner visible at any width + expect(lines[0]).toContain("\u250F"); // ┏ }); it("falls back to a minimal two-column table at the smallest breakpoint", () => {