From aca2b47fe0dae63ba08e8bc207edf719a4bcd205 Mon Sep 17 00:00:00 2001 From: SBakolis Date: Mon, 21 Sep 2026 23:05:21 +0300 Subject: [PATCH 1/3] feat : add reset timers to each meter bar --- README.md | 24 +++++++------- src/tui/compute.ts | 32 +++++++++++++++++- src/tui/quota-bar.tsx | 7 +++- src/tui/sidebar.tsx | 17 ++++++---- test/unit/tui-compute.test.ts | 61 +++++++++++++++++++++++++++++++++-- 5 files changed, 118 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f56bc9f..f84017e 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,18 @@ quota** and **per-session token usage** — without spending a model turn. real time as tokens stream. ```text - ┌─ Codex Meter ────────────────────────────────┐ - │ 5h quota [████████░░░░░░░░░░░░] 37% │ - │ Weekly quota [████████████░░░░░░░░] 62% │ - │ │ - │ openai/gpt-5.5 (5 msgs) │ - │ Input 184,230 │ - │ Output 8,491 │ - │ Reasoning 21,048 │ - │ Cache read 421,120 │ - │ Cache write 0 │ - │ Total 634,889 │ - └───────────────────────────────────────────────┘ + ┌─ Codex Meter ───────────────────────────────────────────┐ + │ 5h quota [████████░░░░░░░░░░░░] 37% resets 2h 14m │ + │ Weekly quota [████████████░░░░░░░░] 62% resets 5d 5h │ + │ │ + │ openai/gpt-5.5 (5 msgs) │ + │ Input 184,230 │ + │ Output 8,491 │ + │ Reasoning 21,048 │ + │ Cache read 421,120 │ + │ Cache write 0 │ + │ Total 634,889 │ + └─────────────────────────────────────────────────────────┘ ``` - **`codex_usage` tool** — ask the agent to call it for a detailed report diff --git a/src/tui/compute.ts b/src/tui/compute.ts index 2f59eef..898600c 100644 --- a/src/tui/compute.ts +++ b/src/tui/compute.ts @@ -8,8 +8,9 @@ * No JSX, no Solid, no side effects — fully unit-testable. */ -import type { QuotaSnapshot } from "../quota/types"; +import type { QuotaSnapshot, UsageWindow } from "../quota/types"; import { type Report, buildReport } from "../report/build"; +import { formatResetDuration } from "../report/detailed"; import { SessionStore } from "../session/aggregate"; import { type SdkMessage, messageToSnapshot } from "../session/opencode-adapter"; @@ -31,3 +32,32 @@ export function computeReport( const usage = store.getSessionUsage(sessionID); return buildReport(sessionID, usage, quota, options); } + +/** + * Live "resets 4h 5m" label for a usage window, or null when no reset + * info is available. Prefers the absolute `resetsAt` timestamp; falls + * back to `fetchedAt + resetAfterSeconds` so a cached snapshot doesn't + * show a stale countdown. Clamped to 0 ("resets now") once past. + */ +export function resetDurationLabel( + window: UsageWindow | null, + fetchedAt: string | null, + nowMs: number, +): string | null { + if (!window) return null; + + let remainingSeconds: number | null = null; + + const resetsAtMs = window.resetsAt !== null ? Date.parse(window.resetsAt) : Number.NaN; + if (!Number.isNaN(resetsAtMs)) { + remainingSeconds = (resetsAtMs - nowMs) / 1000; + } else if (window.resetAfterSeconds !== null) { + const fetchedAtMs = fetchedAt !== null ? Date.parse(fetchedAt) : Number.NaN; + remainingSeconds = Number.isNaN(fetchedAtMs) + ? window.resetAfterSeconds + : window.resetAfterSeconds + (fetchedAtMs - nowMs) / 1000; + } + + if (remainingSeconds === null) return null; + return `resets ${formatResetDuration(Math.max(0, Math.round(remainingSeconds)))}`; +} diff --git a/src/tui/quota-bar.tsx b/src/tui/quota-bar.tsx index 6f6abba..91abcd9 100644 --- a/src/tui/quota-bar.tsx +++ b/src/tui/quota-bar.tsx @@ -1,5 +1,6 @@ /** - * QuotaBar — renders a single quota window as label + progress bar + percentage. + * QuotaBar — renders a single quota window as label + progress bar + + * percentage, plus an optional muted reset-countdown suffix ("resets 4h 5m"). * * Used by for the 5-hour and weekly windows. * When `percent` is null (no data), renders a muted "unavailable" label. @@ -18,6 +19,7 @@ export interface QuotaBarProps { window: UsageWindow | null; colors: ThemeColors; barWidth: number; + reset?: string | null; } export function QuotaBar(props: QuotaBarProps) { @@ -37,6 +39,9 @@ export function QuotaBar(props: QuotaBarProps) { {"░".repeat(props.barWidth - Math.round(((percent() ?? 0) / 100) * props.barWidth))} {` ${percent()}%`} + + {` ${props.reset}`} + ); diff --git a/src/tui/sidebar.tsx b/src/tui/sidebar.tsx index d3a621b..3e950cb 100644 --- a/src/tui/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -3,7 +3,7 @@ * * Composes: * - Title: "Codex Meter" - * - Quota section: 5h and weekly bars + reset info + * - Quota section: 5h and weekly bars with inline reset countdowns * - Token section: per-model table + total * * Handles degraded states: @@ -19,7 +19,7 @@ import { Show, createMemo } from "solid-js"; import type { Report } from "../report/build"; -import { formatResetDuration } from "../report/detailed"; +import { resetDurationLabel } from "./compute"; import { QuotaBar } from "./quota-bar"; import type { ThemeColors } from "./theme"; import { TokenTable } from "./token-table"; @@ -33,6 +33,12 @@ export interface SidebarContentProps { export function SidebarContent(props: SidebarContentProps) { // Reactive: re-evaluates whenever props.report changes. const quota = createMemo(() => props.report?.quota ?? null); + const fiveHourReset = createMemo(() => + resetDurationLabel(quota()?.fiveHour ?? null, quota()?.fetchedAt ?? null, Date.now()), + ); + const weeklyReset = createMemo(() => + resetDurationLabel(quota()?.weekly ?? null, quota()?.fetchedAt ?? null, Date.now()), + ); const showQuota = createMemo(() => { const q = quota(); return ( @@ -72,18 +78,15 @@ export function SidebarContent(props: SidebarContentProps) { window={quota()?.fiveHour ?? null} colors={props.colors} barWidth={14} + reset={fiveHourReset()} /> - - - {` resets ${formatResetDuration(quota()?.fiveHour?.resetAfterSeconds ?? null)}`} - - diff --git a/test/unit/tui-compute.test.ts b/test/unit/tui-compute.test.ts index 50dd01b..3a2a152 100644 --- a/test/unit/tui-compute.test.ts +++ b/test/unit/tui-compute.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { noQuotaSnapshot } from "../../src/quota/types"; +import { type UsageWindow, noQuotaSnapshot } from "../../src/quota/types"; import type { SdkMessage } from "../../src/session/opencode-adapter"; -import { computeReport } from "../../src/tui/compute"; +import { computeReport, resetDurationLabel } from "../../src/tui/compute"; function assistantMsg( id: string, @@ -145,3 +145,60 @@ describe("computeReport", () => { expect(report.models[0]?.input).toBe(100); }); }); + +describe("resetDurationLabel", () => { + const NOW = Date.parse("2026-09-21T12:00:00.000Z"); + + function window(overrides: Partial = {}): UsageWindow { + return { + kind: "five-hour", + usedPercent: 50, + windowSeconds: 18_000, + resetsAt: null, + resetAfterSeconds: null, + ...overrides, + }; + } + + function isoAt(offsetSeconds: number): string { + return new Date(NOW + offsetSeconds * 1000).toISOString(); + } + + it("returns null for a null window", () => { + expect(resetDurationLabel(null, isoAt(0), NOW)).toBeNull(); + }); + + it("returns null when both reset fields are null", () => { + expect(resetDurationLabel(window(), isoAt(0), NOW)).toBeNull(); + }); + + it("computes live remaining time from resetsAt, ignoring resetAfterSeconds", () => { + const w = window({ resetsAt: isoAt(8040), resetAfterSeconds: 60 }); + expect(resetDurationLabel(w, isoAt(0), NOW)).toBe("resets 2h 14m"); + }); + + it("falls back to fetchedAt + resetAfterSeconds when resetsAt is unparseable", () => { + const w = window({ resetsAt: "not-a-date", resetAfterSeconds: 3600 }); + expect(resetDurationLabel(w, isoAt(-300), NOW)).toBe("resets 55m"); + }); + + it("uses raw resetAfterSeconds when fetchedAt is unparseable", () => { + const w = window({ resetAfterSeconds: 3600 }); + expect(resetDurationLabel(w, "garbage", NOW)).toBe("resets 1h 0m"); + }); + + it("uses raw resetAfterSeconds when fetchedAt is null", () => { + const w = window({ resetAfterSeconds: 120 }); + expect(resetDurationLabel(w, null, NOW)).toBe("resets 2m"); + }); + + it("clamps past resets to 'now'", () => { + const w = window({ resetsAt: isoAt(-10) }); + expect(resetDurationLabel(w, isoAt(-60), NOW)).toBe("resets now"); + }); + + it("formats multi-day resets as days and hours", () => { + const w = window({ kind: "weekly", resetsAt: isoAt(5 * 86_400 + 5 * 3600) }); + expect(resetDurationLabel(w, isoAt(0), NOW)).toBe("resets 5d 5h"); + }); +}); From f2a18da2ae8e7974a5f6ccb23297291d0ca44f82 Mon Sep 17 00:00:00 2001 From: SBakolis Date: Mon, 21 Sep 2026 23:09:13 +0300 Subject: [PATCH 2/3] feat : improve ui responsiveness when sidebar width is too small --- src/tui/compute.ts | 20 ++++++++++++++++++++ src/tui/quota-bar.tsx | 22 +++++++++++++++++++++- src/tui/sidebar.tsx | 16 +++++++++++++++- test/unit/tui-compute.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/tui/compute.ts b/src/tui/compute.ts index 898600c..d1aaba5 100644 --- a/src/tui/compute.ts +++ b/src/tui/compute.ts @@ -61,3 +61,23 @@ export function resetDurationLabel( if (remainingSeconds === null) return null; return `resets ${formatResetDuration(Math.max(0, Math.round(remainingSeconds)))}`; } + +export type ResetPlacement = "inline" | "below"; + +/** + * Decide whether a reset label fits inline on its quota bar line + * (`label␣␣bar␣␣NN%␣␣reset`) or must render on its own line below. + * `availableWidth` is the bar's container content width in cells; + * null means unknown → assume inline fits. + */ +export function resetPlacement( + labelLength: number, + barWidth: number, + percent: number, + resetLabel: string, + availableWidth: number | null, +): ResetPlacement { + if (availableWidth === null) return "inline"; + const inlineWidth = labelLength + 2 + barWidth + 2 + `${percent}%`.length + 2 + resetLabel.length; + return inlineWidth <= availableWidth ? "inline" : "below"; +} diff --git a/src/tui/quota-bar.tsx b/src/tui/quota-bar.tsx index 91abcd9..be96a7e 100644 --- a/src/tui/quota-bar.tsx +++ b/src/tui/quota-bar.tsx @@ -1,6 +1,8 @@ /** * QuotaBar — renders a single quota window as label + progress bar + * percentage, plus an optional muted reset-countdown suffix ("resets 4h 5m"). + * When the suffix would overflow `maxWidth`, it renders on its own line + * below the bar instead, aligned under the bar start. * * Used by for the 5-hour and weekly windows. * When `percent` is null (no data), renders a muted "unavailable" label. @@ -12,6 +14,7 @@ import { Show, createMemo } from "solid-js"; import type { UsageWindow } from "../quota/types"; +import { resetPlacement } from "./compute"; import type { ThemeColors } from "./theme"; export interface QuotaBarProps { @@ -20,10 +23,22 @@ export interface QuotaBarProps { colors: ThemeColors; barWidth: number; reset?: string | null; + maxWidth?: number | null; } export function QuotaBar(props: QuotaBarProps) { const percent = createMemo(() => (props.window ? Math.round(props.window.usedPercent) : null)); + const placement = createMemo(() => + props.reset + ? resetPlacement( + props.label.length, + props.barWidth, + percent() ?? 0, + props.reset, + props.maxWidth ?? null, + ) + : null, + ); return ( {` ${percent()}%`} - + {` ${props.reset}`} + + + {`${" ".repeat(props.label.length + 2)}${props.reset}`} + + ); } diff --git a/src/tui/sidebar.tsx b/src/tui/sidebar.tsx index 3e950cb..9c9f11b 100644 --- a/src/tui/sidebar.tsx +++ b/src/tui/sidebar.tsx @@ -17,7 +17,8 @@ * changes are done inside JSX expressions or via `createMemo`. */ -import { Show, createMemo } from "solid-js"; +import type { BoxRenderable } from "@opentui/core"; +import { Show, createMemo, createSignal } from "solid-js"; import type { Report } from "../report/build"; import { resetDurationLabel } from "./compute"; import { QuotaBar } from "./quota-bar"; @@ -39,6 +40,16 @@ export function SidebarContent(props: SidebarContentProps) { const weeklyReset = createMemo(() => resetDurationLabel(quota()?.weekly ?? null, quota()?.fetchedAt ?? null, Date.now()), ); + + // Measured content width of the panel (host controls the sidebar width). + // null until the first layout pass; QuotaBar treats null as "fits inline". + // Panel width minus border (2) and padding (2). + const [contentWidth, setContentWidth] = createSignal(null); + const trackPanelSize = (el: BoxRenderable) => { + const update = () => setContentWidth(el.width > 4 ? el.width - 4 : null); + el.onSizeChange = update; + update(); + }; const showQuota = createMemo(() => { const q = quota(); return ( @@ -59,6 +70,7 @@ export function SidebarContent(props: SidebarContentProps) { } > diff --git a/test/unit/tui-compute.test.ts b/test/unit/tui-compute.test.ts index 3a2a152..4c0de82 100644 --- a/test/unit/tui-compute.test.ts +++ b/test/unit/tui-compute.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { type UsageWindow, noQuotaSnapshot } from "../../src/quota/types"; import type { SdkMessage } from "../../src/session/opencode-adapter"; -import { computeReport, resetDurationLabel } from "../../src/tui/compute"; +import { computeReport, resetDurationLabel, resetPlacement } from "../../src/tui/compute"; function assistantMsg( id: string, @@ -202,3 +202,28 @@ describe("resetDurationLabel", () => { expect(resetDurationLabel(w, isoAt(0), NOW)).toBe("resets 5d 5h"); }); }); + +describe("resetPlacement", () => { + // Line: label(5) + 2 + bar(14) + 2 + "45%"(3) + 2 + "resets 4h 5m"(12) = 40. + const LABEL_LEN = 5; + const BAR_WIDTH = 14; + const RESET = "resets 4h 5m"; + + it("returns inline when the full line fits exactly", () => { + expect(resetPlacement(LABEL_LEN, BAR_WIDTH, 45, RESET, 40)).toBe("inline"); + }); + + it("returns below when one cell short", () => { + expect(resetPlacement(LABEL_LEN, BAR_WIDTH, 45, RESET, 39)).toBe("below"); + }); + + it("assumes inline when available width is unknown", () => { + expect(resetPlacement(LABEL_LEN, BAR_WIDTH, 45, RESET, null)).toBe("inline"); + }); + + it("accounts for percent digit count", () => { + // "100%" is one cell wider than "45%" → same width now overflows. + expect(resetPlacement(LABEL_LEN, BAR_WIDTH, 100, RESET, 40)).toBe("below"); + expect(resetPlacement(LABEL_LEN, BAR_WIDTH, 100, RESET, 41)).toBe("inline"); + }); +}); From f4f88b80eb593634d21679351ae227af255742a3 Mon Sep 17 00:00:00 2001 From: SBakolis Date: Mon, 21 Sep 2026 23:14:32 +0300 Subject: [PATCH 3/3] chore: sync package-lock metadata with package.json Co-Authored-By: Claude Fable 5 --- package-lock.json | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96f7d64..3fb03ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "opencode-codex-meter", "version": "0.1.4", + "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.18.3", "@opencode-ai/sdk": "1.18.3", @@ -26,7 +27,8 @@ "vitest": "2.1.8" }, "engines": { - "node": ">=20" + "node": ">=20", + "opencode": ">=1.0.0 <2.0.0" }, "peerDependencies": { "@opentui/solid": ">=0.4.3", @@ -1344,7 +1346,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1358,7 +1359,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1372,7 +1372,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1386,7 +1385,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1400,7 +1398,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1414,7 +1411,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1428,7 +1424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1442,7 +1437,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [