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
115 changes: 87 additions & 28 deletions src/quota/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const WindowSchema = z
windowSeconds: z.number().optional(),
duration_seconds: z.number().optional(),
durationSeconds: z.number().optional(),
limit_window_seconds: z.number().optional(),
limitWindowSeconds: z.number().optional(),
// Duration in minutes (converted to seconds)
window_minutes: z.number().optional(),
windowMinutes: z.number().optional(),
Expand All @@ -37,11 +39,11 @@ const WindowSchema = z
usedPercent: z.number().optional(),
percent: z.number().optional(),
usage_percent: z.number().optional(),
// Reset time (ISO string)
resets_at: z.string().optional(),
resetsAt: z.string().optional(),
reset_at: z.string().optional(),
resetAt: z.string().optional(),
// Reset time — may be an ISO string OR a Unix timestamp (number, seconds)
resets_at: z.union([z.string(), z.number()]).optional(),
resetsAt: z.union([z.string(), z.number()]).optional(),
reset_at: z.union([z.string(), z.number()]).optional(),
resetAt: z.union([z.string(), z.number()]).optional(),
// Reset after (seconds)
reset_after_seconds: z.number().optional(),
resetAfterSeconds: z.number().optional(),
Expand Down Expand Up @@ -75,6 +77,16 @@ const WhamResponseSchema = z
rate_limits: z.array(WindowSchema).optional(),
rateLimits: z.array(WindowSchema).optional(),
limits: z.array(WindowSchema).optional(),
// rate_limit object with primary_window / secondary_window (singular)
rate_limit: z
.object({
primary_window: WindowSchema.nullable().optional(),
primaryWindow: WindowSchema.nullable().optional(),
secondary_window: WindowSchema.nullable().optional(),
secondaryWindow: WindowSchema.nullable().optional(),
})
.passthrough()
.optional(),
// Plan type
plan_type: z.string().optional(),
planType: z.string().optional(),
Expand Down Expand Up @@ -103,7 +115,13 @@ export interface WhamParseResult {
*/
function extractDurationSeconds(w: z.infer<typeof WindowSchema>): number | null {
// Direct seconds fields
const secs = w.window_seconds ?? w.windowSeconds ?? w.duration_seconds ?? w.durationSeconds;
const secs =
w.window_seconds ??
w.windowSeconds ??
w.duration_seconds ??
w.durationSeconds ??
w.limit_window_seconds ??
w.limitWindowSeconds;
if (typeof secs === "number" && secs > 0) return secs;

// Minutes fields — convert to seconds
Expand All @@ -125,13 +143,19 @@ function extractUsedPercent(w: z.infer<typeof WindowSchema>): number {
}

/**
* Extract reset time (ISO string) from a window object.
* Extract reset time from a window object.
* Accepts either an ISO string or a Unix timestamp (number, in seconds).
* Returns an ISO string or null.
*/
function extractResetsAt(w: z.infer<typeof WindowSchema>): string | null {
const val = w.resets_at ?? w.resetsAt ?? w.reset_at ?? w.resetAt;
if (typeof val === "string" && val.length > 0) {
return val;
}
if (typeof val === "number" && Number.isFinite(val) && val > 0) {
// Unix timestamp in seconds → ISO string
return new Date(val * 1000).toISOString();
}
return null;
}

Expand Down Expand Up @@ -186,6 +210,22 @@ export function parseWhamResponse(raw: unknown): WhamParseResult {

const windows = parseWindowsArray(rawWindows);

// Also extract windows from rate_limit.primary_window / secondary_window
// (singular objects, not arrays). The real wham endpoint uses this shape.
const rateLimit = data.rate_limit;
if (rateLimit) {
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
if (primary) {
const parsedPrimary = parseSingleWindow(primary);
if (parsedPrimary) windows.push(parsedPrimary);
}
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
if (secondary) {
const parsedSecondary = parseSingleWindow(secondary);
if (parsedSecondary) windows.push(parsedSecondary);
}
}

// Extract plan type.
const planType = data.plan_type ?? data.planType ?? data.plan ?? data.subscription ?? null;
const normalizedPlanType = typeof planType === "string" ? planType : null;
Expand All @@ -202,6 +242,17 @@ export function parseWhamResponse(raw: unknown): WhamParseResult {
};
}

/**
* Parse a single raw window object into a normalized UsageWindow.
* Returns null if the window fails schema validation.
*/
function parseSingleWindow(item: unknown): UsageWindow | null {
if (item === null || item === undefined) return null;
const parsed = WindowSchema.safeParse(item);
if (!parsed.success) return null;
return windowFromParsed(parsed.data);
}

/**
* Parse an array of raw window objects into normalized UsageWindow[].
*/
Expand All @@ -210,31 +261,39 @@ function parseWindowsArray(arr: unknown[]): UsageWindow[] {
for (const item of arr) {
const parsed = WindowSchema.safeParse(item);
if (!parsed.success) continue;

const durationSeconds = extractDurationSeconds(parsed.data);
if (durationSeconds === null) {
// Window without duration info — classify as unknown.
result.push({
kind: "unknown",
usedPercent: extractUsedPercent(parsed.data),
windowSeconds: 0,
resetsAt: extractResetsAt(parsed.data),
resetAfterSeconds: extractResetAfterSeconds(parsed.data),
});
continue;
}

result.push({
kind: identifyWindow(durationSeconds),
usedPercent: extractUsedPercent(parsed.data),
windowSeconds: durationSeconds,
resetsAt: extractResetsAt(parsed.data),
resetAfterSeconds: extractResetAfterSeconds(parsed.data),
});
const w = windowFromParsed(parsed.data);
if (w) result.push(w);
}
return result;
}

/**
* Build a UsageWindow from a successfully-parsed window object.
* Returns null only if the window fails internal validation (shouldn't
* happen after a successful Zod parse, but defensive).
*/
function windowFromParsed(data: z.infer<typeof WindowSchema>): UsageWindow | null {
const durationSeconds = extractDurationSeconds(data);
if (durationSeconds === null) {
// Window without duration info — classify as unknown.
return {
kind: "unknown",
usedPercent: extractUsedPercent(data),
windowSeconds: 0,
resetsAt: extractResetsAt(data),
resetAfterSeconds: extractResetAfterSeconds(data),
};
}

return {
kind: identifyWindow(durationSeconds),
usedPercent: extractUsedPercent(data),
windowSeconds: durationSeconds,
resetsAt: extractResetsAt(data),
resetAfterSeconds: extractResetAfterSeconds(data),
};
}

/**
* Parse the credits object into normalized CreditsInfo.
*/
Expand Down
36 changes: 21 additions & 15 deletions src/tui/quota-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
*
* Used by <SidebarContent> for the 5-hour and weekly windows.
* When `percent` is null (no data), renders a muted "unavailable" label.
*
* IMPORTANT (Solid reactivity): the component function body runs ONCE.
* Reads of `props.window` are done inside JSX expressions / createMemo so
* that the bar re-renders when the window prop changes.
*/

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

Expand All @@ -16,22 +21,23 @@ export interface QuotaBarProps {
}

export function QuotaBar(props: QuotaBarProps) {
const percent = props.window ? Math.round(props.window.usedPercent) : null;

if (percent === null) {
return <text style={{ fg: props.colors.textMuted }}>{`${props.label} unavailable`}</text>;
}

const filled = Math.round((percent / 100) * props.barWidth);
const empty = props.barWidth - filled;
const barColor = props.colors.quotaColor(percent);
const percent = createMemo(() => (props.window ? Math.round(props.window.usedPercent) : null));

return (
<text style={{ fg: props.colors.text }}>
<span style={{ fg: props.colors.textMuted }}>{`${props.label} `}</span>
<span style={{ fg: barColor }}>{"█".repeat(filled)}</span>
<span style={{ fg: props.colors.textMuted }}>{"░".repeat(empty)}</span>
<span style={{ fg: barColor }}>{` ${percent}%`}</span>
</text>
<Show
when={percent() !== null}
fallback={<text style={{ fg: props.colors.textMuted }}>{`${props.label} unavailable`}</text>}
>
<text style={{ fg: props.colors.text }}>
<span style={{ fg: props.colors.textMuted }}>{`${props.label} `}</span>
<span style={{ fg: props.colors.quotaColor(percent() ?? 0) }}>
{"█".repeat(Math.round(((percent() ?? 0) / 100) * props.barWidth))}
</span>
<span style={{ fg: props.colors.textMuted }}>
{"░".repeat(props.barWidth - Math.round(((percent() ?? 0) / 100) * props.barWidth))}
</span>
<span style={{ fg: props.colors.quotaColor(percent() ?? 0) }}>{` ${percent()}%`}</span>
</text>
</Show>
);
}
107 changes: 65 additions & 42 deletions src/tui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@
* - No active session: "No active session"
* - No messages yet: handled by TokenTable
* - Quota unavailable: handled by QuotaBar
*
* IMPORTANT (Solid reactivity): the component function body runs ONCE.
* Destructuring props (e.g. `const report = props.report`) captures the
* initial value and never updates. All prop reads that must react to
* changes are done inside JSX expressions or via `createMemo`.
*/

import { Show, createMemo } from "solid-js";
import type { Report } from "../report/build";
import { formatResetDuration } from "../report/detailed";
import { QuotaBar } from "./quota-bar";
Expand All @@ -25,56 +31,73 @@ export interface SidebarContentProps {
}

export function SidebarContent(props: SidebarContentProps) {
if (!props.sessionID) {
// Reactive: re-evaluates whenever props.report changes.
const quota = createMemo(() => props.report?.quota ?? null);
const showQuota = createMemo(() => {
const q = quota();
return (
<box style={{ border: true, borderColor: props.colors.border, padding: 1 }}>
<text style={{ fg: props.colors.textMuted }}>No active session</text>
</box>
q !== null &&
q.status !== "unavailable" &&
q.status !== "unauthenticated" &&
q.status !== "unsupported"
);
}

const report = props.report;
const quota = report?.quota ?? null;
const showQuota =
quota !== null &&
quota.status !== "unavailable" &&
quota.status !== "unauthenticated" &&
quota.status !== "unsupported";
});

return (
<box
style={{
border: true,
borderColor: props.colors.border,
padding: 1,
flexDirection: "column",
}}
<Show
when={props.sessionID}
fallback={
<box style={{ border: true, borderColor: props.colors.border, padding: 1 }}>
<text style={{ fg: props.colors.textMuted }}>No active session</text>
</box>
}
>
<text style={{ fg: props.colors.text }}>
<strong>Codex Meter</strong>
</text>
<box
style={{
border: true,
borderColor: props.colors.border,
padding: 1,
flexDirection: "column",
}}
>
<text style={{ fg: props.colors.text }}>
<strong>Codex Meter</strong>
</text>

{showQuota && quota && (
<box style={{ flexDirection: "column", marginTop: 1 }}>
<text style={{ fg: props.colors.textMuted }}>Quota</text>
<QuotaBar label="5h " window={quota.fiveHour} colors={props.colors} barWidth={14} />
<QuotaBar label="week " window={quota.weekly} colors={props.colors} barWidth={14} />
{quota.fiveHour?.resetAfterSeconds != null && (
<text style={{ fg: props.colors.textMuted }}>
{` resets ${formatResetDuration(quota.fiveHour.resetAfterSeconds)}`}
</text>
)}
</box>
)}
<Show when={showQuota()}>
<box style={{ flexDirection: "column", marginTop: 1 }}>
<text style={{ fg: props.colors.textMuted }}>Quota</text>
<QuotaBar
label="5h "
window={quota()?.fiveHour ?? null}
colors={props.colors}
barWidth={14}
/>
<QuotaBar
label="week "
window={quota()?.weekly ?? null}
colors={props.colors}
barWidth={14}
/>
<Show when={quota()?.fiveHour?.resetAfterSeconds != null}>
<text style={{ fg: props.colors.textMuted }}>
{` resets ${formatResetDuration(quota()?.fiveHour?.resetAfterSeconds ?? null)}`}
</text>
</Show>
</box>
</Show>

{quota !== null && !showQuota && (
<text style={{ fg: props.colors.textMuted, marginTop: 1 }}>{`Quota: ${quota.status}`}</text>
)}
<Show when={quota() !== null && !showQuota()}>
<text
style={{ fg: props.colors.textMuted, marginTop: 1 }}
>{`Quota: ${quota()?.status}`}</text>
</Show>

<box style={{ flexDirection: "column", marginTop: 1 }}>
<text style={{ fg: props.colors.textMuted }}>Tokens (this session)</text>
<TokenTable models={report?.models ?? []} colors={props.colors} />
<box style={{ flexDirection: "column", marginTop: 1 }}>
<text style={{ fg: props.colors.textMuted }}>Tokens (this session)</text>
<TokenTable models={props.report?.models ?? []} colors={props.colors} />
</box>
</box>
</box>
</Show>
);
}
Loading
Loading