Add Svelte 5 frontend (App, TitleBar, BlockRing, ModelStack, WeeklyBar, Settings)

This commit is contained in:
megaproxy 2026-05-09 00:07:02 +01:00
parent 14ffcf4bd3
commit 0e8a87fbc5
17 changed files with 779 additions and 0 deletions

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Usage</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "claude-usage-widget",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-autostart": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}

71
src/components/App.svelte Normal file
View file

@ -0,0 +1,71 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { UsageSnapshot } from "../types";
import { getSnapshot, onUsageUpdated } from "../ipc";
import TitleBar from "./TitleBar.svelte";
import BlockRing from "./BlockRing.svelte";
import ModelStack from "./ModelStack.svelte";
import WeeklyBar from "./WeeklyBar.svelte";
import Settings from "./Settings.svelte";
let snap = $state<UsageSnapshot | null>(null);
let showSettings = $state(false);
let unlisten: (() => void) | null = null;
let tickHandle: ReturnType<typeof setInterval> | null = null;
// Locally-decremented countdown so we don't IPC just to tick the clock.
let nowSeconds = $state(0);
function updateFromSnapshot(s: UsageSnapshot) {
snap = s;
nowSeconds = s.block?.seconds_remaining ?? 0;
}
onMount(async () => {
try {
updateFromSnapshot(await getSnapshot());
} catch (e) {
console.error("initial snapshot failed", e);
}
try {
unlisten = await onUsageUpdated(updateFromSnapshot);
} catch (e) {
console.error("listen failed", e);
}
tickHandle = setInterval(() => {
if (nowSeconds > 0) nowSeconds -= 1;
}, 1000);
});
onDestroy(() => {
if (unlisten) unlisten();
if (tickHandle) clearInterval(tickHandle);
});
</script>
<TitleBar onSettings={() => (showSettings = true)} />
{#if snap}
<BlockRing block={snap.block} cap={snap.caps.block_tokens} {nowSeconds} />
<ModelStack
breakdown={snap.block?.by_family ?? snap.weekly.by_family}
/>
<WeeklyBar weekly={snap.weekly} cap={snap.caps.weekly_tokens} />
{:else}
<div class="loading">Loading transcripts…</div>
{/if}
{#if showSettings}
<Settings onClose={() => (showSettings = false)} />
{/if}
<style>
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
font-size: 12px;
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { BlockSummary } from "../types";
import { formatTokens, formatCountdown, formatPct } from "../format";
let {
block,
cap,
nowSeconds,
}: {
block: BlockSummary | null;
cap: number;
/** Locally-decremented countdown so we don't IPC just to tick the clock. */
nowSeconds: number;
} = $props();
// Geometry.
const SIZE = 140;
const STROKE = 12;
const R = (SIZE - STROKE) / 2;
const C = 2 * Math.PI * R;
let pct = $derived(block && cap > 0 ? Math.min(1, block.total_tokens / cap) : 0);
let dash = $derived(C * pct);
let color = $derived(
pct > 0.95 ? "var(--danger)" : pct > 0.8 ? "var(--warn)" : "var(--accent)",
);
let pulse = $derived(pct > 0.9);
</script>
<div class="wrap">
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
<!-- track -->
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={R}
fill="none"
stroke="var(--border)"
stroke-width={STROKE}
/>
<!-- progress -->
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={R}
fill="none"
stroke={color}
stroke-width={STROKE}
stroke-linecap="round"
stroke-dasharray={`${dash} ${C}`}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
class:pulse
/>
<text x={SIZE / 2} y={SIZE / 2 - 6} text-anchor="middle" class="big">
{block ? formatTokens(block.total_tokens) : "—"}
</text>
<text x={SIZE / 2} y={SIZE / 2 + 12} text-anchor="middle" class="small">
{block ? formatPct(block.total_tokens, cap) : ""}
</text>
<text x={SIZE / 2} y={SIZE / 2 + 30} text-anchor="middle" class="countdown">
{block ? formatCountdown(nowSeconds) : "no activity"}
</text>
</svg>
</div>
<style>
.wrap { display: flex; justify-content: center; padding: 14px 0 6px; }
text { fill: var(--fg); font-family: inherit; }
text.big { font-size: 22px; font-weight: 600; }
text.small { font-size: 11px; fill: var(--fg-dim); }
text.countdown { font-size: 11px; fill: var(--fg-dim); font-variant-numeric: tabular-nums; }
.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
</style>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { ModelBreakdown } from "../types";
import { formatTokens } from "../format";
let { breakdown }: { breakdown: ModelBreakdown } = $props();
const rows = $derived([
{ key: "opus", label: "Opus", color: "var(--opus)", value: breakdown.opus },
{ key: "sonnet", label: "Sonnet", color: "var(--sonnet)", value: breakdown.sonnet },
{ key: "haiku", label: "Haiku", color: "var(--haiku)", value: breakdown.haiku },
{ key: "other", label: "Other", color: "var(--other)", value: breakdown.other },
].filter((r) => r.value > 0));
let total = $derived(rows.reduce((a, r) => a + r.value, 0));
</script>
<div class="section">
<div class="label">Models (current block)</div>
{#if total === 0}
<div class="muted">no model activity</div>
{:else}
<div class="bar">
{#each rows as r (r.key)}
<span
class="seg"
style="background:{r.color}; flex:{r.value};"
title="{r.label}: {formatTokens(r.value)}"
></span>
{/each}
</div>
<div class="legend">
{#each rows as r (r.key)}
<span class="chip">
<span class="dot" style="background:{r.color}"></span>
{r.label} {formatTokens(r.value)}
</span>
{/each}
</div>
{/if}
</div>
<style>
.bar {
display: flex;
height: 10px;
border-radius: 5px;
overflow: hidden;
background: var(--bg-card);
}
.seg { display: block; }
.legend {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
font-size: 11px;
color: var(--fg-dim);
}
.chip { display: inline-flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from "svelte";
import {
enable as enableAutostart,
disable as disableAutostart,
isEnabled as isAutostartEnabled,
} from "@tauri-apps/plugin-autostart";
import { getSettings, setSettings, listDistros, getRoots } from "../ipc";
import type { Settings, ResolvedRoots } from "../types";
let { onClose }: { onClose: () => void } = $props();
let settings = $state<Settings | null>(null);
let distros = $state<string[]>([]);
let roots = $state<ResolvedRoots | null>(null);
let busy = $state(false);
let err = $state<string | null>(null);
onMount(async () => {
try {
settings = await getSettings();
distros = await listDistros();
roots = await getRoots();
// Sync displayed autostart from the plugin's actual state.
const enabled = await isAutostartEnabled();
if (settings && settings.autostart !== enabled) {
settings = { ...settings, autostart: enabled };
}
} catch (e) {
err = `${e}`;
}
});
async function save() {
if (!settings) return;
busy = true;
err = null;
try {
// Autostart state must be applied via the plugin, not just stored.
if (settings.autostart) await enableAutostart();
else await disableAutostart();
await setSettings(settings);
onClose();
} catch (e) {
err = `${e}`;
} finally {
busy = false;
}
}
</script>
<div class="overlay">
<div class="panel">
<div class="row spread">
<strong>Settings</strong>
<button class="icon" onclick={onClose} aria-label="Close settings">×</button>
</div>
{#if err}<div class="error">{err}</div>{/if}
{#if settings}
<div class="field">
<div class="label">5-hour block cap (tokens)</div>
<input
type="number"
min="0"
step="10000"
bind:value={settings.caps.block_tokens}
/>
</div>
<div class="field">
<div class="label">Weekly cap (tokens)</div>
<input
type="number"
min="0"
step="100000"
bind:value={settings.caps.weekly_tokens}
/>
</div>
<div class="field">
<div class="label">WSL distro</div>
<select bind:value={settings.wsl_distro_override}>
<option value={null}> auto-detect —</option>
{#each distros as d (d)}
<option value={d}>{d}</option>
{/each}
</select>
</div>
<label class="check">
<input type="checkbox" bind:checked={settings.include_native} />
Also scan native <code>%USERPROFILE%\.claude\projects</code>
</label>
<label class="check">
<input type="checkbox" bind:checked={settings.autostart} />
Start with Windows
</label>
{#if roots}
<div class="field">
<div class="label">Active roots</div>
{#if roots.roots.length === 0}
<div class="muted">none — Claude Code transcripts not found yet</div>
{:else}
<ul class="roots">
{#each roots.roots as r (r)}
<li><code>{r}</code></li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="row spread actions">
<button onclick={onClose}>Cancel</button>
<button onclick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</button>
</div>
{:else}
<div class="muted">Loading…</div>
{/if}
</div>
</div>
<style>
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
display: flex;
align-items: stretch;
z-index: 10;
}
.panel {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.check { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); }
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
.roots code { font-size: 10px; word-break: break-all; }
.error {
background: rgba(255, 107, 107, 0.15);
border: 1px solid var(--danger);
color: var(--fg);
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
}
</style>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { quitApp, forceRescan } from "../ipc";
let { onSettings }: { onSettings: () => void } = $props();
</script>
<!--
data-tauri-drag-region: anywhere with this attribute is grabbable as a
window-drag handle. https://v2.tauri.app/learn/window-customization/
-->
<header data-tauri-drag-region>
<span class="title" data-tauri-drag-region>Claude Usage</span>
<div class="actions">
<button class="icon" title="Refresh" onclick={forceRescan} aria-label="Refresh"></button>
<button class="icon" title="Settings" onclick={onSettings} aria-label="Settings"></button>
<button class="icon" title="Quit" onclick={quitApp} aria-label="Quit">×</button>
</div>
</header>
<style>
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid var(--border);
height: 28px;
box-sizing: border-box;
}
.title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--fg-dim);
cursor: grab;
}
.actions { display: inline-flex; gap: 2px; }
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { WeeklySummary } from "../types";
import { formatTokens, formatPct, weekdayShort } from "../format";
let {
weekly,
cap,
}: {
weekly: WeeklySummary;
cap: number;
} = $props();
let max = $derived(
Math.max(1, ...weekly.by_day.map((d) => d.total_tokens))
);
</script>
<div class="section">
<div class="row spread">
<div class="label">7-day total</div>
<div class="muted">{formatTokens(weekly.total_tokens)} · {formatPct(weekly.total_tokens, cap)}</div>
</div>
<div class="bars">
{#each weekly.by_day as d (d.date_local)}
<div class="col" title="{d.date_local}: {formatTokens(d.total_tokens)}">
<div class="bar-track">
<div
class="bar-fill"
style="height: {(d.total_tokens / max) * 100}%"
></div>
</div>
<div class="day-label">{weekdayShort(d.date_local)}</div>
</div>
{/each}
</div>
</div>
<style>
.bars {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-top: 6px;
height: 50px;
align-items: end;
}
.col {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
gap: 2px;
}
.bar-track {
flex: 1;
width: 100%;
background: var(--bg-card);
border-radius: 3px;
display: flex;
align-items: end;
overflow: hidden;
}
.bar-fill {
width: 100%;
background: var(--accent);
min-height: 1px;
}
.day-label { font-size: 9px; color: var(--fg-dim); }
</style>

30
src/format.ts Normal file
View file

@ -0,0 +1,30 @@
// Number formatting that fits a 280px-wide widget.
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 10_000) return `${(n / 1_000).toFixed(0)}k`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toString();
}
export function formatPct(num: number, denom: number): string {
if (denom <= 0) return "0%";
const pct = Math.min(999, Math.round((num / denom) * 100));
return `${pct}%`;
}
export function formatCountdown(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
}
/// "2026-05-09" → "Sat" (weekday in user's local).
export function weekdayShort(date_local: string): string {
// date_local is already a calendar day; treat it as local midnight.
const d = new Date(`${date_local}T00:00:00`);
return d.toLocaleDateString(undefined, { weekday: "short" });
}

17
src/ipc.ts Normal file
View file

@ -0,0 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { ResolvedRoots, Settings, UsageSnapshot } from "./types";
export const getSnapshot = (): Promise<UsageSnapshot> => invoke("get_snapshot");
export const getSettings = (): Promise<Settings> => invoke("get_settings");
export const setSettings = (next: Settings): Promise<void> =>
invoke("set_settings", { new: next });
export const listDistros = (): Promise<string[]> => invoke("list_distros");
export const getRoots = (): Promise<ResolvedRoots> => invoke("get_roots");
export const forceRescan = (): Promise<void> => invoke("force_rescan");
export const quitApp = (): Promise<void> => invoke("quit_app");
export const onUsageUpdated = (
cb: (snap: UsageSnapshot) => void,
): Promise<UnlistenFn> =>
listen<UsageSnapshot>("usage-updated", (e) => cb(e.payload));

7
src/main.ts Normal file
View file

@ -0,0 +1,7 @@
import { mount } from "svelte";
import App from "./components/App.svelte";
import "./styles.css";
const app = mount(App, { target: document.getElementById("app")! });
export default app;

96
src/styles.css Normal file
View file

@ -0,0 +1,96 @@
/* Glass / always-on-top widget look. */
:root {
--bg: rgba(18, 20, 26, 0.78);
--bg-card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
--fg: #e8eaf0;
--fg-dim: #9aa0aa;
--accent: #b08bff; /* Anthropic-ish purple */
--opus: #b08bff;
--sonnet: #6ec1ff;
--haiku: #7ee0a3;
--other: #d8d8d8;
--warn: #ffb454;
--danger: #ff6b6b;
--radius: 10px;
--pad: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: var(--fg);
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent; /* honor Tauri transparent:true */
user-select: none;
-webkit-user-select: none;
}
#app {
width: 100vw;
height: 100vh;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
backdrop-filter: blur(14px);
}
button {
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
}
button:hover { background: rgba(255, 255, 255, 0.06); }
button.icon {
border: none;
padding: 4px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
}
button.icon:hover { color: var(--fg); }
input[type="number"], input[type="text"], select {
font: inherit;
color: inherit;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
width: 100%;
box-sizing: border-box;
}
.section {
padding: 8px var(--pad);
border-top: 1px solid var(--border);
}
.section:first-child { border-top: none; }
.label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--fg-dim);
margin-bottom: 4px;
}
.row { display: flex; align-items: center; gap: 8px; }
.row.spread { justify-content: space-between; }
.muted { color: var(--fg-dim); font-size: 11px; }

60
src/types.ts Normal file
View file

@ -0,0 +1,60 @@
// Mirrors the Rust serde structs in src-tauri/src/{usage,settings,paths}.rs.
// Keep in lockstep — if you rename a field on one side, rename it here too.
export type ModelFamily = "opus" | "sonnet" | "haiku" | "other";
export interface ModelBreakdown {
opus: number;
sonnet: number;
haiku: number;
other: number;
}
export interface BlockSummary {
block_start: string; // ISO 8601
block_end: string;
now: string;
seconds_remaining: number;
total_tokens: number;
by_family: ModelBreakdown;
message_count: number;
}
export interface DayBucket {
date_local: string; // YYYY-MM-DD
total_tokens: number;
}
export interface WeeklySummary {
window_start: string;
window_end: string;
total_tokens: number;
by_day: DayBucket[]; // length 7, oldest-first
by_family: ModelBreakdown;
}
export interface Caps {
block_tokens: number;
weekly_tokens: number;
}
export interface UsageSnapshot {
block: BlockSummary | null;
weekly: WeeklySummary;
caps: Caps;
generated_at: string;
}
export interface Settings {
caps: Caps;
wsl_distro_override: string | null;
include_native: boolean;
window_pos: [number, number] | null;
autostart: boolean;
}
export interface ResolvedRoots {
roots: string[];
wsl_distro: string | null;
native_present: boolean;
}

5
svelte.config.js Normal file
View file

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"moduleResolution": "Bundler",
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// Tauri 2 expects the dev server on a fixed port and forwards the URL into
// the webview. https://v2.tauri.app/start/frontend/vite/
export default defineConfig(async () => ({
plugins: [svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: "127.0.0.1",
hmr: { protocol: "ws", host: "127.0.0.1", port: 1421 },
watch: { ignored: ["**/src-tauri/**"] },
},
build: {
target: "esnext",
minify: "esbuild",
sourcemap: false,
},
}));