`).join('');
}
@@ -176,9 +216,9 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
const statCards = `
${stats.map(([label, val]) => `
-
-
${val}
-
${label}
+
`).join('')}
`;
return `
@@ -203,10 +243,20 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
const root = document.createElement('div');
Object.assign(root.style, {
height: '100%', overflowY: 'auto', boxSizing: 'border-box',
- padding: '24px', fontFamily: MONO,
+ padding: '24px', fontFamily: SANS,
});
container.appendChild(root);
+ // Interaction states can't be expressed inline; colours come from --cu-*
+ // properties set on the element so they follow the host theme.
+ const style = document.createElement('style');
+ style.textContent = `
+ .cu-refresh { transition: background 0.15s, color 0.15s; }
+ .cu-refresh:hover { background: var(--cu-hover-bg) !important; color: var(--cu-hover-fg) !important; }
+ .cu-refresh:focus-visible { outline: 2px solid var(--cu-ring); outline-offset: 2px; }
+ `;
+ container.appendChild(style);
+
const state: State = { live: null, liveError: null, history: null, historyError: null };
function render(ctx: PluginContext): void {
@@ -215,8 +265,8 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
root.style.color = c.text;
root.innerHTML = `
-
Claude Usage▌
-
↻ refresh
+
Claude Usage▌
+
↻ refresh
${renderLive(c, state.live, state.liveError)}
${renderHistory(c, state.history, state.historyError)}
From 6fc757000847395a408e46ddba3ed346d5f82fa0 Mon Sep 17 00:00:00 2001
From: ango-dev <94287574+ango-dev@users.noreply.github.com>
Date: Sun, 11 Oct 2026 06:37:31 +0000
Subject: [PATCH 2/4] Remove the cursor glyph after the title
---
src/index.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/index.ts b/src/index.ts
index 6512de5..97bc3a2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -265,7 +265,7 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
root.style.color = c.text;
root.innerHTML = `
-
Claude Usage▌
+
Claude Usage
↻ refresh
${renderLive(c, state.live, state.liveError)}
From 011ca1d57936c2ae21d3ee16488e0717d64bd87d Mon Sep 17 00:00:00 2001
From: ango-dev <94287574+ango-dev@users.noreply.github.com>
Date: Sun, 11 Oct 2026 23:13:14 +0000
Subject: [PATCH 3/4] Match the host's new look; fit phone widths
- Replace the large serif page title with a header bar like the host's
built-in Browser tab: icon, title, plan badge, subtitle and a ghost
refresh icon button that spins during a full load.
- Update colour fallbacks to the host's exact token values, including the
darker, neutral dark-mode border.
- Hover styles apply only on devices with a real pointer, as in the host.
- Lay out by container width so the panel fits phones and a narrowed
workspace: stats drop to two columns, the model and project tables
stack, and padding tightens.
- The header stays fixed while the body scrolls, and the body keeps its
scroll position across re-renders.
The plan badge moves to the header; the "updated" age stays on the plan
limits card. The tab keeps its "Claude Usage" name.
(cherry picked from ango-dev/cloudcli-plugin-claude-usage@53b4809)
---
src/index.ts | 130 +++++++++++++++++++++++++++++++++++++++------------
1 file changed, 99 insertions(+), 31 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 97bc3a2..499ffdd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -34,7 +34,6 @@ const TICK_MS = 60_000;
const SANS = "Geist, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
const MONO = "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
-const SERIF = "'Source Serif 4', ui-serif, Georgia, serif";
/** Corner radius derived from the host's --radius. */
function radius(minusPx = 0): string {
@@ -43,14 +42,14 @@ function radius(minusPx = 0): string {
const FALLBACKS = {
light: {
- background: '60 14% 99%', foreground: '60 3% 8%', card: '0 0% 100%',
- primary: '15 58% 43%', muted: '60 3% 94%', accent: '48 12% 92%', 'muted-foreground': '50 3% 41%',
- border: '60 2% 88%', input: '60 2% 87%', ring: '15 56% 52%', destructive: '4 59% 44%',
+ 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%', foreground: '45 12% 93%', card: '60 2% 12%',
- primary: '15 65% 63%', muted: '0 0% 13%', accent: '0 1% 17%', 'muted-foreground': '45 4% 56%',
- border: '30 2% 18%', input: '30 2% 21%', ring: '15 65% 63%', destructive: '10 72% 67%',
+ 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;
@@ -138,10 +137,13 @@ function errorNote(c: ThemeColors, msg: string): string {
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)} `
: '';
@@ -161,8 +163,8 @@ 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)}
@@ -172,7 +174,7 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
`;
}).join('');
}
- return card(c, `plan limits ${badge}${updated}`, body);
+ return card(c, `plan limits ${updated}`, body);
}
function renderChart(c: ThemeColors, daily: HistoryData['daily']): string {
@@ -214,17 +216,17 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
['sessions', String(t.sessions)],
];
const statCards = `
-
+
${stats.map(([label, val]) => `
-
${val}
+
${val}
${label}
`).join('')}
`;
return `
${statCards}
${card(c, 'daily tokens (30 days)', renderChart(c, history.daily))}
-
+
${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])))}
`;
@@ -239,39 +241,98 @@ interface State {
historyError: string | null;
}
+// 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-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;
+}
+
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: SANS,
+ 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);
- // Interaction states can't be expressed inline; colours come from --cu-*
- // properties set on the element so they follow the host theme.
const style = document.createElement('style');
- style.textContent = `
- .cu-refresh { transition: background 0.15s, color 0.15s; }
- .cu-refresh:hover { background: var(--cu-hover-bg) !important; color: var(--cu-hover-fg) !important; }
- .cu-refresh:focus-visible { outline: 2px solid var(--cu-ring); outline-offset: 2px; }
- `;
+ style.textContent = STYLES;
container.appendChild(style);
- const state: State = { live: null, liveError: null, history: null, historyError: null };
+ const state: State = { live: null, liveError: null, history: null, historyError: null, loading: false };
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
-
↻ refresh
+ header.style.borderBottom = `1px solid ${c.border}`;
+ header.innerHTML = `
+
+
+ ${GAUGE_ICON}
+
Claude Usage
+ ${planBadge(c, state.live)}
+
+
Plan limits and 30-day token history
+
+ ${REFRESH_ICON}
+
+ `;
+ body.innerHTML = `
${renderLive(c, state.live, state.liveError)}
${renderHistory(c, state.history, state.historyError)}
`;
- root.querySelector('#cu-refresh')?.addEventListener('click', () => void load(true));
+ header.querySelector('#cu-refresh')?.addEventListener('click', () => void load(true));
}
async function loadLive(): Promise
{
@@ -295,8 +356,15 @@ 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);
}
From 38218804577f79917576b77a445fdead7b34be70 Mon Sep 17 00:00:00 2001
From: ango-dev <94287574+ango-dev@users.noreply.github.com>
Date: Fri, 16 Oct 2026 07:30:33 +0000
Subject: [PATCH 4/4] Show a chart day's figures on hover, tap or keyboard
The day's tokens and cost were only in a title tooltip, which touch
screens never show. Hovering a bar with a mouse, tapping or dragging a
finger along the bars, or using the arrow keys once the chart has focus
now marks that day's column and puts its date, tokens and cost in the
line under the chart, in place of the date range. Leaving the chart,
tapping elsewhere or Escape goes back to the range.
Also drops a stale duplicate of the State interface.
---
src/index.ts | 114 +++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 96 insertions(+), 18 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 499ffdd..6ef58c9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -55,7 +55,7 @@ const FALLBACKS = {
type HostVar = keyof typeof FALLBACKS.light;
-interface ThemeColors { bg: string; surface: string; border: string; track: string; text: string; muted: string; accent: string; hover: string; ring: string; ok: string; warn: string; bad: string }
+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 {
const fb = FALLBACKS[dark ? 'dark' : 'light'];
@@ -69,6 +69,8 @@ function themeColors(dark: boolean): ThemeColors {
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.
@@ -177,19 +179,26 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
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 {
@@ -202,7 +211,7 @@ function renderTable(c: ThemeColors, rows: [string, TokenCounts, number | null][
`).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) {
@@ -225,7 +234,7 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
`;
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])))}
@@ -234,13 +243,6 @@ function renderHistory(c: ThemeColors, history: HistoryData | null, error: strin
// ── Mount / Unmount ─────────────────────────────────────────────────────
-interface State {
- live: LiveData | null;
- liveError: string | null;
- history: HistoryData | null;
- historyError: string | null;
-}
-
// Lucide-style glyphs drawn like the host's built-in tab headers.
const GAUGE_ICON = '
';
const REFRESH_ICON = '
';
@@ -259,6 +261,10 @@ const STYLES = `
.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) {
@@ -281,6 +287,8 @@ interface State {
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 {
@@ -307,7 +315,7 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
style.textContent = STYLES;
container.appendChild(style);
- const state: State = { live: null, liveError: null, history: null, historyError: null, loading: false };
+ 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');
@@ -330,7 +338,7 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
`;
body.innerHTML = `
${renderLive(c, state.live, state.liveError)}
- ${renderHistory(c, state.history, state.historyError)}
+ ${renderHistory(c, state.history, state.historyError, state.activeDay)}
`;
header.querySelector('#cu-refresh')?.addEventListener('click', () => void load(true));
}
@@ -371,6 +379,74 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
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);
@@ -380,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();
};
}