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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue