Add Svelte 5 frontend (App, TitleBar, BlockRing, ModelStack, WeeklyBar, Settings)
This commit is contained in:
parent
14ffcf4bd3
commit
0e8a87fbc5
17 changed files with 779 additions and 0 deletions
12
index.html
Normal file
12
index.html
Normal 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
28
package.json
Normal 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
71
src/components/App.svelte
Normal 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>
|
||||||
77
src/components/BlockRing.svelte
Normal file
77
src/components/BlockRing.svelte
Normal 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>
|
||||||
61
src/components/ModelStack.svelte
Normal file
61
src/components/ModelStack.svelte
Normal 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>
|
||||||
158
src/components/Settings.svelte
Normal file
158
src/components/Settings.svelte
Normal 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>
|
||||||
37
src/components/TitleBar.svelte
Normal file
37
src/components/TitleBar.svelte
Normal 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>
|
||||||
69
src/components/WeeklyBar.svelte
Normal file
69
src/components/WeeklyBar.svelte
Normal 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
30
src/format.ts
Normal 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
17
src/ipc.ts
Normal 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
7
src/main.ts
Normal 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
96
src/styles.css
Normal 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
60
src/types.ts
Normal 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
5
svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
21
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
Add table
Add a link
Reference in a new issue