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}
+
+
${title}
${body}
`; } 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}
+
+
${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(); }; }