From b5fb1d73b3329350cb979ef5c528adaf3b62bb7b Mon Sep 17 00:00:00 2001 From: alexey Date: Tue, 3 Nov 2026 18:43:35 +0200 Subject: [PATCH] feat(tui): add calendar pace indicators and align sidebar rows Compare usage with elapsed calendar time for each window. Show reserve, deficit, or on pace within five percentage points. Use fixed columns and a ten-cell bar with an inline track span. Add pace, format, and renderer tests. --- README.md | 34 ++++- src/format.ts | 4 +- src/pace.ts | 112 ++++++++++++++++ tests/format.test.ts | 48 +++++++ tests/pace.test.ts | 243 +++++++++++++++++++++++++++++++++ tests/render.test.tsx | 306 ++++++++++++++++++++++++++++++++++++++++++ tui.tsx | 61 +++++++-- 7 files changed, 792 insertions(+), 16 deletions(-) create mode 100644 src/pace.ts create mode 100644 tests/format.test.ts create mode 100644 tests/pace.test.ts create mode 100644 tests/render.test.tsx diff --git a/README.md b/README.md index b44329c..b3dd5ec 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,37 @@ shows, read from the official usage endpoint. ``` Go usage -5h ──────────────── 0% ↻ 4h 56m -7d ──────────────── 3% ↻ 6d 4h -30d ━━────────────── 10% ↻ 25d 16h +5h ━───────── 8% +10pp ↻ 4h 6m +7d ━━━━────── 42% on pace ↻ 4d 4h +1mo ━───────── 13% on pace ↻ 25d 16h ``` -One line per window: label, bar, percentage and time until the window resets. -Bars turn yellow from 75% and red from 90%. +One line per window: label, bar, percentage, pace and time until the window +resets. Columns are fixed width so the rows stay aligned: 3 cells for the +label, 10 for the bar, 4 for the percentage (100% is the widest) and 7 for the +pace indicator. Bars turn yellow from 75% and red from 90%. + +### Pace + +The pace indicator compares the used percentage with the share of the window +that has already elapsed. Rolling is exactly 5 hours and weekly is exactly +7 days. The monthly window follows the subscription anniversary: it starts one +UTC calendar month before `resetsAt`, so it lasts 28 to 31 days. The start keeps +the UTC time fields of `resetsAt` and clamps the day to the last valid day of the +previous month: 31 March starts on 28 February (29 in leap years), and 31 May starts on 30 April. +Calendar time counts in full, including Saturdays and Sundays. + +Deviations are rounded to whole points before the bucket is chosen: a rounded +magnitude of 5 or less is on pace, and anything above that is a reserve or a +deficit based on the sign of the original deviation. + +| Label | Meaning | Color | +| --- | --- | --- | +| `+12pp` | Reserve: usage is 12 points below the elapsed share | green | +| `on pace` | Rounded difference is at most 5 points | cyan | +| `-12pp` | Deficit: usage is 12 points above the elapsed share | yellow | + +A missing or invalid reset timestamp hides the pace label. ## Requirements diff --git a/src/format.ts b/src/format.ts index 6127c61..65c1696 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,7 +1,7 @@ import { clampPercent } from "./usage.ts" -/** Width of the drawing area in characters. */ -export const BAR_WIDTH = 16 +/** Width of the drawing area in characters. Fixed so rows stay aligned. */ +export const BAR_WIDTH = 10 const FILLED = "\u2501" // ━ const EMPTY = "\u2500" // ─ diff --git a/src/pace.ts b/src/pace.ts new file mode 100644 index 0000000..5f55c71 --- /dev/null +++ b/src/pace.ts @@ -0,0 +1,112 @@ +/** + * Pace indicator: compare used percentage against the share of the window that + * has already elapsed. + * + * Rolling is exactly 5 hours and weekly is exactly 7 days. Monthly follows the + * subscription anniversary, so its length is one UTC calendar month (28 to 31 + * days). The monthly start is the reset timestamp minus one UTC calendar month, + * with the day clamped to the last valid day of the previous month. Only UTC + * fields are used, so local time and daylight saving transitions do not shift + * the result. + * + * Positive values mean a reserve (used less than the elapsed share), negative + * values mean a deficit (used more). Every elapsed second counts, including + * Saturdays and Sundays. + */ +import type { UsageWindowKind } from "./usage.ts" + +/** Percentage points either way before the indicator leaves "on pace". */ +export const PACE_TOLERANCE = 5 + +const ROLLING_WINDOW_MS = 5 * 3_600_000 +const WEEKLY_WINDOW_MS = 7 * 86_400_000 + +export type Pace = + | { kind: "reserve"; delta: number } + | { kind: "deficit"; delta: number } + | { kind: "on-pace" } + +type PaceInput = { + /** Used percentage reported by the API. */ + usedPercent: number + /** ISO timestamp of the window reset. */ + resetsAt: string | undefined + /** Length policy of the window: fixed 5h, fixed 7d, or one UTC calendar month. */ + windowKind: UsageWindowKind + /** Current time in epoch milliseconds. */ + now: number +} + +/** + * Start of the monthly window, one UTC calendar month before `resetsMs`. + * + * The day is clamped to the last valid day of that month, so 31 March starts on + * 28 February (29 February in a leap year) and 31 May starts on 30 April. Hours, + * minutes, seconds and milliseconds are preserved. + */ +function monthlyStartMs(resetsMs: number): number { + const resets = new Date(resetsMs) + const year = resets.getUTCFullYear() + const month = resets.getUTCMonth() + const day = resets.getUTCDate() + const lastDayOfPreviousMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() + + const start = new Date(resetsMs) + // Move through the first of the month so the current day cannot overflow. + start.setUTCDate(1) + start.setUTCMonth(month - 1) + start.setUTCDate(Math.min(day, lastDayOfPreviousMonth)) + + return start.getTime() +} + +/** Start of the window that ends at `resetsMs`, in epoch milliseconds. */ +function windowStartMs(resetsMs: number, kind: UsageWindowKind): number { + if (kind === "rolling") return resetsMs - ROLLING_WINDOW_MS + if (kind === "weekly") return resetsMs - WEEKLY_WINDOW_MS + + return monthlyStartMs(resetsMs) +} + +/** Length of the window that ends at `resetsMs`, in milliseconds. */ +function windowMsOf(resetsMs: number, kind: UsageWindowKind): number { + return resetsMs - windowStartMs(resetsMs, kind) +} + +/** Expected share of the budget already spent at `now`, in percent. */ +export function expectedPercent(input: Omit): number | undefined { + if (input.resetsAt === undefined) return undefined + + const resets = Date.parse(input.resetsAt) + if (!Number.isFinite(resets)) return undefined + + const windowMs = windowMsOf(resets, input.windowKind) + if (!(windowMs > 0)) return undefined + + const start = resets - windowMs + const elapsed = Math.min(windowMs, Math.max(0, input.now - start)) + + return (elapsed / windowMs) * 100 +} + +export function paceOf(input: PaceInput): Pace | undefined { + const expected = expectedPercent(input) + if (expected === undefined) return undefined + + const delta = input.usedPercent - expected + // Round the magnitude, not the signed value: -5.5 and +5.5 must both leave + // the on-pace band with an absolute deviation of 6 points. + const magnitude = Math.round(Math.abs(delta)) + + if (magnitude <= PACE_TOLERANCE) return { kind: "on-pace" } + + return delta < 0 ? { kind: "reserve", delta: magnitude } : { kind: "deficit", delta: magnitude } +} + +/** Fixed-width label: `+12pp`, `-3pp`, `on pace`. */ +export function formatPace(pace: Pace | undefined): string { + if (pace === undefined) return "" + if (pace.kind === "on-pace") return "on pace" + + return `${pace.kind === "reserve" ? "+" : "-"}${pace.delta}pp` +} diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..e5ecd03 --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" + +import { BAR_WIDTH, bar, remaining, severity } from "../src/format.ts" + +describe("characterization: severity thresholds stay unchanged", () => { + test("maps usage onto ok, warn and danger buckets", () => { + expect(severity(0)).toBe("ok") + expect(severity(74)).toBe("ok") + expect(severity(75)).toBe("warn") + expect(severity(89)).toBe("warn") + expect(severity(90)).toBe("danger") + expect(severity(100)).toBe("danger") + }) +}) + +describe("characterization: reset countdown stays unchanged", () => { + const now = Date.parse("2026-10-01T00:00:00Z") + + test("formats days, hours, minutes and the boundaries", () => { + expect(remaining(new Date(now + 2 * 86_400_000 + 5 * 3_600_000).toISOString(), now)).toBe("2d 5h") + expect(remaining(new Date(now + 3 * 3_600_000 + 12 * 60_000).toISOString(), now)).toBe("3h 12m") + expect(remaining(new Date(now + 42 * 60_000).toISOString(), now)).toBe("42m") + expect(remaining(new Date(now + 30_000).toISOString(), now)).toBe("<1m") + expect(remaining(new Date(now).toISOString(), now)).toBe("now") + expect(remaining(new Date(now - 1_000).toISOString(), now)).toBe("now") + expect(remaining(undefined, now)).toBe("\u2014") + expect(remaining("not-a-date", now)).toBe("\u2014") + }) +}) + +describe("changed: fixed ten-cell bar", () => { + test("keeps combined filled and empty width at ten cells", () => { + expect(BAR_WIDTH).toBe(10) + + for (const percent of [0, 9, 10, 99, 100]) { + const segments = bar(percent) + expect(segments.filled.length + segments.empty.length).toBe(BAR_WIDTH) + } + }) + + test("rounds the filled cells at the boundary percentages", () => { + expect(bar(0)).toEqual({ filled: "", empty: "\u2500".repeat(10) }) + expect(bar(9).filled).toBe("\u2501") + expect(bar(10).filled).toBe("\u2501") + expect(bar(99).filled).toBe("\u2501".repeat(10)) + expect(bar(100).filled).toBe("\u2501".repeat(10)) + }) +}) diff --git a/tests/pace.test.ts b/tests/pace.test.ts new file mode 100644 index 0000000..2d8524d --- /dev/null +++ b/tests/pace.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test" + +import { expectedPercent, formatPace, paceOf, PACE_TOLERANCE } from "../src/pace.ts" +import type { UsageWindowKind } from "../src/usage.ts" + +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +/** Saturday 2026-10-31T00:00:00Z. */ +const WEEKEND_START = Date.parse("2026-10-31T00:00:00Z") + +function windowInput(windowKind: UsageWindowKind, startMs: number, lengthMs: number) { + return { windowKind, resetsAt: new Date(startMs + lengthMs).toISOString() } +} + +describe("calendar expected share", () => { + test("advances linearly for rolling, weekly and monthly windows", () => { + const windows = [ + { windowKind: "rolling" as const, start: Date.parse("2026-11-15T13:12:24.000Z"), length: 5 * HOUR_MS }, + { windowKind: "weekly" as const, start: Date.parse("2026-11-08T18:12:24.000Z"), length: 7 * DAY_MS }, + { windowKind: "monthly" as const, start: Date.parse("2026-10-15T18:12:24.000Z"), length: 31 * DAY_MS }, + ] + + for (const window of windows) { + const input = windowInput(window.windowKind, window.start, window.length) + + expect(expectedPercent({ ...input, now: window.start })).toBe(0) + expect(expectedPercent({ ...input, now: window.start + window.length / 4 })).toBeCloseTo(25, 10) + expect(expectedPercent({ ...input, now: window.start + window.length / 2 })).toBeCloseTo(50, 10) + expect(expectedPercent({ ...input, now: window.start + window.length })).toBe(100) + } + }) + + test("uses a real UTC calendar month of 28, 29, 30 or 31 days", () => { + const months = [ + { name: "31-day January", resetsAt: "2026-02-15T18:12:24.000Z", start: "2026-01-15T18:12:24.000Z", days: 31 }, + { name: "28-day February", resetsAt: "2026-03-15T18:12:24.000Z", start: "2026-02-15T18:12:24.000Z", days: 28 }, + { name: "29-day February", resetsAt: "2028-03-15T18:12:24.000Z", start: "2028-02-15T18:12:24.000Z", days: 29 }, + { name: "30-day April", resetsAt: "2026-05-15T18:12:24.000Z", start: "2026-04-15T18:12:24.000Z", days: 30 }, + ] + + for (const month of months) { + const start = Date.parse(month.start) + const resets = Date.parse(month.resetsAt) + expect(resets - start, month.name).toBe(month.days * DAY_MS) + + expect(expectedPercent({ windowKind: "monthly", resetsAt: month.resetsAt, now: start })).toBe(0) + expect( + expectedPercent({ windowKind: "monthly", resetsAt: month.resetsAt, now: start + (resets - start) / 2 }), + month.name, + ).toBeCloseTo(50, 10) + expect(expectedPercent({ windowKind: "monthly", resetsAt: month.resetsAt, now: resets })).toBe(100) + } + }) + + test("clamps the start day to the last valid day of the previous month", () => { + const cases = [ + { resetsAt: "2026-03-31T18:12:24.000Z", start: "2026-02-28T18:12:24.000Z" }, + { resetsAt: "2028-03-31T18:12:24.000Z", start: "2028-02-29T18:12:24.000Z" }, + { resetsAt: "2026-03-30T18:12:24.000Z", start: "2026-02-28T18:12:24.000Z" }, + { resetsAt: "2026-03-29T18:12:24.000Z", start: "2026-02-28T18:12:24.000Z" }, + { resetsAt: "2026-05-31T18:12:24.000Z", start: "2026-04-30T18:12:24.000Z" }, + { resetsAt: "2026-01-31T18:12:24.000Z", start: "2025-12-31T18:12:24.000Z" }, + ] + + for (const entry of cases) { + const start = Date.parse(entry.start) + const resets = Date.parse(entry.resetsAt) + expect(expectedPercent({ windowKind: "monthly", resetsAt: entry.resetsAt, now: start }), entry.resetsAt).toBe(0) + expect( + expectedPercent({ windowKind: "monthly", resetsAt: entry.resetsAt, now: start + (resets - start) / 2 }), + entry.resetsAt, + ).toBeCloseTo(50, 10) + } + }) + + test("crosses the year boundary and preserves the reset UTC time", () => { + const resetsAt = "2026-01-15T18:12:24.500Z" + const start = Date.parse("2025-12-15T18:12:24.500Z") + const resets = Date.parse(resetsAt) + + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start })).toBe(0) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start + (resets - start) / 2 })).toBeCloseTo(50, 10) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: resets })).toBe(100) + }) + + test("preserves reset milliseconds instead of truncating to seconds", () => { + const resetsAt = "2026-11-15T18:12:24.789Z" + const start = Date.parse("2026-10-15T18:12:24.789Z") + const resets = Date.parse(resetsAt) + + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start })).toBe(0) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start + (resets - start) / 2 })).toBeCloseTo(50, 10) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: resets - 1 })).toBeCloseTo(100, 6) + }) + + test("does not depend on local time or daylight saving transitions", () => { + // This window crosses the US spring DST switch on 2026-03-08. A local-time + // implementation would shift the start by an hour and miss the exact values. + const resetsAt = "2026-03-15T18:12:24.000Z" + const start = Date.parse("2026-02-15T18:12:24.000Z") + const resets = Date.parse(resetsAt) + + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start })).toBe(0) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start + (resets - start) / 2 })).toBeCloseTo(50, 10) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: resets })).toBe(100) + }) + + test("counts Saturdays and Sundays as elapsed time", () => { + // A 7d window starts on Saturday. One day later, Sunday has already used 1/7 of the budget. + const resetsAt = new Date(WEEKEND_START + 7 * DAY_MS).toISOString() + expect( + expectedPercent({ + windowKind: "weekly", + resetsAt, + now: WEEKEND_START + DAY_MS, + }), + ).toBeCloseTo(100 / 7, 10) + + // A 5h window that starts on Friday night and ends on Saturday has no weekend pause. + const friday = Date.parse("2026-10-30T22:00:00Z") + expect( + expectedPercent({ + windowKind: "rolling", + resetsAt: new Date(friday + 5 * HOUR_MS).toISOString(), + now: friday + 3 * HOUR_MS, + }), + ).toBeCloseTo(60, 10) + }) + + test("clamps the elapsed share to the window boundaries", () => { + const start = Date.parse("2026-10-01T00:00:00Z") + const resetsAt = new Date(start + 5 * HOUR_MS).toISOString() + + expect(expectedPercent({ windowKind: "rolling", resetsAt, now: start - HOUR_MS })).toBe(0) + expect(expectedPercent({ windowKind: "rolling", resetsAt, now: start + 4 * HOUR_MS })).toBeCloseTo(80, 10) + expect(expectedPercent({ windowKind: "rolling", resetsAt, now: start + 5 * HOUR_MS })).toBe(100) + expect(expectedPercent({ windowKind: "rolling", resetsAt, now: start + 6 * HOUR_MS })).toBe(100) + }) + + test("omits the share when the reset is missing or invalid", () => { + const input = { windowKind: "rolling" as const, now: WEEKEND_START } + + expect(expectedPercent({ ...input, resetsAt: undefined })).toBeUndefined() + expect(expectedPercent({ ...input, resetsAt: "" })).toBeUndefined() + expect(expectedPercent({ ...input, resetsAt: "not-a-date" })).toBeUndefined() + expect( + expectedPercent({ windowKind: "monthly", resetsAt: "not-a-date", now: WEEKEND_START }), + ).toBeUndefined() + }) +}) + +describe("monthly pace alignment", () => { + // 2026-10-15T18:12:24Z to 2026-11-15T18:12:24Z is a 31-day calendar month. + const resetsAt = "2026-11-15T18:12:24.000Z" + const start = Date.parse("2026-10-15T18:12:24.000Z") + const resets = Date.parse(resetsAt) + const midpoint = start + (resets - start) / 2 + + test("measures the expected share from the derived calendar start", () => { + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: midpoint })).toBeCloseTo(50, 10) + + expect(paceOf({ windowKind: "monthly", resetsAt, now: midpoint, usedPercent: 50 })).toEqual({ kind: "on-pace" }) + expect(paceOf({ windowKind: "monthly", resetsAt, now: midpoint, usedPercent: 44.5 })).toEqual({ + kind: "reserve", + delta: 6, + }) + expect(paceOf({ windowKind: "monthly", resetsAt, now: midpoint, usedPercent: 55.5 })).toEqual({ + kind: "deficit", + delta: 6, + }) + }) + + test("handles the boundaries before and after the derived start", () => { + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start - 1 })).toBe(0) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: start })).toBe(0) + expect(expectedPercent({ windowKind: "monthly", resetsAt, now: resets + 1 })).toBe(100) + expect(paceOf({ windowKind: "monthly", resetsAt, now: start - 1, usedPercent: 0 })).toEqual({ kind: "on-pace" }) + }) + + test("does not reuse a fixed thirty-day window", () => { + // February 2026 is a 28-day month. At its start a fixed 30-day window would + // already report 6.67% elapsed, so 1% used would show a +6pp reserve. + const februaryReset = "2026-03-15T18:12:24.000Z" + const februaryStart = Date.parse("2026-02-15T18:12:24.000Z") + + expect(expectedPercent({ windowKind: "monthly", resetsAt: februaryReset, now: februaryStart })).toBe(0) + expect(paceOf({ windowKind: "monthly", resetsAt: februaryReset, now: februaryStart, usedPercent: 1 })).toEqual({ + kind: "on-pace", + }) + }) +}) + +describe("pace tolerance", () => { + const start = Date.parse("2026-10-01T00:00:00Z") + const resetsAt = new Date(start + 5 * HOUR_MS).toISOString() + // Half of the window has elapsed, so the expected share is exactly 50%. + const now = start + 2.5 * HOUR_MS + const input = { resetsAt, windowKind: "rolling" as const, now } + + test("stays on pace within five percentage points, boundary included", () => { + const inside = [50, 50 - PACE_TOLERANCE, 50 + PACE_TOLERANCE, 49.8, 50.2] + + for (const usedPercent of inside) { + expect(paceOf({ ...input, usedPercent })).toEqual({ kind: "on-pace" }) + } + }) + + test("rounds the absolute deviation before choosing the bucket", () => { + // 5.4 points rounds to 5 and stays on pace on both sides of the expected share. + expect(paceOf({ ...input, usedPercent: 44.6 })).toEqual({ kind: "on-pace" }) + expect(paceOf({ ...input, usedPercent: 55.4 })).toEqual({ kind: "on-pace" }) + + // 5.5 points rounds to 6 and leaves the band symmetrically. + expect(paceOf({ ...input, usedPercent: 44.5 })).toEqual({ kind: "reserve", delta: 6 }) + expect(paceOf({ ...input, usedPercent: 55.5 })).toEqual({ kind: "deficit", delta: 6 }) + }) + + test("reports reserve when usage is below the expected share", () => { + expect(paceOf({ ...input, usedPercent: 44 })).toEqual({ kind: "reserve", delta: 6 }) + expect(paceOf({ ...input, usedPercent: 43.6 })).toEqual({ kind: "reserve", delta: 6 }) + expect(paceOf({ ...input, usedPercent: 0 })).toEqual({ kind: "reserve", delta: 50 }) + }) + + test("reports deficit when usage is above the expected share", () => { + expect(paceOf({ ...input, usedPercent: 56 })).toEqual({ kind: "deficit", delta: 6 }) + expect(paceOf({ ...input, usedPercent: 100 })).toEqual({ kind: "deficit", delta: 50 }) + }) + + test("omits pace when the reset is invalid", () => { + expect(paceOf({ ...input, resetsAt: undefined, usedPercent: 40 })).toBeUndefined() + expect(paceOf({ ...input, resetsAt: "not-a-date", usedPercent: 40 })).toBeUndefined() + }) +}) + +describe("pace labels", () => { + test("uses a sign for reserve and deficit, and a fixed label for on pace", () => { + expect(formatPace({ kind: "reserve", delta: 12 })).toBe("+12pp") + expect(formatPace({ kind: "deficit", delta: 3 })).toBe("-3pp") + expect(formatPace({ kind: "on-pace" })).toBe("on pace") + expect(formatPace(undefined)).toBe("") + }) +}) diff --git a/tests/render.test.tsx b/tests/render.test.tsx new file mode 100644 index 0000000..2d47daf --- /dev/null +++ b/tests/render.test.tsx @@ -0,0 +1,306 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Renderer checks for the sidebar layout. + * + * The plugin module is imported after `fetchUsage` is mocked, so no test reads + * credentials or calls the usage API. A fake host records the sidebar slot + * claim, and the real OpenTUI test renderer mounts it. + */ +import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import type { Context } from "@opencode/plugin/tui" +import { testRender } from "@opentui/solid" +import * as usageModule from "../src/usage.ts" +import type { UsageResult } from "../src/usage.ts" + +const BASE = Date.parse("2030-01-01T00:00:00Z") +const POLL_MS = 60_000 +const CLOCK_MS = 30_000 + +/** + * A monthly window from 2029-12-16T12:00:00Z to 2030-01-16T12:00:00Z. It is a + * 31-day UTC calendar month, and BASE is exactly its midpoint. + */ +const MONTHLY_RESET = "2030-01-16T12:00:00.000Z" + +let nextResult: UsageResult + +mock.module("../src/usage.ts", () => ({ + ...usageModule, + fetchUsage: async () => nextResult, +})) + +afterAll(() => { + mock.restore() +}) + +const { default: plugin } = await import("../tui.tsx") + +type Claim = { render: (input: { sessionID: string }) => unknown } + +type Scheduled = { + id: ReturnType + run: () => void + ms: number +} + +const schedules: Scheduled[] = [] +const views: Array<{ dispose: () => Promise }> = [] +const cleanups: Array<() => void> = [] +let cleared: Array> = [] + +const realSetInterval = globalThis.setInterval +const realClearInterval = globalThis.clearInterval + +let intervalSpy: ReturnType +let clearSpy: ReturnType +let nowSpy: ReturnType + +const THEME = { + text: { + base: "#FFFFFF", + muted: "#AAAAAA", + feedback: { + success: { base: "#00FF00" }, + warning: { base: "#FFFF00" }, + error: { base: "#FF0000" }, + info: { base: "#00FFFF" }, + }, + }, + border: { base: "#123456" }, +} + +function fakeHost() { + const claims: Claim[] = [] + const context = { + theme: THEME, + ui: { + slot: (claim: Claim) => { + claims.push(claim) + return () => {} + }, + }, + } as unknown as Context + + return { context, claims } +} + +function usage( + percent: { rolling: number; weekly: number; monthly: number }, + withResets = true, +): UsageResult { + const half = (seconds: number) => new Date(BASE + (seconds * 1000) / 2).toISOString() + + return { + ok: true, + usage: { + rolling: { percent: percent.rolling, resetsAt: withResets ? half(5 * 3_600) : undefined }, + weekly: { percent: percent.weekly, resetsAt: withResets ? half(7 * 86_400) : undefined }, + monthly: { percent: percent.monthly, resetsAt: withResets ? MONTHLY_RESET : undefined }, + }, + } +} + +async function mount(result: UsageResult) { + nextResult = result + const host = fakeHost() + const cleanup = (await plugin.setup(host.context)) as (() => void) | undefined + // Register the setup cleanup before any assertion, so a failing mount still + // stops the setup-owned polling and releases the sidebar slot. + if (cleanup) cleanups.push(cleanup) + + const claim = host.claims[0] + if (!claim) throw new Error("plugin did not claim the sidebar slot") + + const view = await testRender(() => claim.render({ sessionID: "session" }) as any, { + width: 80, + height: 10, + }) + views.push(view) + await settle() + await view.flush() + + return { view } +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function row(frame: string, label: string): string { + const line = frame.split("\n").find((entry) => entry.startsWith(`${label} `)) + if (line === undefined) throw new Error(`missing row ${label} in:\n${frame}`) + return line +} + +function fillCount(line: string): number { + return [...line.slice(4, 14)].filter((cell) => cell === "\u2501").length +} + +beforeEach(() => { + schedules.length = 0 + cleared = [] + nextResult = usage({ rolling: 0, weekly: 0, monthly: 0 }) + nowSpy = spyOn(Date, "now").mockReturnValue(BASE) + intervalSpy = spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, ms?: number) => { + const id = realSetInterval(callback, ms) + schedules.push({ id, run: callback, ms: Number(ms) }) + return id + }) as typeof setInterval) + clearSpy = spyOn(globalThis, "clearInterval").mockImplementation(((id: ReturnType) => { + cleared.push(id) + realClearInterval(id) + }) as typeof clearInterval) +}) + +afterEach(async () => { + try { + // Stop setup-owned polling before the views go away. + for (const cleanup of cleanups.splice(0)) cleanup() + } finally { + try { + for (const view of views.splice(0)) await view.dispose() + } finally { + const leaked = schedules.filter((entry) => !cleared.includes(entry.id)) + for (const entry of schedules) realClearInterval(entry.id) + intervalSpy.mockRestore() + clearSpy.mockRestore() + nowSpy.mockRestore() + + if (leaked.length > 0) { + throw new Error(`setup cleanup left ${leaked.length} interval(s) running`) + } + } + } +}) + +describe("fixed sidebar columns", () => { + test("aligns label, bar, percent, pace and countdown at boundary percentages", async () => { + const first = await mount(usage({ rolling: 0, weekly: 9, monthly: 10 })) + const firstFrame = first.view.captureCharFrame() + + const low = [ + row(firstFrame, "5h"), + row(firstFrame, "7d"), + row(firstFrame, "1mo"), + ] + + expect(low[0]!.slice(0, 4)).toBe("5h ") + expect(low[1]!.slice(0, 4)).toBe("7d ") + expect(low[2]!.slice(0, 4)).toBe("1mo ") + + for (const line of low) { + expect(line.slice(4, 14)).toHaveLength(10) + expect(line.charAt(14)).toBe(" ") + expect(line.slice(20, 27)).toHaveLength(7) + expect(line.charAt(29)).toBe("\u21bb") + } + + expect(low[0]!.slice(14, 19)).toBe(" 0%") + expect(low[1]!.slice(14, 19)).toBe(" 9%") + expect(low[2]!.slice(14, 19)).toBe(" 10%") + + expect(fillCount(low[0]!)).toBe(0) + expect(fillCount(low[1]!)).toBe(1) + expect(fillCount(low[2]!)).toBe(1) + + const second = await mount(usage({ rolling: 99, weekly: 100, monthly: 42 })) + const secondFrame = second.view.captureCharFrame() + + const high = [ + row(secondFrame, "5h"), + row(secondFrame, "7d"), + row(secondFrame, "1mo"), + ] + + expect(high[0]!.slice(14, 19)).toBe(" 99%") + expect(high[1]!.slice(14, 19)).toBe(" 100%") + expect(high[2]!.slice(14, 19)).toBe(" 42%") + + expect(high[0]!.slice(20, 27)).toBe("-49pp ") + expect(high[1]!.slice(20, 27)).toBe("-50pp ") + expect(high[2]!.slice(20, 27)).toBe("+8pp ") + + expect(fillCount(high[0]!)).toBe(10) + expect(fillCount(high[1]!)).toBe(10) + expect(fillCount(high[2]!)).toBe(4) + + for (const line of high) { + expect(line.slice(4, 14)).toHaveLength(10) + expect(line.charAt(14)).toBe(" ") + expect(line.slice(20, 27)).toHaveLength(7) + expect(line.charAt(29)).toBe("\u21bb") + } + }) + + test("renders labels, bars, on-pace labels and reset countdowns", async () => { + const { view } = await mount(usage({ rolling: 50, weekly: 50, monthly: 50 })) + const frame = view.captureCharFrame() + + expect(row(frame, "5h")).toContain("5h \u2501\u2501\u2501\u2501\u2501\u2500\u2500\u2500\u2500\u2500 50% on pace") + expect(row(frame, "5h")).toContain("\u21bb 2h 30m") + expect(row(frame, "7d")).toContain("\u21bb 3d 12h") + expect(row(frame, "1mo")).toContain("\u21bb 15d 12h") + + for (const label of ["5h", "7d", "1mo"]) { + expect(fillCount(row(frame, label))).toBe(5) + expect(row(frame, label).slice(20, 27)).toBe("on pace") + } + }) + + test("omits the pace column when a reset is missing", async () => { + const { view } = await mount(usage({ rolling: 42, weekly: 10, monthly: 5 }, false)) + const frame = view.captureCharFrame() + + for (const label of ["5h", "7d", "1mo"]) { + expect(row(frame, label).slice(20, 27)).toBe(" ") + expect(row(frame, label)).toContain("\u21bb \u2014") + } + }) + + test("updates pace when the clock signal advances", async () => { + const { view } = await mount(usage({ rolling: 50, weekly: 50, monthly: 50 })) + expect(row(view.captureCharFrame(), "5h").slice(20, 27)).toBe("on pace") + + const clock = schedules.find((entry) => entry.ms === CLOCK_MS) + if (!clock) throw new Error("panel did not schedule the clock") + + // 30 minutes later in a 5h window: the expected share grows from 50% to 60%. + nowSpy.mockReturnValue(BASE + 30 * 60_000) + clock.run() + await settle() + await view.flush() + + expect(row(view.captureCharFrame(), "5h").slice(20, 27)).toBe("+10pp ") + }) +}) + +describe("characterization: unchanged theme colors", () => { + test("paints the empty bar track with the theme track color", async () => { + const { view } = await mount(usage({ rolling: 0, weekly: 10, monthly: 100 })) + const frame = view.captureSpans() + + const empty = frame.lines + .flatMap((line) => line.spans) + .find((span) => span.text === "\u2500".repeat(10)) + if (!empty) throw new Error("no empty bar span in the rendered frame") + + expect(empty.fg.toInts().slice(0, 3)).toEqual([0x12, 0x34, 0x56]) + + const filled = frame.lines + .flatMap((line) => line.spans) + .find((span) => span.text === "\u2501") + if (!filled) throw new Error("no filled bar span in the rendered frame") + + // 10% stays in the ok bucket, so the existing severity color is unchanged. + expect(filled.fg.toInts().slice(0, 3)).toEqual([0x00, 0xff, 0x00]) + }) +}) + +describe("characterization: unchanged poll schedule", () => { + test("schedules one refresh poll and one clock", async () => { + await mount(usage({ rolling: 1, weekly: 2, monthly: 3 })) + + expect(schedules.filter((entry) => entry.ms === POLL_MS)).toHaveLength(1) + expect(schedules.filter((entry) => entry.ms === CLOCK_MS)).toHaveLength(1) + }) +}) diff --git a/tui.tsx b/tui.tsx index ed8182e..6277927 100644 --- a/tui.tsx +++ b/tui.tsx @@ -12,6 +12,7 @@ import { Plugin } from "@opencode/plugin/tui" import type { RGBA } from "@opentui/core" import { createSignal, Show } from "solid-js" import { bar, remaining, severity, type Severity } from "./src/format.ts" +import { formatPace, paceOf } from "./src/pace.ts" import { createRefresher } from "./src/refresh.ts" import { clampPercent, fetchUsage, type Usage, type UsageWindow, type UsageWindowKind } from "./src/usage.ts" @@ -21,16 +22,22 @@ const CLOCK_MS = 30_000 /** Upper bound for a whole refresh cycle, credential lookup included. */ const REFRESH_TIMEOUT_MS = 15_000 +/** Fixed widths of the row columns, in cells. */ +const LABEL_WIDTH = 3 +const PERCENT_WIDTH = 4 +const PACE_WIDTH = 7 + const ROWS: { key: UsageWindowKind; label: string }[] = [ { key: "rolling", label: "5h" }, { key: "weekly", label: "7d" }, - { key: "monthly", label: "30d" }, + { key: "monthly", label: "1mo" }, ] -const FALLBACK_COLORS: Record = { +const FALLBACK_COLORS: Record = { ok: "#3FB950", warn: "#D29922", danger: "#F85149", + info: "#39C5CF", } type Color = string | RGBA @@ -41,6 +48,7 @@ type Palette = { ok?: Color warn?: Color danger?: Color + info?: Color } /** @@ -58,6 +66,7 @@ function paletteOf(theme: unknown): Palette { ok: (feedback?.success?.base ?? FALLBACK_COLORS.ok) as Color | undefined, warn: (feedback?.warning?.base ?? FALLBACK_COLORS.warn) as Color | undefined, danger: (feedback?.error?.base ?? FALLBACK_COLORS.danger) as Color | undefined, + info: (feedback?.info?.base ?? FALLBACK_COLORS.info) as Color | undefined, } } @@ -65,17 +74,45 @@ function colorFor(palette: Palette, percent: number): Color | undefined { return palette[severity(percent)] } -const Row = (props: { label: string; window: UsageWindow; now: number; palette: Palette }) => { +const Row = (props: { + label: string + window: UsageWindow + windowKind: UsageWindowKind + now: () => number + palette: Palette +}) => { const percent = () => clampPercent(props.window.percent) const segments = () => bar(percent()) + const pace = () => + paceOf({ + usedPercent: props.window.percent, + resetsAt: props.window.resetsAt, + windowKind: props.windowKind, + now: props.now(), + }) + const paceColor = () => { + const current = pace() + if (current === undefined) return props.palette.muted + if (current.kind === "reserve") return props.palette.ok + if (current.kind === "deficit") return props.palette.warn + + return props.palette.info + } return ( - {props.label.padEnd(4)} - {segments().filled} - {segments().empty} - {` ${percent()}%`} - {` \u21bb ${remaining(props.window.resetsAt, props.now)}`} + {`${props.label.padEnd(LABEL_WIDTH)} `} + + {segments().filled} + {segments().empty} + + + {` ${String(percent()).padStart(PERCENT_WIDTH - 1)}%`} + + {` ${formatPace(pace()).padEnd(PACE_WIDTH)}`} + + {` \u21bb ${remaining(props.window.resetsAt, props.now())}`} + ) } @@ -110,7 +147,13 @@ const Panel = (props: { theme: unknown; status: () => Status; now: () => number {(usage) => ( {ROWS.map((row) => ( - + ))} )}