diff --git a/index.html b/index.html new file mode 100644 index 0000000..0fca78c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Claude Usage + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3491c5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/components/App.svelte b/src/components/App.svelte new file mode 100644 index 0000000..f9f8107 --- /dev/null +++ b/src/components/App.svelte @@ -0,0 +1,71 @@ + + + (showSettings = true)} /> + +{#if snap} + + + +{:else} +
Loading transcripts…
+{/if} + +{#if showSettings} + (showSettings = false)} /> +{/if} + + diff --git a/src/components/BlockRing.svelte b/src/components/BlockRing.svelte new file mode 100644 index 0000000..cd47549 --- /dev/null +++ b/src/components/BlockRing.svelte @@ -0,0 +1,77 @@ + + +
+ + + + + + + {block ? formatTokens(block.total_tokens) : "—"} + + + {block ? formatPct(block.total_tokens, cap) : ""} + + + {block ? formatCountdown(nowSeconds) : "no activity"} + + +
+ + diff --git a/src/components/ModelStack.svelte b/src/components/ModelStack.svelte new file mode 100644 index 0000000..45364db --- /dev/null +++ b/src/components/ModelStack.svelte @@ -0,0 +1,61 @@ + + +
+
Models (current block)
+ {#if total === 0} +
no model activity
+ {:else} +
+ {#each rows as r (r.key)} + + {/each} +
+
+ {#each rows as r (r.key)} + + + {r.label} {formatTokens(r.value)} + + {/each} +
+ {/if} +
+ + diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte new file mode 100644 index 0000000..dd20e70 --- /dev/null +++ b/src/components/Settings.svelte @@ -0,0 +1,158 @@ + + +
+
+
+ Settings + +
+ + {#if err}
{err}
{/if} + + {#if settings} +
+
5-hour block cap (tokens)
+ +
+ +
+
Weekly cap (tokens)
+ +
+ +
+
WSL distro
+ +
+ + + + + + {#if roots} +
+
Active roots
+ {#if roots.roots.length === 0} +
none — Claude Code transcripts not found yet
+ {:else} +
    + {#each roots.roots as r (r)} +
  • {r}
  • + {/each} +
+ {/if} +
+ {/if} + +
+ + +
+ {:else} +
Loading…
+ {/if} +
+
+ + diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte new file mode 100644 index 0000000..253167c --- /dev/null +++ b/src/components/TitleBar.svelte @@ -0,0 +1,37 @@ + + + +
+ Claude Usage +
+ + + +
+
+ + diff --git a/src/components/WeeklyBar.svelte b/src/components/WeeklyBar.svelte new file mode 100644 index 0000000..797cc19 --- /dev/null +++ b/src/components/WeeklyBar.svelte @@ -0,0 +1,69 @@ + + +
+
+
7-day total
+
{formatTokens(weekly.total_tokens)} · {formatPct(weekly.total_tokens, cap)}
+
+
+ {#each weekly.by_day as d (d.date_local)} +
+
+
+
+
{weekdayShort(d.date_local)}
+
+ {/each} +
+
+ + diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..92731e2 --- /dev/null +++ b/src/format.ts @@ -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" }); +} diff --git a/src/ipc.ts b/src/ipc.ts new file mode 100644 index 0000000..245a30e --- /dev/null +++ b/src/ipc.ts @@ -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 => invoke("get_snapshot"); +export const getSettings = (): Promise => invoke("get_settings"); +export const setSettings = (next: Settings): Promise => + invoke("set_settings", { new: next }); +export const listDistros = (): Promise => invoke("list_distros"); +export const getRoots = (): Promise => invoke("get_roots"); +export const forceRescan = (): Promise => invoke("force_rescan"); +export const quitApp = (): Promise => invoke("quit_app"); + +export const onUsageUpdated = ( + cb: (snap: UsageSnapshot) => void, +): Promise => + listen("usage-updated", (e) => cb(e.payload)); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..953b0de --- /dev/null +++ b/src/main.ts @@ -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; diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..2c0bf0d --- /dev/null +++ b/src/styles.css @@ -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; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..bb7330a --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4091665 --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..3adda81 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7738ba7 --- /dev/null +++ b/vite.config.ts @@ -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, + }, +}));