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) => ( - + ))} )}