Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,27 @@ quota** and **per-session token usage** — without spending a model turn.
Reset information refreshes with quota data (every 90 seconds by default).

```text
┌─ Codex Meter ────────────────────────────────┐
│ 5h quota [████████░░░░░░░░░░░░] 37% │
│ Weekly quota [████████████░░░░░░░░] 62% │
│ │
│ Usage limit resets │
│ 2 available │
│ │
│ Full reset (Weekly + 5 hr) │
│ Expires Oct 4, 8:37 AM │
│ │
│ Full reset (Weekly + 5 hr) │
│ Expires Oct 5, 7:21 AM │
│ │
│ 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 │
│ │
│ Usage limit resets │
│ 2 available │
│ │
│ Full reset (Weekly + 5 hr) │
│ Expires Oct 4, 8:37 AM │
│ │
│ Full reset (Weekly + 5 hr) │
│ Expires Oct 5, 7:21 AM │
│ │
│ 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
Expand Down
12 changes: 3 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion src/tui/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,3 +32,52 @@ 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)))}`;
}

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";
}
27 changes: 26 additions & 1 deletion src/tui/quota-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/**
* 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").
* When the suffix would overflow `maxWidth`, it renders on its own line
* below the bar instead, aligned under the bar start.
*
* Used by <SidebarContent> for the 5-hour and weekly windows.
* When `percent` is null (no data), renders a muted "unavailable" label.
Expand All @@ -11,17 +14,31 @@

import { Show, createMemo } from "solid-js";
import type { UsageWindow } from "../quota/types";
import { resetPlacement } from "./compute";
import type { ThemeColors } from "./theme";

export interface QuotaBarProps {
label: string;
window: UsageWindow | null;
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 (
<Show
Expand All @@ -37,7 +54,15 @@ export function QuotaBar(props: QuotaBarProps) {
{"░".repeat(props.barWidth - Math.round(((percent() ?? 0) / 100) * props.barWidth))}
</span>
<span style={{ fg: props.colors.quotaColor(percent() ?? 0) }}>{` ${percent()}%`}</span>
<Show when={placement() === "inline"}>
<span style={{ fg: props.colors.textMuted }}>{` ${props.reset}`}</span>
</Show>
</text>
<Show when={placement() === "below"}>
<text style={{ fg: props.colors.textMuted }}>
{`${" ".repeat(props.label.length + 2)}${props.reset}`}
</text>
</Show>
</Show>
);
}
33 changes: 25 additions & 8 deletions src/tui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
* - Available manual usage resets with expiry dates
* - Token section: per-model table + total
*
Expand All @@ -18,10 +18,11 @@
* 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 { formatResetDuration } from "../report/detailed";
import { formatResetCredits } from "../report/reset-credits";
import { resetDurationLabel } from "./compute";
import { QuotaBar } from "./quota-bar";
import type { ThemeColors } from "./theme";
import { TokenTable } from "./token-table";
Expand All @@ -35,6 +36,22 @@ 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()),
);

// 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<number | null>(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 (
Expand All @@ -55,6 +72,7 @@ export function SidebarContent(props: SidebarContentProps) {
}
>
<box
ref={trackPanelSize}
style={{
border: true,
borderColor: props.colors.border,
Expand All @@ -74,18 +92,17 @@ export function SidebarContent(props: SidebarContentProps) {
window={quota()?.fiveHour ?? null}
colors={props.colors}
barWidth={14}
reset={fiveHourReset()}
maxWidth={contentWidth()}
/>
<QuotaBar
label="week "
window={quota()?.weekly ?? null}
colors={props.colors}
barWidth={14}
reset={weeklyReset()}
maxWidth={contentWidth()}
/>
<Show when={quota()?.fiveHour?.resetAfterSeconds != null}>
<text style={{ fg: props.colors.textMuted }}>
{` resets ${formatResetDuration(quota()?.fiveHour?.resetAfterSeconds ?? null)}`}
</text>
</Show>
</box>
</Show>

Expand Down
Loading
Loading