usage panel on the claude code pill
clicking the harness pill opens what claude code's own usage display shows: session/weekly/scoped limits with reset countdowns, tokens by day for the last week, tokens by model. officer has none of these numbers and could not derive them. per-session cost is in-memory in websocket.ts and dies on restart, and the rate-limit windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the box, not only by this chat. so both halves come from the cli's own record: the oauth usage endpoint for the windows, ~/.claude/projects for the tokens, ~/.claude.json for which account is signed in. shape and the non-obvious correctness details are lifted from omarchy's agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds the account email): dedup by message id, the four token counters, the per-payload percent-vs-fraction scale, and reading the model-scoped window out of limits[] rather than the flat buckets — every seven_day_* bucket is null on this account while limits[] carries a real fable window. verified against the live payload today; limitsFromPayload is tested against it. owner-only in the handler, not the registry: `chat` is confined, so a member holding it reaches every route here, and these are someone else's windows. the pill falls back to the plain label it always was for anyone else and for opencode. 442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the query only runs when the popover opens. not exercised through http — the route needs an officer restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
|
||||
@@ -24,19 +25,28 @@ const MODELS: ModelOption[] = [
|
||||
{ id: 'openai/gpt-5', name: 'gpt-5', provider: 'openai' },
|
||||
] as ModelOption[];
|
||||
|
||||
/*
|
||||
A client, because the harness pill is a usage popover now and it asks who you are.
|
||||
|
||||
`usePermissions` answers from React Query, so without a provider the whole selector throws before any of
|
||||
these assertions get near it. The query never resolves here — `isOwner` stays false and the pill renders as
|
||||
the plain label it always was, which is the state every test below was written against.
|
||||
*/
|
||||
const mount = (over: Partial<Parameters<typeof ModelSelector>[0]> = {}) =>
|
||||
render(
|
||||
<ModelSelector
|
||||
messages={[]}
|
||||
availableModels={MODELS}
|
||||
selectedModel="claude-code/opus"
|
||||
onModelChange={() => {}}
|
||||
model={null}
|
||||
isConnected
|
||||
isGenerating={false}
|
||||
hasStarted
|
||||
{...over}
|
||||
/>,
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ModelSelector
|
||||
messages={[]}
|
||||
availableModels={MODELS}
|
||||
selectedModel="claude-code/opus"
|
||||
onModelChange={() => {}}
|
||||
model={null}
|
||||
isConnected
|
||||
isGenerating={false}
|
||||
hasStarted
|
||||
{...over}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const providerButton = (name: RegExp) => screen.getByRole('button', { name });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import type { ModelOption } from 'state/useModels';
|
||||
import { getProviderDisplayName } from 'state/useModels';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { UsagePopover } from './UsagePopover';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
@@ -150,12 +151,20 @@ export function ModelSelector({
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
{providerStripLocked ? (
|
||||
// A static label, not an action — `bg-muted` rather than a filled accent. It used to be
|
||||
// `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was white
|
||||
// on white for every session that had already started, i.e. almost always.
|
||||
<span className="shrink-0 truncate rounded-md bg-muted px-3 py-1 text-xs font-medium text-foreground">
|
||||
{activeProvider ? displayName(activeProvider) : 'Chat'}
|
||||
</span>
|
||||
/*
|
||||
The collapsed strip: a label, and for the owner on Claude Code, a button that opens the usage panel.
|
||||
|
||||
It used to be `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was
|
||||
white on white for every session that had already started, i.e. almost always. `bg-muted` since.
|
||||
|
||||
Only interactive where there is something behind it. The numbers are the OWNER'S subscription
|
||||
windows — the route refuses anyone else — and OpenCode has no equivalent, so both cases fall back
|
||||
to exactly the label that was here before.
|
||||
*/
|
||||
<UsagePopover
|
||||
label={activeProvider ? displayName(activeProvider) : 'Chat'}
|
||||
harness={activeProvider === 'claude-code' ? 'claude-code' : 'other'}
|
||||
/>
|
||||
) : (
|
||||
// Fifteen providers can be configured and the names are long ("Google Vertex AI"), so the strip
|
||||
// scrolls rather than crushing the model select beside it.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { ClaudeUsage } from '../useClaudeUsage';
|
||||
import { formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
|
||||
|
||||
/**
|
||||
* Claude Code's own usage display, on the harness pill.
|
||||
*
|
||||
* Everything here comes from the CLI's own record — Anthropic's OAuth usage endpoint for the windows, the
|
||||
* transcripts under `~/.claude/projects` for the tokens. Officer counts none of it itself, and could not:
|
||||
* the windows are spent by every `claude` on the machine, not only by this chat. See
|
||||
* `servers/api/chat/claude-usage.ts`.
|
||||
*
|
||||
* The layout follows omarchy's agents panel (basecamp/omarchy, quattro) because the owner already reads
|
||||
* these numbers in that shape on this machine, and two different arrangements of the same three sections is
|
||||
* a tax paid every time you look at either.
|
||||
*/
|
||||
|
||||
/** A bar. `value` is 0–1; anything outside is clamped rather than allowed to overflow its track. */
|
||||
const Meter = ({ value, strong = false }: { value: number; strong?: boolean }) => (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${strong ? 'bg-foreground' : 'bg-foreground/50'}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, value * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => (
|
||||
<Section title="Limits">
|
||||
<div className="flex flex-col gap-3">
|
||||
{usage.limits.map((limit) => {
|
||||
const resets = formatReset(limit.resetsAt, now);
|
||||
return (
|
||||
<div key={limit.label} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
|
||||
</div>
|
||||
<Meter value={limit.percent / 100} strong />
|
||||
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const Days = ({ usage }: { usage: ClaudeUsage }) => {
|
||||
// Every bar is scaled to the busiest day, so the shape of the week is readable even in a quiet one. A
|
||||
// fixed scale would flatten a whole week of ordinary work into nothing.
|
||||
const peak = Math.max(1, ...usage.days.map((d) => d.tokens));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by day">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{usage.days.map((day, index) => {
|
||||
const today = index === usage.days.length - 1;
|
||||
return (
|
||||
<div key={day.date} className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-10 shrink-0 text-xs ${today ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{today ? 'Today' : weekdayLabel(day.date)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Meter value={day.tokens / peak} strong={today} />
|
||||
</div>
|
||||
<span
|
||||
className={`w-14 shrink-0 text-right text-xs tabular-nums ${today ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{formatTokens(day.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const Models = ({ usage }: { usage: ClaudeUsage }) => {
|
||||
// Four, because the tail is a long list of models that answered once. The heaviest is the scale, the same
|
||||
// way the week scales to its busiest day.
|
||||
const models = usage.models.slice(0, 4);
|
||||
const peak = Math.max(1, ...models.map((m) => m.total));
|
||||
|
||||
return (
|
||||
<Section title="Tokens by model">
|
||||
<div className="flex flex-col gap-1">
|
||||
{models.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="relative flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1.5"
|
||||
title={`In ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache read ${formatTokens(model.cacheRead)} · cache write ${formatTokens(model.cacheWrite)}`}
|
||||
>
|
||||
{/* The bar sits BEHIND the row rather than beside it — the model names are long and a separate
|
||||
track would leave no room for either. */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-md bg-muted"
|
||||
style={{ width: `${(model.total / peak) * 100}%` }}
|
||||
/>
|
||||
<span className="relative truncate text-sm text-foreground">{model.name}</span>
|
||||
<span className="relative shrink-0 text-sm tabular-nums text-muted-foreground">
|
||||
{formatTokens(model.total)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
type UsagePopoverProps = {
|
||||
/** What the pill reads — the provider display name, unchanged. */
|
||||
label: string;
|
||||
/** Only Claude Code has any of this. OpenCode reports no windows and writes no transcripts we can read. */
|
||||
harness: 'claude-code' | 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* The harness pill, and what it opens.
|
||||
*
|
||||
* The pill is inert once a conversation has started — there is only one harness left to choose, so the
|
||||
* provider strip has already collapsed to a label. That is exactly why it is free to become a button: it
|
||||
* costs nothing that was working, and it is the one piece of chrome in the composer that names the thing
|
||||
* these numbers are about.
|
||||
*/
|
||||
export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
/*
|
||||
The owner check lives HERE rather than in the caller, so the composer does not have to know that a pill
|
||||
has anything to do with permissions.
|
||||
|
||||
`usePermissions` fails open on grants by design — hiding a dock icon is a courtesy and the 403 is the
|
||||
lock — but `isOwner` itself defaults to FALSE, which is the direction that matters here: a transient
|
||||
failure leaves the pill as the plain label it has always been, rather than offering a member a button
|
||||
whose route will refuse them.
|
||||
*/
|
||||
const { isOwner } = usePermissions();
|
||||
const enabled = isOwner && harness === 'claude-code';
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading, isError } = useClaudeUsage(enabled && open);
|
||||
|
||||
// The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a
|
||||
// re-render a minute for something nobody is looking at.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNow(Date.now());
|
||||
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open]);
|
||||
|
||||
const pill = 'shrink-0 truncate rounded-md bg-muted px-3 py-1 text-xs font-medium text-foreground';
|
||||
|
||||
if (!enabled) return <span className={pill}>{label}</span>;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title="Usage and limits">
|
||||
{label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" side="top" className="z-[900] w-80 p-0">
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-sm font-semibold text-foreground">{label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{[data?.account.plan, data?.account.email].filter(Boolean).join(' · ') ||
|
||||
(isLoading ? 'Reading usage…' : 'Not signed in')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem
|
||||
still leaves real token history below it. */}
|
||||
{isError && (
|
||||
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">
|
||||
Couldn't read usage on this machine.
|
||||
</div>
|
||||
)}
|
||||
{data?.limitsProblem && (
|
||||
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">{data.limitsProblem}</div>
|
||||
)}
|
||||
|
||||
{isLoading && !data && (
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{data.limits.length > 0 && <Limits usage={data} now={now} />}
|
||||
<Days usage={data} />
|
||||
{data.models.length > 0 && <Models usage={data} />}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { formatReset, formatTokens, weekdayLabel } from './useClaudeUsage';
|
||||
|
||||
describe('formatTokens', () => {
|
||||
test('scales to the unit the number deserves', () => {
|
||||
expect(formatTokens(0)).toBe('0');
|
||||
expect(formatTokens(842)).toBe('842');
|
||||
expect(formatTokens(25_600)).toBe('25.6K');
|
||||
expect(formatTokens(1_900_000)).toBe('1.9M');
|
||||
expect(formatTokens(248_500_000)).toBe('248.5M');
|
||||
expect(formatTokens(1_200_000_000)).toBe('1.2B');
|
||||
});
|
||||
|
||||
test('a day with no work reads as zero, not as an empty cell', () => {
|
||||
// The week always shows seven rows, and a quiet day is information — a blank one looks like a bug.
|
||||
expect(formatTokens(-1)).toBe('0');
|
||||
expect(formatTokens(Number.NaN)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReset', () => {
|
||||
const now = Date.parse('2026-08-18T04:14:00Z');
|
||||
|
||||
test('hours and minutes, the way Claude Code says it', () => {
|
||||
expect(formatReset('2026-08-18T08:00:00Z', now)).toBe('3h 46m');
|
||||
expect(formatReset('2026-08-19T01:00:00Z', now)).toBe('20h 46m');
|
||||
});
|
||||
|
||||
test('under an hour drops the hours', () => {
|
||||
expect(formatReset('2026-08-18T04:40:00Z', now)).toBe('26m');
|
||||
});
|
||||
|
||||
test('past a day it says days, rather than a three-digit hour count', () => {
|
||||
expect(formatReset('2026-08-20T06:14:00Z', now)).toBe('2d 2h');
|
||||
});
|
||||
|
||||
test('a window that has already reset says nothing at all', () => {
|
||||
// A countdown at zero is worse than no countdown: the next reading carries the new window, and until it
|
||||
// arrives "Resets in 0m" reads as a limit that is stuck.
|
||||
expect(formatReset('2026-08-18T04:00:00Z', now)).toBeNull();
|
||||
expect(formatReset(null, now)).toBeNull();
|
||||
expect(formatReset('not a date', now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekdayLabel', () => {
|
||||
test('names the local day', () => {
|
||||
// Parsed field by field rather than through `new Date(string)`, which reads a bare `YYYY-MM-DD` as UTC
|
||||
// midnight — so anywhere west of Greenwich every label slid back a day.
|
||||
expect(weekdayLabel('2026-08-17')).toBe('Mon');
|
||||
expect(weekdayLabel('2026-08-18')).toBe('Tue');
|
||||
});
|
||||
|
||||
test('an unparseable date falls back to itself rather than to Invalid Date', () => {
|
||||
expect(weekdayLabel('nonsense')).toBe('nonsense');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
export type UsageLimit = { label: string; percent: number; resetsAt: string | null };
|
||||
export type UsageDay = { date: string; tokens: number };
|
||||
export type UsageModel = {
|
||||
name: string;
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
|
||||
export type ClaudeUsage = {
|
||||
account: { email: string | null; plan: string | null };
|
||||
limits: UsageLimit[];
|
||||
limitsProblem?: string;
|
||||
days: UsageDay[];
|
||||
models: UsageModel[];
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The owner's Claude Code usage — limits, tokens by day, tokens by model.
|
||||
*
|
||||
* `enabled` rather than an always-on query, and that is the whole design of this hook: the server side reads
|
||||
* ~90MB of transcripts and probes an endpoint that rate-limits, so it must not run because a chat panel
|
||||
* mounted. It runs when the popover opens.
|
||||
*
|
||||
* Owner-only at the route (a member gets a 403), so pass `enabled: isOwner` from the caller rather than
|
||||
* letting every member's composer fire a request that is always refused.
|
||||
*/
|
||||
export function useClaudeUsage(enabled: boolean) {
|
||||
const client = useClient();
|
||||
|
||||
return useQuery<ClaudeUsage>({
|
||||
queryKey: ['chat', 'usage'],
|
||||
queryFn: () => client.get<ClaudeUsage>('/chat/usage'),
|
||||
enabled,
|
||||
// The server caches for a minute; asking more often than that gets the same answer back.
|
||||
staleTime: 60_000,
|
||||
// The numbers move while you watch — a turn finishing pushes the session window up — but not fast, and
|
||||
// a popover that reloads under the cursor is worse than one that is a minute behind.
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** `1.9M`, `248.5M`, `1.2B`. Bare integers below a thousand. */
|
||||
export function formatTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0';
|
||||
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
|
||||
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
||||
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* How long until a window resets, as `3h 46m`.
|
||||
*
|
||||
* Null when there is no reset time or it has already passed — a countdown that has run out says nothing
|
||||
* useful, and the next reading will carry the new window anyway.
|
||||
*/
|
||||
export function formatReset(resetsAt: string | null, now: number): string | null {
|
||||
if (!resetsAt) return null;
|
||||
const at = Date.parse(resetsAt);
|
||||
if (Number.isNaN(at)) return null;
|
||||
const minutes = Math.floor((at - now) / 60_000);
|
||||
if (minutes <= 0) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||
return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
/** `2026-08-18` → `Mon`. Parsed as a LOCAL date, so the label cannot slide a day west of UTC. */
|
||||
export function weekdayLabel(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
if (!year || !month || !day) return date;
|
||||
return new Date(year, month - 1, day).toLocaleDateString(undefined, { weekday: 'short' });
|
||||
}
|
||||
Reference in New Issue
Block a user