diff --git a/src/index.ts b/src/index.ts
index fe4226c..6ef58c9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -27,16 +27,57 @@ interface HistoryData {
const LIVE_POLL_MS = 5 * 60_000;
const TICK_MS = 60_000;
-// ── Theme (matches starter conventions) ─────────────────────────────────
+// ── Theme (follows the CloudCLI host) ───────────────────────────────────
+// The plugin is mounted straight into the host page, so host CSS variables
+// and fonts are inherited. Every colour reads the host variable, with a
+// fallback for hosts that do not define it. Variables are HSL triplets.
-const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace";
+const SANS = "Geist, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
+const MONO = "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
-interface ThemeColors { bg: string; surface: string; border: string; text: string; muted: string; accent: string; ok: string; warn: string; bad: string }
+/** Corner radius derived from the host's --radius. */
+function radius(minusPx = 0): string {
+ return minusPx ? `calc(var(--radius, 0.625rem) - ${minusPx}px)` : 'var(--radius, 0.625rem)';
+}
+
+const FALLBACKS = {
+ light: {
+ background: '60 14.3% 98.6%', foreground: '60 2.6% 7.6%', card: '0 0% 100%',
+ primary: '15.5 58.2% 43.1%', muted: '60 3.2% 93.9%', accent: '48 12.2% 92%', 'muted-foreground': '50 2.9% 40.8%',
+ border: '60 1.7% 88.4%', input: '60 1.5% 87.3%', ring: '15.1 55.6% 52.4%', destructive: '4.1 59.1% 44.1%',
+ },
+ dark: {
+ background: '0 0% 8.2%', foreground: '45 11.8% 93.3%', card: '60 1.6% 12.4%',
+ primary: '15.2 64.9% 63.1%', muted: '0 0% 12.9%', accent: '0 1.2% 16.7%', 'muted-foreground': '45 3.6% 56.1%',
+ border: '0 1.2% 15.9%', input: '30 1.9% 21.2%', ring: '15.2 64.9% 63.1%', destructive: '9.9 72.5% 67.3%',
+ },
+} as const;
+
+type HostVar = keyof typeof FALLBACKS.light;
+
+interface ThemeColors { bg: string; surface: string; border: string; track: string; text: string; muted: string; accent: string; hover: string; highlight: string; ring: string; ok: string; warn: string; bad: string }
function themeColors(dark: boolean): ThemeColors {
- return dark
- ? { bg: '#08080f', surface: '#0e0e1a', border: '#1a1a2c', text: '#e2e0f0', muted: '#52507a', accent: '#fbbf24', ok: '#10b981', warn: '#f59e0b', bad: '#f43f5e' }
- : { bg: '#fafaf9', surface: '#ffffff', border: '#e8e6f0', text: '#0f0e1a', muted: '#9490b0', accent: '#d97706', ok: '#059669', warn: '#d97706', bad: '#e11d48' };
+ const fb = FALLBACKS[dark ? 'dark' : 'light'];
+ const v = (name: HostVar): string => `hsl(var(--${name}, ${fb[name]}))`;
+ return {
+ bg: v('background'),
+ surface: v('card'),
+ border: v('border'),
+ track: v('input'),
+ text: v('foreground'),
+ muted: v('muted-foreground'),
+ accent: v('primary'),
+ hover: v('accent'),
+ // A faint wash of the muted text colour: --accent is too close to --card in dark mode to mark a column.
+ highlight: `hsl(var(--muted-foreground, ${fb['muted-foreground']}) / 0.16)`,
+ ring: v('ring'),
+ // The host has no success/warning tokens; these are tuned for >= 4.5:1
+ // on --card and --background in each mode.
+ ok: dark ? 'hsl(142 42% 58%)' : 'hsl(142 52% 30%)',
+ warn: dark ? 'hsl(38 82% 62%)' : 'hsl(32 92% 33%)',
+ bad: v('destructive'),
+ };
}
function sevColor(c: ThemeColors, severity: string, percent: number): string {
@@ -88,22 +129,25 @@ function esc(s: string): string {
function card(c: ThemeColors, title: string, body: string): string {
return `
-
-
${title}
+
`;
}
function errorNote(c: ThemeColors, msg: string): string {
- return `
⚠ ${esc(msg)}
`;
+ return `
⚠ ${esc(msg)}
`;
+}
+
+function planBadge(c: ThemeColors, live: LiveData | null): string {
+ if (!live?.plan) return '';
+ const tier = live.tier ? ` · ${esc(live.tier.replace(/^default_claude_/, ''))}` : '';
+ return `
${esc(live.plan)}${tier}`;
}
function renderLive(c: ThemeColors, live: LiveData | null, error: string | null): string {
- const badge = live?.plan
- ? `
${esc(live.plan)}${live.tier ? ` · ${esc(live.tier.replace(/^default_claude_/, ''))}` : ''}`
- : '';
const updated = live
- ? `
updated ${ago(Date.now() - live.fetchedAt)}`
+ ? `
updated ${ago(Date.now() - live.fetchedAt)}`
: '';
let body: string;
if (error) {
@@ -121,46 +165,53 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
const color = sevColor(c, l.severity, l.percent);
const pct = Math.max(0, Math.min(100, l.percent));
return `
-
-
+
+
${esc(l.label)}
- ${l.percent}% · ${resetsIn(l.resetsAt)}
+ ${l.percent}% · ${resetsIn(l.resetsAt)}
-
`;
}).join('');
}
- return card(c, `plan limits ${badge}${updated}`, body);
+ return card(c, `plan limits ${updated}`, body);
}
-function renderChart(c: ThemeColors, daily: HistoryData['daily']): string {
+/** The line under the chart: the date range, or the active day's figures. */
+function chartReadout(c: ThemeColors, daily: HistoryData['daily'], active: number | null): string {
+ const d = active === null ? undefined : daily[active];
+ if (!d) return `
${daily[0]?.date ?? ''}${daily[daily.length - 1]?.date ?? ''}`;
+ return `
${d.date}
+
${fmtTokens(totalOf(d.tokens))} tokens · ${fmtCost(d.cost)}`;
+}
+
+function renderChart(c: ThemeColors, daily: HistoryData['daily'], active: number | null): string {
const max = Math.max(1, ...daily.map((d) => totalOf(d.tokens)));
const bars = daily.map((d, i) => {
const h = Math.round((totalOf(d.tokens) / max) * 100);
const label = `${d.date}: ${fmtTokens(totalOf(d.tokens))} tokens · ${fmtCost(d.cost)}`;
- return `
-
+ return `
`;
}).join('');
- return `
${bars}
-
- ${daily[0]?.date ?? ''}${daily[daily.length - 1]?.date ?? ''}
-
`;
+ return `
${bars}
+
${chartReadout(c, daily, active)}
`;
}
function renderTable(c: ThemeColors, rows: [string, TokenCounts, number | null][]): string {
if (rows.length === 0) return `
No data.
`;
return rows.map(([name, tokens, cost]) => `
-
+
${esc(name)}
-
${fmtTokens(totalOf(tokens))}
-
${fmtCost(cost)}
+
${fmtTokens(totalOf(tokens))}
+
${fmtCost(cost)}
`).join('');
}
-function renderHistory(c: ThemeColors, history: HistoryData | null, error: string | null): string {
+function renderHistory(c: ThemeColors, history: HistoryData | null, error: string | null, activeDay: number | null): string {
if (error) return card(c, 'usage history (30 days)', errorNote(c, error));
if (!history) return card(c, 'usage history (30 days)', `
Loading…
`);
if (history.totals.messages === 0) {
@@ -174,17 +225,17 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
['sessions', String(t.sessions)],
];
const statCards = `
-
+
${stats.map(([label, val]) => `
-
-
${val}
-
${label}
+
`).join('')}
`;
return `
${statCards}
- ${card(c, 'daily tokens (30 days)', renderChart(c, history.daily))}
-
+ ${card(c, 'daily tokens (30 days)', renderChart(c, history.daily, activeDay))}
+
${card(c, 'by model', renderTable(c, history.byModel.map((m) => [m.model, m.tokens, m.cost])))}
${card(c, 'by project', renderTable(c, history.byProject.slice(0, 10).map((p) => [p.project, p.tokens, p.cost])))}
`;
@@ -192,36 +243,104 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
// ── Mount / Unmount ─────────────────────────────────────────────────────
+// Lucide-style glyphs drawn like the host's built-in tab headers.
+const GAUGE_ICON = '
';
+const REFRESH_ICON = '
';
+
+// Layout and interaction states that inline styles can't express. Colours
+// come from --cu-* properties set inline so they follow the host theme.
+// Breakpoints are container queries: the panel is narrower than the screen
+// whenever the host sidebar is open.
+const STYLES = `
+ .cu-root { container-type: inline-size; }
+ .cu-body { padding: 20px 24px 24px; }
+ .cu-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
+ .cu-stat-value { font-size: 1.35rem; }
+ .cu-gauge + .cu-gauge { margin-top: 14px; }
+ .cu-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 10px; }
+ .cu-refresh { transition: background 0.15s, color 0.15s; }
+ .cu-refresh:disabled { opacity: 0.5; cursor: default; }
+ .cu-refresh:focus-visible { outline: 1px solid var(--cu-ring); outline-offset: 0; }
+ .cu-day[data-active] { background: var(--cu-day-active); }
+ .cu-day[data-active] > div { opacity: 1 !important; }
+ #cu-chart:focus { outline: none; }
+ #cu-chart:focus-visible { outline: 1px solid var(--cu-ring); outline-offset: 4px; }
+ .cu-spin { animation: cu-spin 1s linear infinite; }
+ @keyframes cu-spin { to { transform: rotate(360deg); } }
+ @media (hover: hover) {
+ .cu-refresh:not(:disabled):hover { background: var(--cu-hover-bg) !important; color: var(--cu-hover-fg) !important; }
+ }
+ @container (max-width: 720px) {
+ .cu-split { grid-template-columns: minmax(0, 1fr); }
+ }
+ @container (max-width: 560px) {
+ .cu-body { padding: 16px; }
+ .cu-header { padding-left: 16px !important; padding-right: 12px !important; }
+ .cu-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .cu-stat-value { font-size: 1.2rem; }
+ }
+`;
+
interface State {
live: LiveData | null;
liveError: string | null;
history: HistoryData | null;
historyError: string | null;
+ loading: boolean;
+ /** Chart day under the pointer, tapped or picked with the keyboard. */
+ activeDay: number | null;
}
export function mount(container: HTMLElement, api: PluginAPI): void {
const root = document.createElement('div');
+ root.className = 'cu-root';
Object.assign(root.style, {
- height: '100%', overflowY: 'auto', boxSizing: 'border-box',
- padding: '24px', fontFamily: MONO,
+ height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', fontFamily: SANS,
});
+ // The header stays put; only the body scrolls, and it is reused across
+ // renders so its scroll position survives the 60s refresh.
+ const header = document.createElement('div');
+ header.className = 'cu-header';
+ Object.assign(header.style, {
+ flexShrink: '0', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ gap: '12px', padding: '12px 16px',
+ });
+ const body = document.createElement('div');
+ body.className = 'cu-body';
+ Object.assign(body.style, { flex: '1', minHeight: '0', overflowY: 'auto', boxSizing: 'border-box' });
+ root.append(header, body);
container.appendChild(root);
- const state: State = { live: null, liveError: null, history: null, historyError: null };
+ const style = document.createElement('style');
+ style.textContent = STYLES;
+ container.appendChild(style);
+
+ const state: State = { live: null, liveError: null, history: null, historyError: null, loading: false, activeDay: null };
function render(ctx: PluginContext): void {
const c = themeColors(ctx.theme === 'dark');
root.style.background = c.bg;
root.style.color = c.text;
- root.innerHTML = `
-
-
Claude Usage▌
-
+ header.style.borderBottom = `1px solid ${c.border}`;
+ header.innerHTML = `
+
+
+ ${GAUGE_ICON}
+
Claude Usage
+ ${planBadge(c, state.live)}
+
+
Plan limits and 30-day token history
+
+ `;
+ body.innerHTML = `
${renderLive(c, state.live, state.liveError)}
- ${renderHistory(c, state.history, state.historyError)}
+ ${renderHistory(c, state.history, state.historyError, state.activeDay)}
`;
- root.querySelector('#cu-refresh')?.addEventListener('click', () => void load(true));
+ header.querySelector('#cu-refresh')?.addEventListener('click', () => void load(true));
}
async function loadLive(): Promise
{
@@ -245,14 +364,89 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
}
async function load(refreshHistory: boolean): Promise {
+ // Only full loads (first load, refresh button) show the spinner; the
+ // background poll updates quietly.
+ if (refreshHistory) {
+ state.loading = true;
+ render(api.context);
+ }
// Sections load and fail independently.
await Promise.all([loadLive(), refreshHistory || !state.history ? loadHistory() : Promise.resolve()]);
+ state.loading = false;
render(api.context);
}
render(api.context);
void load(true);
+ // Chart readout. The browser's title tooltips never show on touch screens,
+ // so a day is picked by hovering with a mouse, tapping or dragging a finger
+ // along the bars, or the arrow keys once the chart has focus.
+ let dragging = false;
+
+ function setActiveDay(i: number | null): void {
+ if (i === state.activeDay) return;
+ state.activeDay = i;
+ const chart = body.querySelector('#cu-chart');
+ const readout = body.querySelector('#cu-readout');
+ if (!chart || !readout || !state.history) return;
+ chart.querySelectorAll('[data-day]').forEach((el) => {
+ el.toggleAttribute('data-active', Number(el.dataset.day) === i);
+ });
+ readout.innerHTML = chartReadout(themeColors(api.context.theme === 'dark'), state.history.daily, i);
+ }
+
+ const inChart = (t: EventTarget | null): boolean => t instanceof Element && t.closest('#cu-chart') !== null;
+
+ // A touch's events keep targeting the element it started on, so look up
+ // what is under the finger now. Gaps between bars leave the day unchanged.
+ function pickDayAt(e: PointerEvent): void {
+ const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-day]');
+ if (el && body.contains(el)) setActiveDay(Number(el.dataset.day));
+ }
+
+ const endDrag = (): void => { dragging = false; };
+
+ body.addEventListener('pointerdown', (e) => {
+ if (!inChart(e.target)) {
+ setActiveDay(null);
+ return;
+ }
+ dragging = true;
+ pickDayAt(e);
+ });
+ body.addEventListener('pointermove', (e) => {
+ if (dragging) {
+ pickDayAt(e);
+ } else if (e.pointerType === 'mouse') {
+ if (inChart(e.target)) pickDayAt(e);
+ else setActiveDay(null);
+ }
+ });
+ body.addEventListener('pointerleave', (e) => {
+ if (e.pointerType === 'mouse' && !dragging) setActiveDay(null);
+ });
+ window.addEventListener('pointerup', endDrag);
+ window.addEventListener('pointercancel', endDrag);
+ body.addEventListener('keydown', (e) => {
+ if (!inChart(e.target) || !state.history) return;
+ const last = state.history.daily.length - 1;
+ const cur = state.activeDay;
+ let next: number | null;
+ if (e.key === 'ArrowLeft') next = cur === null ? last : Math.max(0, cur - 1);
+ else if (e.key === 'ArrowRight') next = cur === null ? last : Math.min(last, cur + 1);
+ else if (e.key === 'Home') next = 0;
+ else if (e.key === 'End') next = last;
+ else if (e.key === 'Escape') next = null;
+ else return;
+ e.preventDefault();
+ setActiveDay(next);
+ });
+ body.addEventListener('focusout', (e) => {
+ // Ignore the chart being replaced by a re-render.
+ if (!dragging && e.target instanceof Element && e.target.isConnected && inChart(e.target)) setActiveDay(null);
+ });
+
// Live gauges re-fetch every 5 minutes; countdown / "updated" text re-renders
// every minute without touching the network.
const poll = window.setInterval(() => void load(false), LIVE_POLL_MS);
@@ -262,6 +456,8 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
(container as any)._cuCleanup = () => {
window.clearInterval(poll);
window.clearInterval(tick);
+ window.removeEventListener('pointerup', endDrag);
+ window.removeEventListener('pointercancel', endDrag);
unsubscribe();
};
}