Sanduhr's session and weekly meters above the prompt: bars with the pace mark, reset countdowns, your Style popover looks animated (sweep, shimmer, glow)…

<img src="docs/images/icon-512.png" width="160" alt="Sanduhr icon">
<h1 align="center">Sanduhr für Claude</h1>
A native desktop widget that turns your Claude.ai subscription usage into something you can actually pace yourself by — burn-rate projection, pace markers, sparkline trends, and five hand-tuned glass themes.
<a href="https://github.com/estevanhernandez-stack-ed/Sanduhr_f-r_Claude/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/estevanhernandez-stack-ed/Sanduhr_f-r_Claude?label=release"></a> <a href="https://github.com/estevanhernandez-stack-ed/Sanduhr_f-r_Claude/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/estevanhernandez-stack-ed/Sanduhr_f-r_Claude?style=flat"></a> <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-4ade80"></a> <a href="https://estevanhernandez-stack-ed.github.io/Sanduhr_f-r_Claude/"><img alt="Landing page" src="https://img.shields.io/badge/landing-page-3bb4d9"></a>
<strong><a href="https://estevanhernandez-stack-ed.github.io/Sanduhr_f-r_Claude/">🌐 Landing page</a></strong> · <strong>macOS</strong> · <strong>Windows 11</strong> · <strong>Python (any OS)</strong>
<sub>Independent third-party tool. Not affiliated with Anthropic. Requires an active Claude Pro / Team / Enterprise subscription.</sub>
Via Homebrew (recommended):
brew tap estevanhernandez-stack-ed/tap
brew install --cask sanduhr
Via DMG: download the latest Sanduhr.dmg from [Releases][releases] and drag to Applications.
com.626labs.sanduhr) on release builds, and in a permissions-restricted file (~/Library/Application Support/Sanduhr/credentials.json, mode 0600) on dev builds — see mac/README.md.brew upgrade --cask sanduhr also works.Homebrew/homebrew-cask is pending the project's notability bar (90 forks / 90 watchers / 225 stars).[releases]: https://github.com/estevanhernandez-stack-ed/Sanduhr_f-r_Claude/releases [tap]: https://github.com/estevanhernandez-stack-ed/homebrew-tap
Via the Microsoft Store (recommended): search Sanduhr für Claude (publisher 626Labs LLC) and install. Store-signed, no SmartScreen prompt, updates arrive through the Store.
Via GitHub Release: download 626Labs.Sanduhr-win-Setup.exe from [Releases][releases], click through SmartScreen ("More info → Run anyway"; the installer is unsigned, the Store package is the signed path), and run it. Installs per-user and updates itself from the release feed. A portable zip sits on the same page.
com.626labs.sanduhr). Uninstall does not clear these entries on either channel — use Sign Out first, or delete them from Credential Manager yourself.windows-dotnet/. The retired Python apps (tkinter v1 and the PySide6 build) were removed on 2026-09-13; they live at tag legacy/python-v2.3.0..msix sideload note, and uninstall behaviour: INSTALL.md.sanduhr-mcp (Windows 3.4.0+)Settings ▸ Claude Usage ▸ Install statusline… puts your usage percentages and reset times under the Claude Code prompt of the one home you pick. After each fetch the widget writes %APPDATA%\Sanduhr\snapshot.json; the statusline script and the sanduhr-mcp server (get_usage, get_local_burn_by_project, get_model_usage, get_usage_history, ping, publish_usage, propose_theme) read that file and nothing else that could hold a credential.
sanduhr-mcp requires widget 3.4.0 or later. Older widgets (the Store's 3.3.0 included) poll and keep history but have no snapshot writer, so the MCP server would read a missing file, or a dead one left behind by a development build. When that happens get_usage and ping answer reason: widget_too_old with the remedy "update it", and serve the widget's latest history point as status: degraded (utilization and reset times only) rather than nothing. widget_not_polling now means exactly that: nothing on the machine has polled in 15 minutes.%APPDATA%\Sanduhr\mcp\, writes the launcher %APPDATA%\Sanduhr\bin\sanduhr-mcp.cmd, and adds a sanduhr entry to that home's .claude.json with a timestamped backup beside it. New Claude Code sessions pick it up; the widget refreshes the files on every start. Remove MCP server reverts all of it..mcp.json: claude mcp add --scope user sanduhr -- %APPDATA%\Sanduhr\bin\sanduhr-mcp.cmd. Remove it with claude mcp remove sanduhr. Check the pairing any time with the ping tool: it reports the widget version that wrote the snapshot against the 3.4.0 floor.propose_theme: the widget lints the palette (schema plus the design rules in docs/themes/AGENT_PROMPT.md), saves it under %APPDATA%\Sanduhr\themes\, applies it, and tells the agent which theme was active before so you can go back. A rejected palette comes back with the fields to fix; the agent iterates. Settings ▸ Themes shows the same findings for anything you paste..json into your platform's themes folder.docs/themes/AGENT_PROMPT.md) — hand any chat agent a reference image or vibe description and get back a drop-in theme JSON.com.626labs.sanduhr). Uninstall does not clear these entries on either channel (GitHub .exe or Microsoft Store) — use Sign Out (below) first, or delete the entries from Credential Manager yourself. On macOS, release builds use the Keychain (service com.626labs.sanduhr; dev builds use a 0600 file) — see mac/README.md. Dragging the Mac app to the Trash removes neither: use Settings → Credentials → Sign Out first.%APPDATA%\Sanduhr\history.{Account}.json. Settings → History shows a stacked per-tier line chart with Week / Month windows + per-account / All-accounts overlay views. Export as CSV to analyze with any agent.Ctrl+R, Ctrl+,, Ctrl+D, Ctrl+H).claude.ai, using your own session cookie. See SECURITY.md for why no data ever comes back to us.<table> <tr> <td align="center" width="33%"><img src="docs/images/screenshots/theme-obsidian.png" width="260"><br><strong>Obsidian</strong><br><sub>deep black · purple accent</sub></td> <td align="center" width="33%"><img src="docs/images/screenshots/theme-aurora.png" width="260"><br><strong>Aurora</strong><br><sub>dark blue · cyan glow</sub></td> <td align="center" width="33%"><img src="docs/images/screenshots/theme-ember.png" width="260"><br><strong>Ember</strong><br><sub>dark red · orange warmth</sub></td> </tr> <tr> <td align="center" width="33%"><img src="docs/images/screenshots/theme-mint.png" width="260"><br><strong>Mint</strong><br><sub>dark green · teal glass</sub></td> <td align="center" width="33%"><img src="docs/images/screenshots/theme-626-labs.png" width="260"><br><strong>626 Labs</strong><br><sub>navy · cyan · magenta</sub></td> <td align="center" width="33%"><img src="docs/images/screenshots/theme-matrix.png" width="260"><br><strong>Matrix</strong><br><sub>phosphor · CRT corners</sub></td> </tr> </table>
Drop a custom theme JSON into ~/Library/Application Support/Sanduhr/themes/ (macOS) or %APPDATA%\Sanduhr\themes\ (Windows) and it appears in the theme strip on next launch. Template + prompt at docs/themes/.
Launch Sanduhr and click Sign in to Claude. A secure in-app window opens on the real claude.ai login page; sign in normally (Google, email, or passkey) and Sanduhr captures your session automatically. No DevTools, no copy-paste. Your credentials are stored in the Windows Credential Manager, never in a file.
Session expired? Sanduhr shows Session expired — sign in again with a one-click button that re-authenticates the active account in place — your history is kept and no duplicate account is created. You can also re-authenticate any account from Settings → Accounts.
Prefer to paste the key by hand, or running the cross-platform Python build?
⌥⌘I on macOS, F12 on Windows).sessionKey cookie.Sanduhr hits two claude.ai endpoints — the same ones the settings page uses — to read your usage, and stores the cookie in Windows Credential Manager on Windows or, on macOS, the Keychain (release builds; a permissions-restricted 0600 file on dev builds). Nothing else leaves your machine.
| Action | Effect |
|---|---|
| 🎨 Theme | Open theme picker menu |
| ⚙ Settings | Credentials · Themes · Pacing · Help tabs |
| 📊 Graph | Cycle sparkline: Classic / Horizon |
| ↕ Compact | Toggle compact mode (Ctrl+D) |
| ⏳ Focus | Swap tier cards for the deep-work hourglass |
| 🐍 Snake | Play the cooldown snake game |
| Refresh | Force a data refresh (Ctrl+R) |
| Pin / Unpin | Toggle always-on-top |
| Drag anywhere | Reposition the widget |
| Drag any edge or corner | Resize the widget |
| Double-click anywhere | Toggle compact mode |
| Right-click | Refresh / Compact / Settings / Quit |
| × | Close Sanduhr |
Full keybindings documented in the in-app Settings → Help tab.
extra_usage tier (API credit spend tracking)estevanhernandez-stack-ed/tap (repo)Homebrew/homebrew-cask (pending notability bar)/usage endpoint)Sanduhr (ZAHND-oor) is German for "hourglass" — Sand + Uhr (sand clock). Für = "for." You're watching the sand drain on your Claude usage, pacing yourself so you don't run out before the reset.
MIT — do whatever you want with it. Built by 626Labs LLC (@626Labs-LLC on GitHub).
<sub> "Claude" and "claude.ai" are trademarks of Anthropic PBC, used nominatively to describe integration. Sanduhr für Claude is an independent third-party tool. </sub>
hooks/register.tsx 533 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BandStyle, MetersBand, MetersSnapshot } from '../types'
5import {
6 FAST_MS,
7 MAX_ROWS,
8 SHIMMER_BEFORE_MS,
9 SLOW_MS,
10 crossings,
11 glowLevel,
12 gradient,
13 isMoving,
14 letters,
15 liveWatchers,
16 mix,
17 nextFrameIn,
18 onceProgress,
19 paint,
20 parseBand,
21 pulseProgress,
22 watcherMovers,
23 watcherRows,
24} from './band'
25import type { Movers, Run, WatcherRow } from './band'
26import {
27 PACE_COLOR,
28 RESET_TOAST,
29 SESSION,
30 WEEKLY,
31 bandFor,
32 bar,
33 parseSnapshot,
34 remember,
35 sessionReset,
36 warningKeys,
37 warningToast,
38} from './meters'
39import type { Band, Bars, Meter, Style } from './meters'
40
41// Sanduhr's meters above the prompt, and its watchers (items 50, 65f, 66). Reads two files,
42// Sanduhr's snapshot.json and band.json, and nothing else: no network, no model calls, no writes
43// but the "already toasted" keys in $.store.
44
45const snapshot = atom({ plugin: 'sanduhr-meters', key: 'snapshot' } as const, null as MetersSnapshot | null)
46const bandFile = atom({ plugin: 'sanduhr-meters', key: 'band' } as const, null as MetersBand | null)
47
48const POLL_MS = 30_000
49// Watchers change by the second: band.json is a small local file, read every two.
50const BAND_POLL_MS = 2_000
51const TOASTED = 'toasted'
52const WARN_RED = '#f87171'
53const BAR_WIDTH: Record<Style, number> = { compact: 10, full: 20 }
54const NARROW_BAR = 6
55// Below this many columns a watcher shows its short title.
56const WIDE_ROW = 60
57
58type Motion = 'on' | 'off'
59type Options = { bars: Bars; style: Style; label: string; motion: Motion }
60
61// The module's own memory (a reload starts it over; session.start reads the files again).
62let opts: Options = { bars: 'both', style: 'compact', label: '', motion: 'on' }
63let lastText: string | null = null
64let current: MetersSnapshot | null = null
65let lastBandText: string | null = null
66let currentBand: MetersBand | null = null
67let lastSessionReset: number | null = null
68// Each limit's percent at the last reading, and when each one last crossed a warning line.
69let levels: Record<string, number> | null = null
70let sweeps: Record<string, number> = {}
71// Whether the band drew anything last, and what: a frame redraws only when that would change.
72let drawn = false
73let frameKey: string | null = null
74let timers: (() => void)[] = []
75let frameTimer: (() => void) | null = null
76
77function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
78 return allowed.includes(value as T) ? (value as T) : fallback
79}
80
81/** A file in Sanduhr's folder on this machine; `named` (an override variable's value) names another (testing). */
82async function sanduhrFile($: EngineInterface, name: string, named: string | undefined): Promise<string | null> {
83
84 if (named !== undefined && named !== '') {
85 return named
86 }
87
88 if ((await $.env.get('OS')) === 'Windows_NT') {
89 const appData = await $.env.get('APPDATA')
90
91 return appData === undefined || appData === '' ? null : `${appData}\\Sanduhr\\${name}`
92 }
93
94 const home = await $.env.get('HOME')
95
96 return home === undefined || home === '' ? null : `${home}/Library/Application Support/Sanduhr/${name}`
97}
98
99async function readFile($: EngineInterface, name: string, named: string | undefined): Promise<string | null> {
100 const path = await sanduhrFile($, name, named)
101
102 if (path === null) {
103 return null
104 }
105
106 try {
107 return await $.fs.read(path)
108 } catch {
109 // Missing (Sanduhr not installed, signed out, or nothing for the band): no band.
110 return null
111 }
112}
113
114/** A toast once per limit and reset window, and once per session reset, across sessions. */
115async function toasts($: EngineInterface, snap: MetersSnapshot | null, now: number) {
116 const tz = new Date(now).getTimezoneOffset()
117 const stored = await $.store.get(TOASTED)
118 const seen = Array.isArray(stored) ? stored.filter((k): k is string => typeof k === 'string') : []
119 let next = seen
120
121 for (const key of warningKeys(snap, now)) {
122 if (next.includes(key) || snap === null) {
123 continue
124 }
125
126 const text = warningToast(snap, key, now, tz)
127
128 if (text !== null) {
129 $.ui.toast(text, { timeoutMs: 8000 })
130 }
131
132 next = remember(next, key)
133 }
134
135 const reset = sessionReset(lastSessionReset, snap, now)
136
137 if (reset !== null && !next.includes(`reset@${reset}`)) {
138 $.ui.toast(RESET_TOAST, { timeoutMs: 6000 })
139 next = remember(next, `reset@${reset}`)
140 }
141
142 const session = snap?.kind === 'snapshot' ? snap.tiers.find(t => t.key === SESSION)?.resetsAt ?? null : null
143
144 if (session !== null) {
145 lastSessionReset = session
146 }
147
148 if (next !== seen) {
149 await $.store.set(TOASTED, next)
150 }
151}
152
153/** Each limit's percent in a fresh reading, for the warning-line sweeps. */
154function percents(snap: MetersSnapshot | null): Record<string, number> | null {
155 if (snap === null || snap.kind !== 'snapshot' || snap.status !== 'ok') {
156 return null
157 }
158
159 const out: Record<string, number> = {}
160
161 for (const t of snap.tiers) {
162 if (t.utilization !== null) {
163 out[t.key] = t.utilization
164 }
165 }
166
167 return out
168}
169
170/** Reads the snapshot; the drawing hears of it only when the numbers changed. */
171async function poll($: EngineInterface) {
172 const text = await readSnapshot($)
173 const now = await $.clock.now()
174
175 if (text !== lastText) {
176 lastText = text
177 current = text === null ? null : parseSnapshot(text)
178 const next = percents(current)
179
180 // A limit that crossed a warning line since the last reading sweeps its bar once.
181 if (next !== null) {
182 if (levels !== null) {
183 for (const key of crossings(levels, next)) {
184 sweeps[key] = now
185 }
186 }
187
188 levels = next
189 }
190
191 await update($, snapshot, () => current)
192 kick($)
193 }
194
195 await toasts($, current, now)
196}
197
198async function readSnapshot($: EngineInterface): Promise<string | null> {
199 return readFile($, 'snapshot.json', await $.env.get('SANDUHR_SNAPSHOT'))
200}
201
202/** Reads band.json: the looks, Reduce Motion and the watchers. */
203async function pollBand($: EngineInterface) {
204 const text = await readFile($, 'band.json', await $.env.get('SANDUHR_BAND'))
205
206 if (text !== lastBandText) {
207 lastBandText = text
208 currentBand = text === null ? null : parseBand(text)
209 await update($, bandFile, () => currentBand)
210 kick($)
211 }
212}
213
214function motionAllowed(band: MetersBand | null): boolean {
215 return opts.motion === 'on' && band?.reduceMotion !== true
216}
217
218function bandAt(snap: MetersSnapshot | null, now: number, s: Style): Band {
219 return bandFor(snap, now, { bars: opts.bars, style: s, tzOffsetMinutes: new Date(now).getTimezoneOffset() })
220}
221
222/** What may move now: sweeps, the pulses (a nearly full limit glows, one about to reset shimmers, waiting watchers), fades. */
223function movers(now: number): Movers {
224 const band = bandAt(current, now, opts.style)
225 const meters = band.kind === 'meters' && !band.isDim ? band.meters : []
226 const w = watcherMovers(liveWatchers(currentBand, now))
227 const shown = new Set(meters.map(m => m.key))
228
229 return {
230 sweeps: Object.entries(sweeps)
231 .filter(([k]) => shown.has(k))
232 .map(([, at]) => at),
233 pulses: w.pulses || meters.some(m => m.isWarning || isShimmering(m)),
234 fades: w.fades,
235 }
236}
237
238function isShimmering(m: Meter): boolean {
239 return m.resetLeft !== null && m.resetLeft <= SHIMMER_BEFORE_MS
240}
241
242/** What the band would draw at `now`, as a key: a frame redraws only when it changed. */
243function signature(now: number): string {
244 const moving = motionAllowed(currentBand)
245 const rows = watcherRows(liveWatchers(currentBand, now), now, { wide: true, motion: moving })
246 const frame = moving && isMoving(movers(now), now) ? Math.floor(now / FAST_MS) : null
247
248 // The pace fraction moves every millisecond; the mark moves by whole cells (at most 20), so hundredths do.
249 return JSON.stringify([bandAt(current, now, opts.style), rows, frame, currentBand?.styles ?? null], (k, v) =>
250 k === 'resetLeft' ? undefined : k === 'fraction' && typeof v === 'number' ? Math.floor(v * 100) : v,
251 )
252}
253
254/** One frame: redraw when something changed, then wait fast while a light moves, else a second. */
255async function frame($: EngineInterface) {
256 frameTimer = null
257 const now = await $.clock.now()
258
259 for (const [k, at] of Object.entries(sweeps)) {
260 if (onceProgress(at, now) === null && at <= now) {
261 delete sweeps[k]
262 }
263 }
264
265 if (drawn) {
266 const key = signature(now)
267
268 if (key !== frameKey) {
269 frameKey = key
270 $.ui.invalidate('ui.render')
271 }
272 }
273
274 schedule($, drawn ? nextFrameIn(movers(now), now, motionAllowed(currentBand)) : SLOW_MS)
275}
276
277function schedule($: EngineInterface, ms: number) {
278 frameTimer?.()
279 const t = $.clock.after(ms, () => {
280 void frame($).catch(() => undefined)
281 })
282 frameTimer = () => t.cancel()
283}
284
285/** Something new arrived: the next frame comes at once, not at the end of a resting second. */
286function kick($: EngineInterface) {
287 if (frameTimer !== null) {
288 schedule($, 0)
289 }
290}
291
292// -- Drawing ----------------------------------------------------------------------------------
293
294/** The Text attributes a style turns on. */
295type Attrs = { bold: boolean; italic: boolean; underline: boolean; dim: boolean }
296
297function attrsOf(style: BandStyle | undefined, bold = false): Attrs {
298 return { bold: bold || style?.bold === true, italic: style?.italic === true, underline: style?.underline === true, dim: style?.dim === true }
299}
300
301/** `text` in `style`'s letters and ink (else `fallback`), lit by `t` and `glow`. */
302function styled(text: string, style: BandStyle | undefined, fallback: string | null, t: number | null, glow: number) {
303 const chars = letters(text, style?.font ?? null)
304 const base = style !== undefined && style.ink.length > 0 ? gradient(style.ink, chars.length) : chars.map(() => fallback)
305
306 return paint(chars, base, t, glow)
307}
308
309function styleFor(band: MetersBand | null, key: string): BandStyle | undefined {
310 return key === SESSION ? band?.styles.session : key === WEEKLY ? band?.styles.weekly : undefined
311}
312
313export const register: Register = (on, options) => {
314 opts = {
315 bars: pick<Bars>(options.bars, ['both', 'session', 'weekly'], 'both'),
316 style: pick<Style>(options.style, ['compact', 'full'], 'compact'),
317 label: typeof options.label === 'string' ? options.label.trim() : '',
318 motion: pick<Motion>(options.motion, ['on', 'off'], 'on'),
319 }
320
321 on('session.start', async ($, e, next) => {
322 const started = await next(e)
323
324 for (const cancel of timers) {
325 cancel()
326 }
327
328 frameTimer?.()
329 frameTimer = null
330 await poll($).catch(() => undefined)
331 await pollBand($).catch(() => undefined)
332
333 const pollTimer = $.clock.every(POLL_MS, () => {
334 void poll($).catch(() => undefined)
335 })
336 const bandTimer = $.clock.every(BAND_POLL_MS, () => {
337 void pollBand($).catch(() => undefined)
338 })
339 timers = [() => pollTimer.cancel(), () => bandTimer.cancel()]
340 schedule($, SLOW_MS)
341
342 return started
343 })
344
345 // Shares the band: draws its lines above whatever the plugins beneath draw.
346 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
347 const below = await next(e)
348 const snap = await read($, snapshot)
349 const look = await read($, bandFile)
350
351 if (e.props.hasSurvey || e.props.maxRows < 1) {
352 drawn = false
353
354 return below
355 }
356
357 const now = await $.clock.now()
358 const width = e.props.bodyColumns
359 const moving = motionAllowed(look)
360 // Full style takes a row per meter; it folds to one line where the rows or columns are short.
361 const full = bandAt(snap, now, 'full')
362 const fullFits = full.kind !== 'meters' || (full.meters.length <= e.props.maxRows && width >= 64)
363 const shown = opts.style === 'full' && fullFits ? 'full' : 'compact'
364 const band = shown === 'full' ? full : bandAt(snap, now, 'compact')
365 const used = band.kind === 'none' ? 0 : band.kind === 'meters' && shown === 'full' ? band.meters.length : 1
366 const room = Math.max(0, Math.min(MAX_ROWS, e.props.maxRows - used))
367 const rows = watcherRows(liveWatchers(look, now), now, { wide: width >= WIDE_ROW, motion: moving })
368 drawn = band.kind !== 'none' || (rows.length > 0 && room > 0)
369 frameKey = signature(now)
370
371 if (!drawn) {
372 return below
373 }
374
375 const { Box, Text } = $.ui.resolve(e)
376 // Runs as Text elements in a style's attributes; a run without a color is dim when `dimBare`.
377 const texts = (prefix: string, runs: Run[], a: Attrs, dimBare: boolean) =>
378 runs.map((r, i) => (
379 <Text
380 key={`${prefix}${i}`}
381 color={r.color ?? undefined}
382 bold={a.bold ? true : undefined}
383 italic={a.italic ? true : undefined}
384 underline={a.underline ? true : undefined}
385 dimColor={a.dim || (r.color === null && dimBare) ? true : undefined}
386 >
387 {r.text}
388 </Text>
389 ))
390 const barWidth = shown === 'compact' && width < 80 ? NARROW_BAR : BAR_WIDTH[shown]
391 const plain = band.kind === 'meters' && band.isDim
392 const lit = moving && !plain
393
394 const meterRow = (m: Meter) => {
395 const style = styleFor(look, m.key)
396 const name = texts('n', styled(m.name, style, null, null, 0), attrsOf(style), plain)
397
398 if (m.isCrossed) {
399 return (
400 <Box key={`sm-${m.key}`} gap={1}>
401 <Box key="name">{texts('n', styled(m.name, style, null, null, 0), attrsOf(style), true)}</Box>
402 <Text key="reset" dimColor>
403 reset
404 </Text>
405 </Box>
406 )
407 }
408
409 const tone = m.isWarning ? WARN_RED : m.color
410 const cells = bar(m.pct, m.fraction, barWidth).flatMap(r =>
411 Array.from(r.text).map(ch => ({ ch, color: plain ? null : r.part === 'fill' ? tone : r.part === 'pace' ? PACE_COLOR : null })),
412 )
413 const pctChars = letters(`${m.pct}%`, style?.font ?? null)
414 const span = cells.length + pctChars.length
415 const sweep = lit ? onceProgress(sweeps[m.key], now) : null
416 const glow = lit && m.isWarning ? glowLevel(pulseProgress(now)) : 0
417 const barRuns = paint(cells.map(c => c.ch), cells.map(c => c.color), sweep, 0, 0, span)
418 const pctBase = plain ? pctChars.map(() => null) : style !== undefined && style.ink.length > 0 ? gradient(style.ink, pctChars.length) : pctChars.map(() => tone)
419 const pctRuns = paint(pctChars, pctBase, sweep, glow, cells.length, span)
420 const resets = look?.styles.resets
421 const shimmer = lit && isShimmering(m) ? pulseProgress(now) : null
422
423 return (
424 <Box key={`sm-${m.key}`} gap={1}>
425 <Box key="name">{name}</Box>
426 <Box key="bar">
427 <Text key="l" dimColor>
428 ▕
429 </Text>
430 {barRuns.map((r, i) => (
431 <Text key={`r${i}`} color={r.color ?? undefined} dimColor={plain || r.color === null ? true : undefined}>
432 {r.text}
433 </Text>
434 ))}
435 <Text key="r" dimColor>
436 ▏
437 </Text>
438 </Box>
439 <Box key="pct">{texts('p', pctRuns, attrsOf(style, m.isWarning), plain)}</Box>
440 {m.isWarning && (
441 <Text key="warn" color={mix(WARN_RED, '#ffffff', glow)}>
442 ⚠
443 </Text>
444 )}
445 {shown === 'full' && m.pace !== null && (
446 <Text key="pace" dimColor>
447 {m.pace}
448 </Text>
449 )}
450 {m.reset !== null && <Box key="reset">{texts('s', styled(m.reset, resets, null, shimmer, 0), attrsOf(resets), true)}</Box>}
451 </Box>
452 )
453 }
454
455 const watcherRow = (r: WatcherRow, more: number) => (
456 <Box key={`sw-${r.key}`} gap={1}>
457 <Text key="mark" color={r.color}>
458 {r.mark}
459 </Text>
460 <Text key="title" color={r.titleColor ?? undefined} dimColor={r.isDim ? true : undefined} wrap="truncate-end">
461 {r.text}
462 </Text>
463 {r.elapsed !== null && (
464 <Text key="elapsed" dimColor>
465 {r.elapsed}
466 </Text>
467 )}
468 {r.progress !== null && (
469 <Text key="progress" dimColor={r.isDim ? true : undefined}>
470 {r.progress}
471 </Text>
472 )}
473 {more > 0 && (
474 <Text key="more" dimColor>
475 {`+${more} more`}
476 </Text>
477 )}
478 </Box>
479 )
480
481 const visible = rows.slice(0, room)
482 const watchers =
483 visible.length === 0 ? null : (
484 <Box key="sm-watchers" flexDirection="column">
485 {visible.map((r, i) => watcherRow(r, i === visible.length - 1 ? rows.length - visible.length : 0))}
486 </Box>
487 )
488
489 const head =
490 opts.label === '' ? null : (
491 <Text key="sm-label" dimColor>
492 {opts.label}
493 </Text>
494 )
495 const note =
496 band.kind !== 'meters' || band.note === null ? null : (
497 <Text key="sm-note" dimColor>
498 {band.note}
499 </Text>
500 )
501 const meters =
502 band.kind === 'line' ? (
503 <Text key="sm-line" dimColor wrap="truncate-end">
504 {band.text}
505 </Text>
506 ) : band.kind !== 'meters' ? null : shown === 'full' ? (
507 <Box key="sm-full" flexDirection="column">
508 {band.meters.map((m, i) => (
509 <Box key={`sm-row-${m.key}`} gap={1}>
510 {i === 0 && head}
511 {meterRow(m)}
512 {i === band.meters.length - 1 && note}
513 </Box>
514 ))}
515 </Box>
516 ) : (
517 <Box key="sm-band" gap={3}>
518 {head}
519 {band.meters.map(meterRow)}
520 {note}
521 </Box>
522 )
523
524 return (
525 <Box flexDirection="column">
526 {meters}
527 {watchers}
528 {below}
529 </Box>
530 )
531 })
532}
533hooks/band.ts 529 lines1import type { BandStyle, BandWatcher, LetterStyle, MetersBand, WatcherState } from '../types'
2
3// The animated band's pure logic (items 65f and 66): band.json parsing, the letter styles and
4// ink gradients of the statusline's Style popover, the moving light, when the band needs fast
5// frames, and the watcher rows. No engine calls here, so every edge is a plain test.
6//
7// band.json is Sanduhr's (schema_version 1, next to snapshot.json): `meters.styles` (session,
8// weekly, resets; the statusline's style grammar), `reduce_motion` (macOS's Reduce Motion), and
9// `watchers` only while "Show watchers above the prompt" is on. Malformed parts are dropped.
10//
11// The motion follows the owner's now-playing mod: per-character colors, a three-character light
12// brightening toward white, fast frames only while a light moves, else once a second.
13
14export const BAND_SCHEMA_VERSION = 1
15/** A frame while something moves: 12.5 a second, under the engine's 30 redraws a second. */
16export const FAST_MS = 80
17/** At rest: once a second (the watchers' clocks). */
18export const SLOW_MS = 1000
19/** One pass of the light. */
20export const SWEEP_MS = 1100
21/** Shimmer, glow and waiting pulses repeat this often. */
22export const PERIOD_MS = 4000
23/** A limit this close to its reset shimmers. */
24export const SHIMMER_BEFORE_MS = 10 * 60_000
25/** A passed or finished watcher fades over this long (the app drops it after the same six seconds). */
26export const FADE_MS = 6000
27/** Sanduhr rewrites band.json each minute while watchers show: older than this, it quit. */
28export const STALE_MS = 3 * 60_000
29/** Most watchers band.json may carry (the app keeps 24). */
30export const MAX_WATCHERS = 24
31/** Most watcher rows drawn; more fold into "+N more". */
32export const MAX_ROWS = 6
33
34const WHITE = '#ffffff'
35/** What a light brightens when the text has no color of its own (dim text). */
36export const PLAIN = '#9ca3af'
37
38// -- band.json ---------------------------------------------------------------------------------
39
40const HEX = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
41const FONTS: readonly LetterStyle[] = ['bold', 'italic', 'bold-italic', 'script', 'fraktur', 'double-struck', 'sans', 'mono', 'small-caps']
42const STYLE_KEYS = new Set(['ink', 'font', 'bold', 'italic', 'dim', 'underline'])
43const STATES: readonly WatcherState[] = ['running', 'waiting', 'passed', 'failed', 'finished', 'lost_touch']
44const TITLE_CAP = 80
45const SHORT_CAP = 12
46
47function isObject(v: unknown): v is Record<string, unknown> {
48 return typeof v === 'object' && v !== null && !Array.isArray(v)
49}
50
51/** "#abc" and "abc" as "#aabbcc"; null for anything else. */
52export function normalizeHex(v: unknown): string | null {
53 if (typeof v !== 'string') {
54 return null
55 }
56
57 const m = HEX.exec(v.trim())
58
59 if (m === null) {
60 return null
61 }
62
63 const h = (m[1] as string).toLowerCase()
64
65 return `#${h.length === 3 ? h.split('').map(c => c + c).join('') : h}`
66}
67
68/** A style as the statusline's picks carry it, or null when it is anything else. */
69export function parseStyle(v: unknown): BandStyle | null {
70 if (!isObject(v) || Object.keys(v).some(k => !STYLE_KEYS.has(k))) {
71 return null
72 }
73
74 let ink: string[] = []
75
76 if (v.ink !== undefined) {
77 if (!Array.isArray(v.ink) || v.ink.length < 1 || v.ink.length > 4) {
78 return null
79 }
80
81 const colors = v.ink.map(normalizeHex)
82
83 if (colors.some(c => c === null)) {
84 return null
85 }
86
87 ink = colors as string[]
88 }
89
90 if (v.font !== undefined && !FONTS.includes(v.font as LetterStyle)) {
91 return null
92 }
93
94 for (const k of ['bold', 'italic', 'dim', 'underline']) {
95 if (v[k] !== undefined && typeof v[k] !== 'boolean') {
96 return null
97 }
98 }
99
100 return {
101 ink,
102 font: (v.font as LetterStyle | undefined) ?? null,
103 bold: v.bold === true,
104 italic: v.italic === true,
105 dim: v.dim === true,
106 underline: v.underline === true,
107 }
108}
109
110function parseTime(v: unknown): number | null {
111 if (typeof v !== 'string' || v.trim() === '') {
112 return null
113 }
114
115 const ms = Date.parse(v.trim().replace(/(\.\d{3})\d+/, '$1'))
116
117 return Number.isNaN(ms) ? null : ms
118}
119
120/** One line of text, no control characters, at most `cap` characters; null when empty. */
121function line(v: unknown, cap: number): string | null {
122 if (typeof v !== 'string') {
123 return null
124 }
125
126 const flat = Array.from(v.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim())
127
128 if (flat.length === 0) {
129 return null
130 }
131
132 return flat.length > cap ? `${flat.slice(0, cap - 1).join('').trim()}…` : flat.join('')
133}
134
135function count(v: unknown): number | null {
136 return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 1_000_000 ? v : null
137}
138
139/** One watcher row, or null when it is malformed (a row the band can't trust is left out). */
140export function parseWatcher(v: unknown): BandWatcher | null {
141 if (!isObject(v) || !STATES.includes(v.state as WatcherState)) {
142 return null
143 }
144
145 const state = v.state as WatcherState
146
147 if (v.source === 'automatic') {
148 const kind = line(v.kind, 20)
149
150 return kind === null ? null : { source: 'automatic', kind, state }
151 }
152
153 if (v.source !== 'agent') {
154 return null
155 }
156
157 const title = line(v.title, TITLE_CAP)
158
159 if (title === null) {
160 return null
161 }
162
163 const total = count(v.total)
164 const done = total === null ? null : Math.min(count(v.done) ?? 0, total)
165
166 return {
167 source: 'agent',
168 title,
169 short: line(v.short, SHORT_CAP),
170 state,
171 done,
172 total,
173 startedAt: parseTime(v.started_at),
174 endedAt: parseTime(v.ended_at),
175 }
176}
177
178/** band.json's text, or null for anything missing, malformed or of a newer schema. */
179export function parseBand(text: string): MetersBand | null {
180 let raw: unknown
181
182 try {
183 raw = JSON.parse(text)
184 } catch {
185 return null
186 }
187
188 if (!isObject(raw) || raw.schema_version !== BAND_SCHEMA_VERSION) {
189 return null
190 }
191
192 const writtenAt = parseTime(raw.written_at)
193
194 if (writtenAt === null) {
195 return null
196 }
197
198 const styles: MetersBand['styles'] = {}
199 const all = isObject(raw.meters) && isObject(raw.meters.styles) ? raw.meters.styles : {}
200
201 for (const key of ['session', 'weekly', 'resets'] as const) {
202 const s = parseStyle(all[key])
203
204 if (s !== null) {
205 styles[key] = s
206 }
207 }
208
209 let watchers: BandWatcher[] | null = null
210
211 if (Array.isArray(raw.watchers)) {
212 watchers = raw.watchers.slice(0, MAX_WATCHERS).map(parseWatcher).filter((w): w is BandWatcher => w !== null)
213 }
214
215 return { writtenAt, reduceMotion: raw.reduce_motion === true, styles, watchers }
216}
217
218// -- Letters and colors -----------------------------------------------------------------------
219
220// (capital A, small a, digit 0 or null, the letters the block leaves out), as the statusline.
221const BLOCKS: Record<Exclude<LetterStyle, 'small-caps'>, [number, number, number | null, Record<string, number>]> = {
222 bold: [0x1d400, 0x1d41a, 0x1d7ce, {}],
223 italic: [0x1d434, 0x1d44e, null, { h: 0x210e }],
224 'bold-italic': [0x1d468, 0x1d482, null, {}],
225 script: [0x1d49c, 0x1d4b6, null, { B: 0x212c, E: 0x2130, F: 0x2131, H: 0x210b, I: 0x2110, L: 0x2112, M: 0x2133, R: 0x211b, e: 0x212f, g: 0x210a, o: 0x2134 }],
226 fraktur: [0x1d504, 0x1d51e, null, { C: 0x212d, H: 0x210c, I: 0x2111, R: 0x211c, Z: 0x2128 }],
227 'double-struck': [0x1d538, 0x1d552, 0x1d7d8, { C: 0x2102, H: 0x210d, N: 0x2115, P: 0x2119, Q: 0x211a, R: 0x211d, Z: 0x2124 }],
228 sans: [0x1d5a0, 0x1d5ba, 0x1d7e2, {}],
229 mono: [0x1d670, 0x1d68a, 0x1d7f6, {}],
230}
231const SMALL_CAPS = 'ᴀʙᴄᴅᴇꜰɢʜɪᴊᴋʟᴍɴᴏᴘꞯʀꜱᴛᴜᴠᴡxʏᴢ'
232
233/** `ch` in the letter style `font`; anything the style has no form for stays as it is. */
234export function letter(ch: string, font: LetterStyle | null): string {
235 if (font === null || !/^[A-Za-z0-9]$/.test(ch)) {
236 return ch
237 }
238
239 if (font === 'small-caps') {
240 return /[a-z]/.test(ch) ? (Array.from(SMALL_CAPS)[ch.charCodeAt(0) - 97] as string) : ch
241 }
242
243 const [upper, lower, digit, holes] = BLOCKS[font]
244 const hole = holes[ch]
245
246 if (hole !== undefined) {
247 return String.fromCodePoint(hole)
248 }
249
250 const code = ch.charCodeAt(0)
251
252 if (ch >= 'A' && ch <= 'Z') {
253 return String.fromCodePoint(upper + code - 65)
254 }
255
256 if (ch >= 'a' && ch <= 'z') {
257 return String.fromCodePoint(lower + code - 97)
258 }
259
260 return digit === null ? ch : String.fromCodePoint(digit + code - 48)
261}
262
263/** `text` as characters (code points) in `font`. */
264export function letters(text: string, font: LetterStyle | null): string[] {
265 return Array.from(text).map(ch => letter(ch, font))
266}
267
268function rgb(hex: string): [number, number, number] {
269 const h = (normalizeHex(hex) ?? '#ffffff').slice(1)
270
271 return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
272}
273
274function toHex(c: readonly number[]): string {
275 return `#${c.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`
276}
277
278/** `a` moved toward `b` by `t` (0..1). */
279export function mix(a: string, b: string, t: number): string {
280 const x = rgb(a)
281 const y = rgb(b)
282 const k = Math.max(0, Math.min(1, t))
283
284 return toHex(x.map((v, i) => v + ((y[i] as number) - v) * k))
285}
286
287/** `n` colors along the stops of `ink` (one color: all the same), as the statusline draws them. */
288export function gradient(ink: readonly string[], n: number): string[] {
289 if (ink.length === 0 || n <= 0) {
290 return []
291 }
292
293 if (ink.length === 1 || n === 1) {
294 return Array.from({ length: n }, () => normalizeHex(ink[0]) ?? WHITE)
295 }
296
297 return Array.from({ length: n }, (_, i) => {
298 const t = (i / (n - 1)) * (ink.length - 1)
299 const k = Math.min(Math.floor(t), ink.length - 2)
300
301 return mix(ink[k] as string, ink[k + 1] as string, t - k)
302 })
303}
304
305// -- The light ---------------------------------------------------------------------------------
306
307/**
308 * How bright character `i` of `n` is while a light `t` (0..1) of the way through its pass: a
309 * three-character window, brightest at its center, travelling from before the first character
310 * to past the last.
311 */
312export function lightAt(i: number, n: number, t: number): number {
313 const center = -1 + t * (n + 1)
314
315 return Math.max(0, 1 - Math.abs(i - center) / 2)
316}
317
318/** How far through a one-shot pass that started at `start`, or null when it isn't moving. */
319export function onceProgress(start: number | undefined, now: number): number | null {
320 if (start === undefined || now < start || now - start >= SWEEP_MS) {
321 return null
322 }
323
324 return (now - start) / SWEEP_MS
325}
326
327/** How far through the repeating pass (every PERIOD_MS, SWEEP_MS long), or null between passes. */
328export function pulseProgress(now: number): number | null {
329 const phase = ((now % PERIOD_MS) + PERIOD_MS) % PERIOD_MS
330
331 return phase < SWEEP_MS ? phase / SWEEP_MS : null
332}
333
334/** A glow's strength through a pass: up and back down. */
335export function glowLevel(t: number | null): number {
336 return t === null ? 0 : Math.sin(Math.PI * t) * 0.6
337}
338
339export type Run = { text: string; color: string | null }
340
341/**
342 * Characters with their base colors (null: the terminal's own), brightened toward white by a
343 * light at `t` across them and a glow `glow` over all; neighbours of one color merge. A light
344 * that crosses several pieces (a bar, then its percent) names where this piece starts in it
345 * (`offset`) and its whole length (`span`).
346 */
347export function paint(
348 chars: readonly string[],
349 base: readonly (string | null)[],
350 t: number | null,
351 glow = 0,
352 offset = 0,
353 span = chars.length,
354): Run[] {
355 const runs: Run[] = []
356
357 chars.forEach((ch, i) => {
358 const own = base[i] ?? null
359 const light = Math.max(t === null ? 0 : lightAt(i + offset, span, t) * 0.85, glow)
360 const color = light > 0.01 ? mix(own ?? PLAIN, WHITE, light) : own
361 const last = runs[runs.length - 1]
362
363 if (last !== undefined && last.color === color) {
364 last.text += ch
365 } else {
366 runs.push({ text: ch, color })
367 }
368 })
369
370 return runs
371}
372
373// -- Warning lines and when the band moves ----------------------------------------------------
374
375/** Which side of the warning lines a percent is on (the widget's colors: 50, 75, 90). */
376export function level(pct: number): number {
377 return pct >= 90 ? 3 : pct >= 75 ? 2 : pct >= 50 ? 1 : 0
378}
379
380/** The limits whose percent crossed a warning line upward between two readings. */
381export function crossings(before: Record<string, number>, after: Record<string, number>): string[] {
382 return Object.keys(after).filter(k => before[k] !== undefined && level(after[k] as number) > level(before[k] as number))
383}
384
385/** What may move in the band now. */
386export type Movers = {
387 /** When each one-shot sweep started (a meter crossed a warning line). */
388 sweeps: number[]
389 /** Something pulses on the period: a limit about to reset, a nearly full one, a watcher waiting on you. */
390 pulses: boolean
391 /** When each passed or finished watcher ended (it fades). */
392 fades: number[]
393}
394
395/** Whether a light, a pulse or a fade is under way at `now`. */
396export function isMoving(m: Movers, now: number): boolean {
397 return (
398 m.sweeps.some(s => onceProgress(s, now) !== null) ||
399 (m.pulses && pulseProgress(now) !== null) ||
400 m.fades.some(e => now >= e && now - e < FADE_MS)
401 )
402}
403
404/** How long until the next frame: fast while something moves, else once a second or sooner when a pulse is due. */
405export function nextFrameIn(m: Movers, now: number, motion: boolean): number {
406 if (!motion) {
407 return SLOW_MS
408 }
409
410 if (isMoving(m, now)) {
411 return FAST_MS
412 }
413
414 let wait = SLOW_MS
415
416 if (m.pulses) {
417 const phase = ((now % PERIOD_MS) + PERIOD_MS) % PERIOD_MS
418 wait = Math.min(wait, PERIOD_MS - phase)
419 }
420
421 for (const s of [...m.sweeps, ...m.fades]) {
422 if (s > now) {
423 wait = Math.min(wait, s - now)
424 }
425 }
426
427 return Math.max(FAST_MS, wait)
428}
429
430// -- Watcher rows ------------------------------------------------------------------------------
431
432export const MARKS: Record<WatcherState, string> = {
433 running: '●',
434 waiting: '◉',
435 passed: '✓',
436 failed: '⚠',
437 finished: '✓',
438 lost_touch: '○',
439}
440
441export const MARK_COLORS: Record<WatcherState, string> = {
442 running: '#38bdf8',
443 waiting: '#fbbf24',
444 passed: '#4ade80',
445 failed: '#f87171',
446 finished: '#9ca3af',
447 lost_touch: '#6b7280',
448}
449
450export type WatcherRow = {
451 key: string
452 state: WatcherState
453 mark: string
454 /** The mark's color this frame (pulsing, fading). */
455 color: string
456 /** The title's color this frame, null for the terminal's own. */
457 titleColor: string | null
458 isDim: boolean
459 text: string
460 elapsed: string | null
461 progress: string | null
462}
463
464/** "12s", "4m", "1h 05m", as the notch and the Desk write it. */
465export function elapsedWords(ms: number): string {
466 const s = Math.floor(Math.max(0, ms) / 1000)
467
468 if (s < 60) {
469 return `${s}s`
470 }
471
472 if (s < 3600) {
473 return `${Math.floor(s / 60)}m`
474 }
475
476 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
477}
478
479/** The watchers the band shows now: none from an old file (Sanduhr quit) or with the switch off. */
480export function liveWatchers(band: MetersBand | null, now: number): BandWatcher[] {
481 if (band === null || band.watchers === null || now - band.writtenAt > STALE_MS) {
482 return []
483 }
484
485 return band.watchers
486}
487
488/** One row per watcher, as drawn at `now`; `wide` writes the whole title, else the short one. */
489export function watcherRows(watchers: readonly BandWatcher[], now: number, opts: { wide: boolean; motion: boolean }): WatcherRow[] {
490 return watchers.map((w, i) => {
491 const state = w.state
492 let color = MARK_COLORS[state]
493 let titleColor: string | null = null
494 let isDim = state === 'lost_touch' || state === 'finished'
495 const ended = w.source === 'agent' ? w.endedAt : null
496
497 if (state === 'waiting') {
498 const glow = opts.motion ? glowLevel(pulseProgress(now)) : 0
499 color = mix(MARK_COLORS.waiting, WHITE, glow)
500 titleColor = glow > 0.01 ? mix(MARK_COLORS.waiting, WHITE, glow) : MARK_COLORS.waiting
501 } else if (state === 'failed') {
502 titleColor = MARK_COLORS.failed
503 } else if ((state === 'passed' || state === 'finished') && ended !== null && opts.motion) {
504 // The check holds a moment, then fades toward grey before Sanduhr drops it.
505 const t = Math.max(0, Math.min(1, (now - ended - 1000) / (FADE_MS - 1000)))
506 color = mix(MARK_COLORS[state], '#4b5563', t)
507 isDim = isDim || t >= 0.5
508 }
509
510 if (w.source === 'automatic') {
511 return { key: `w${i}`, state, mark: MARKS[state], color, titleColor, isDim, text: `background ${w.kind}`, elapsed: null, progress: null }
512 }
513
514 const text = opts.wide || w.short === null ? w.title : w.short
515 const elapsed = w.startedAt === null ? null : elapsedWords((w.endedAt ?? now) - w.startedAt)
516 const progress = w.total === null ? null : `${w.done ?? 0}/${w.total}`
517
518 return { key: `w${i}`, state, mark: MARKS[state], color, titleColor, isDim, text, elapsed, progress }
519 })
520}
521
522/** What moves among the watchers: waiting pulses, passed and finished fade. */
523export function watcherMovers(watchers: readonly BandWatcher[]): { pulses: boolean; fades: number[] } {
524 return {
525 pulses: watchers.some(w => w.state === 'waiting'),
526 fades: watchers.flatMap(w => (w.source === 'agent' && (w.state === 'passed' || w.state === 'finished') && w.endedAt !== null ? [w.endedAt] : [])),
527 }
528}
529hooks/meters.ts 422 lines1import type { MetersSnapshot, MetersTier } from '../types'
2
3// The band's pure logic: snapshot.json parsing, freshness, bars, countdowns, warnings and the
4// toast decisions. No engine calls here, so every edge is a plain test.
5//
6// The snapshot contract is Sanduhr's (schema_version 1): the macOS widget writes
7// ~/Library/Application Support/Sanduhr/snapshot.json, the Windows widget %APPDATA%\Sanduhr\snapshot.json.
8// Freshness bands, the reset-crossed rule and the newer-schema refusal follow the statusline
9// script; the pace math and the bar colors follow the widget, so the band shows its numbers.
10
11export const SCHEMA_VERSION = 1
12// Fresh below 1.5x the widget's 5-minute fetch, stale up to two missed polls, dead beyond.
13export const FRESH_SECONDS = 450
14export const DEAD_SECONDS = 900
15
16export const SESSION = 'five_hour'
17export const WEEKLY = 'seven_day'
18
19const HOUR = 3600_000
20const DAY = 24 * HOUR
21const TOTAL_MS: Record<string, number> = { [SESSION]: 5 * HOUR, [WEEKLY]: 7 * DAY }
22const NAMES: Record<string, string> = { [SESSION]: 'Session', [WEEKLY]: 'Weekly' }
23
24// The widget's meter warning (MeterWarning): nearly full while the reset is still far off.
25// Weekly: 90% with more than a day to go (the widget's default). Session: 90% with more than an
26// hour to go (the widget's rule for the session limit when switched on).
27export const WARN_PERCENT = 90
28const WARN_MIN_RESET: Record<string, number> = { [SESSION]: HOUR, [WEEKLY]: DAY }
29
30// A session reset older than this is old news: no toast.
31export const RESET_TOAST_WINDOW_MS = 10 * 60_000
32
33export type Bars = 'both' | 'session' | 'weekly'
34export type Style = 'compact' | 'full'
35
36export type SnapshotTier = MetersTier
37export type Snapshot = MetersSnapshot
38
39/** An ISO-8601 time in ms, or null. Fractions are cut to milliseconds (.NET writes seven digits). */
40export function parseTime(value: unknown): number | null {
41 if (typeof value !== 'string' || value.trim() === '') {
42 return null
43 }
44
45 const s = value.trim().replace(/(\.\d{3})\d+/, '$1')
46 const ms = Date.parse(s)
47
48 return Number.isNaN(ms) ? null : ms
49}
50
51/** snapshot.json's text, or null for anything missing or malformed (the uninstalled look). */
52export function parseSnapshot(text: string): Snapshot | null {
53 let raw: unknown
54
55 try {
56 raw = JSON.parse(text)
57 } catch {
58 return null
59 }
60
61 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
62 return null
63 }
64
65 const o = raw as Record<string, unknown>
66 const version = Number(o.schema_version)
67
68 if (!Number.isFinite(version)) {
69 return null
70 }
71
72 // A newer major is refused, never read best effort: wrong numbers are the failure to avoid.
73 if (version > SCHEMA_VERSION) {
74 return { kind: 'newer' }
75 }
76
77 const capturedAt = parseTime(o.captured_at)
78
79 if (capturedAt === null) {
80 return null
81 }
82
83 const tiers: SnapshotTier[] = []
84
85 for (const t of Array.isArray(o.tiers) ? o.tiers : []) {
86 if (typeof t !== 'object' || t === null) {
87 continue
88 }
89
90 const r = t as Record<string, unknown>
91 const util = typeof r.utilization === 'number' && Number.isFinite(r.utilization) ? Math.trunc(r.utilization) : null
92 tiers.push({ key: String(r.key ?? ''), utilization: util, resetsAt: parseTime(r.resets_at) })
93 }
94
95 return {
96 kind: 'snapshot',
97 capturedAt,
98 status: o.status === 'error' ? 'error' : 'ok',
99 errorKind: typeof o.error_kind === 'string' ? o.error_kind : null,
100 tiers,
101 }
102}
103
104export type Freshness = 'fresh' | 'stale' | 'dead'
105
106/** The statusline's bands; a snapshot from the future is fresh. */
107export function freshness(capturedAt: number, now: number): Freshness {
108 const age = Math.max(0, (now - capturedAt) / 1000)
109
110 if (age < FRESH_SECONDS) {
111 return 'fresh'
112 }
113
114 return age <= DEAD_SECONDS ? 'stale' : 'dead'
115}
116
117/** The fraction of the period gone (0..1), or null without a reset time. */
118export function paceFraction(key: string, resetsAt: number | null, now: number): number | null {
119 const total = TOTAL_MS[key]
120
121 if (resetsAt === null || total === undefined) {
122 return null
123 }
124
125 const rem = Math.max(0, resetsAt - now)
126
127 return Math.min(1, Math.max(0, (total - rem) / total))
128}
129
130/** The widget's pace words: on pace within 5 points, else how far ahead or under. */
131export function paceWords(pct: number, fraction: number | null): string | null {
132 if (fraction === null) {
133 return null
134 }
135
136 const diff = pct - fraction * 100
137
138 if (Math.abs(diff) < 5) {
139 return 'on pace'
140 }
141
142 return `${Math.trunc(Math.abs(diff))}% ${diff > 0 ? 'ahead' : 'under'}`
143}
144
145export function isWarning(key: string, pct: number, resetsAt: number | null, now: number): boolean {
146 const minReset = WARN_MIN_RESET[key]
147
148 if (minReset === undefined || pct < WARN_PERCENT) {
149 return false
150 }
151
152 return resetsAt === null || resetsAt - now > minReset
153}
154
155/** The widget's usage colors: green, yellow, orange, then red from 90%. */
156export function usageColor(pct: number): string {
157 if (pct < 50) {
158 return '#4ade80'
159 }
160
161 if (pct < 75) {
162 return '#facc15'
163 }
164
165 return pct < 90 ? '#fb923c' : '#f87171'
166}
167
168export const PACE_COLOR = '#f472b6'
169
170export type BarRun = { text: string; part: 'fill' | 'pace' | 'empty' }
171
172const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
173
174/** A bar `width` cells wide: full blocks, one partial eighth, the pace mark, and the rest empty. */
175export function bar(pct: number, fraction: number | null, width: number): BarRun[] {
176 const cells: { ch: string; part: BarRun['part'] }[] = []
177 const filled = (Math.min(100, Math.max(0, pct)) / 100) * width
178 const full = Math.floor(filled)
179 const eighth = Math.round((filled - full) * 8)
180
181 for (let i = 0; i < width; i += 1) {
182 if (i < full) {
183 cells.push({ ch: '█', part: 'fill' })
184 } else if (i === full && eighth === 8) {
185 cells.push({ ch: '█', part: 'fill' })
186 } else if (i === full && eighth > 0) {
187 cells.push({ ch: EIGHTHS[eighth] as string, part: 'fill' })
188 } else {
189 cells.push({ ch: '░', part: 'empty' })
190 }
191 }
192
193 if (fraction !== null) {
194 const at = Math.min(width - 1, Math.max(0, Math.floor(fraction * width)))
195 cells[at] = { ch: '│', part: 'pace' }
196 }
197
198 const runs: BarRun[] = []
199
200 for (const c of cells) {
201 const last = runs[runs.length - 1]
202
203 if (last !== undefined && last.part === c.part) {
204 last.text += c.ch
205 } else {
206 runs.push({ text: c.ch, part: c.part })
207 }
208 }
209
210 return runs
211}
212
213/** "2d 3h", "2h 14m", "14m", or "now". */
214export function countdown(ms: number): string {
215 const minutes = Math.floor(Math.max(0, ms) / 60_000)
216
217 if (minutes <= 0) {
218 return 'now'
219 }
220
221 const d = Math.floor(minutes / 1440)
222 const h = Math.floor((minutes % 1440) / 60)
223 const m = minutes % 60
224
225 if (d > 0) {
226 return h > 0 ? `${d}d ${h}h` : `${d}d`
227 }
228
229 return h > 0 ? `${h}h ${m}m` : `${m}m`
230}
231
232const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
233
234/** The local time of `at`, "Mon 9:00 AM", given the host's `Date#getTimezoneOffset()`. */
235export function clockTime(at: number, tzOffsetMinutes: number, withDay: boolean): string {
236 const local = new Date(at - tzOffsetMinutes * 60_000)
237 const h24 = local.getUTCHours()
238 const h12 = h24 % 12 === 0 ? 12 : h24 % 12
239 const mm = String(local.getUTCMinutes()).padStart(2, '0')
240 const time = `${h12}:${mm} ${h24 < 12 ? 'AM' : 'PM'}`
241
242 return withDay ? `${DAYS[local.getUTCDay()]} ${time}` : time
243}
244
245/** When the limit resets, in the band's words: a countdown inside a day, else the day. */
246export function resetWords(resetsAt: number | null, now: number, style: Style, tzOffsetMinutes: number): string | null {
247 if (resetsAt === null) {
248 return null
249 }
250
251 const left = resetsAt - now
252
253 if (left > DAY) {
254 return style === 'full'
255 ? `resets ${clockTime(resetsAt, tzOffsetMinutes, true)}`
256 : `resets ${DAYS[new Date(resetsAt - tzOffsetMinutes * 60_000).getUTCDay()]}`
257 }
258
259 return style === 'full' ? `resets in ${countdown(left)}` : `resets ${countdown(left)}`
260}
261
262export type Meter = {
263 key: string
264 name: string
265 // The limit's reset has passed: the stored percent is wrong until Sanduhr fetches again.
266 isCrossed: boolean
267 pct: number
268 color: string
269 fraction: number | null
270 pace: string | null
271 isWarning: boolean
272 reset: string | null
273 // How long until the limit resets (ms), null without a reset time or once it passed.
274 resetLeft: number | null
275}
276
277export type Band =
278 | { kind: 'none' }
279 | { kind: 'line'; text: string }
280 | { kind: 'meters'; meters: Meter[]; isDim: boolean; note: string | null }
281
282export type BandOptions = { bars: Bars; style: Style; tzOffsetMinutes: number }
283
284const ERROR_WORDS: Record<string, string> = { session_expired: 'sign in again', cloudflare: 'blocked, sign in again' }
285
286function wanted(key: string, bars: Bars): boolean {
287 return key === SESSION ? bars !== 'weekly' : key === WEEKLY ? bars !== 'session' : false
288}
289
290/** What the band shows for a snapshot at `now`. Nothing at all without a snapshot. */
291export function bandFor(snap: Snapshot | null, now: number, opts: BandOptions): Band {
292 if (snap === null) {
293 return { kind: 'none' }
294 }
295
296 if (snap.kind === 'newer') {
297 return { kind: 'line', text: 'Sanduhr: this snapshot is newer than the meters mod. Update the mod from Sanduhr.' }
298 }
299
300 const age = freshness(snap.capturedAt, now)
301
302 if (age === 'dead') {
303 const minutes = Math.floor((now - snap.capturedAt) / 60_000)
304
305 return { kind: 'line', text: `Sanduhr: no update for ${minutes}m. Is the widget running?` }
306 }
307
308 const meters: Meter[] = []
309
310 for (const t of snap.tiers) {
311 if (!wanted(t.key, opts.bars) || t.utilization === null) {
312 continue
313 }
314
315 const isCrossed = t.resetsAt !== null && t.resetsAt <= now
316 const fraction = isCrossed ? null : paceFraction(t.key, t.resetsAt, now)
317 meters.push({
318 key: t.key,
319 name: NAMES[t.key] ?? t.key,
320 isCrossed,
321 pct: t.utilization,
322 color: usageColor(t.utilization),
323 fraction,
324 pace: isCrossed ? null : paceWords(t.utilization, fraction),
325 isWarning: !isCrossed && snap.status === 'ok' && isWarning(t.key, t.utilization, t.resetsAt, now),
326 reset: isCrossed ? null : resetWords(t.resetsAt, now, opts.style, opts.tzOffsetMinutes),
327 resetLeft: isCrossed || t.resetsAt === null ? null : t.resetsAt - now,
328 })
329 }
330
331 if (snap.status === 'error') {
332 const words = ERROR_WORDS[snap.errorKind ?? ''] ?? 'offline'
333
334 // Signed out (or the session expired) with nothing kept: one line, no old numbers.
335 if (snap.tiers.length === 0) {
336 return { kind: 'line', text: snap.errorKind === 'session_expired' ? 'Sanduhr: sign in to see your meters.' : `Sanduhr: ${words}.` }
337 }
338
339 return meters.length === 0 ? { kind: 'line', text: `Sanduhr: ${words}.` } : { kind: 'meters', meters, isDim: true, note: `last known, ${words}` }
340 }
341
342 if (meters.length === 0) {
343 return { kind: 'none' }
344 }
345
346 if (age === 'stale') {
347 return { kind: 'meters', meters, isDim: true, note: `(${Math.floor((now - snap.capturedAt) / 60_000)}m ago)` }
348 }
349
350 return { kind: 'meters', meters, isDim: false, note: null }
351}
352
353/** A meter as one plain line, the words the band draws (tests and the compact row read this). */
354export function meterText(m: Meter, style: Style, width: number): string {
355 if (m.isCrossed) {
356 return `${m.name} reset`
357 }
358
359 const cells = bar(m.pct, m.fraction, width).map(r => r.text).join('')
360 const parts = [`${m.name} ▕${cells}▏ ${m.pct}%${m.isWarning ? ' ⚠' : ''}`]
361
362 if (style === 'full' && m.pace !== null) {
363 parts.push(m.pace)
364 }
365
366 if (m.reset !== null) {
367 parts.push(m.reset)
368 }
369
370 return parts.join(' ')
371}
372
373// -- Toasts -----------------------------------------------------------------------------------
374
375/** One key per limit and reset window that warns now: a toast is due once per key. */
376export function warningKeys(snap: Snapshot | null, now: number): string[] {
377 if (snap === null || snap.kind !== 'snapshot' || snap.status !== 'ok' || freshness(snap.capturedAt, now) === 'dead') {
378 return []
379 }
380
381 return snap.tiers
382 .filter(t => t.utilization !== null && !(t.resetsAt !== null && t.resetsAt <= now))
383 .filter(t => isWarning(t.key, t.utilization as number, t.resetsAt, now))
384 .map(t => `warn:${t.key}@${t.resetsAt ?? 'none'}`)
385}
386
387export function warningToast(snap: Snapshot, key: string, now: number, tzOffsetMinutes: number): string | null {
388 if (snap.kind !== 'snapshot') {
389 return null
390 }
391
392 const t = snap.tiers.find(x => `warn:${x.key}@${x.resetsAt ?? 'none'}` === key)
393
394 if (t === undefined || t.utilization === null) {
395 return null
396 }
397
398 const reset = resetWords(t.resetsAt, now, 'full', tzOffsetMinutes)
399 const name = (NAMES[t.key] ?? t.key).toLowerCase()
400
401 return `Sanduhr: the ${name} limit is at ${t.utilization}%${reset === null ? '' : `, ${reset}`}.`
402}
403
404/**
405 * The session reset that just passed, or null: the snapshot's own reset time crossed, or the
406 * last one seen crossed and the snapshot already moved on to the next window. Older than ten
407 * minutes is old news.
408 */
409export function sessionReset(lastSeen: number | null, snap: Snapshot | null, now: number): number | null {
410 const current = snap !== null && snap.kind === 'snapshot' ? snap.tiers.find(t => t.key === SESSION)?.resetsAt ?? null : null
411 const candidates = [current, lastSeen].filter((x): x is number => x !== null && x <= now && now - x <= RESET_TOAST_WINDOW_MS)
412
413 return candidates.length === 0 ? null : Math.max(...candidates)
414}
415
416export const RESET_TOAST = 'Sanduhr: the session limit has reset.'
417
418/** The keys remembered as toasted, newest last, at most `cap`. */
419export function remember(seen: readonly string[], key: string, cap = 24): string[] {
420 return [...seen.filter(k => k !== key), key].slice(-cap)
421}
422types/index.d.ts 71 lines1// The band's state contract: what the hooks module keeps in $.state for its drawing.
2
3/** One limit as snapshot.json carries it; times in ms since the epoch. */
4export type MetersTier = { key: string; utilization: number | null; resetsAt: number | null }
5
6/** Sanduhr's snapshot.json, read; `newer` is a schema this mod must not read. */
7export type MetersSnapshot =
8 | {
9 kind: 'snapshot'
10 capturedAt: number
11 status: 'ok' | 'error'
12 errorKind: string | null
13 tiers: MetersTier[]
14 }
15 | { kind: 'newer' }
16
17/** A Unicode letter style, the statusline's names (item 63b). */
18export type LetterStyle =
19 | 'bold'
20 | 'italic'
21 | 'bold-italic'
22 | 'script'
23 | 'fraktur'
24 | 'double-struck'
25 | 'sans'
26 | 'mono'
27 | 'small-caps'
28
29/** A segment's look from the Combine sheet's Style popover: ink (one color or 2 to 4 stops). */
30export type BandStyle = {
31 ink: string[]
32 font: LetterStyle | null
33 bold: boolean
34 italic: boolean
35 dim: boolean
36 underline: boolean
37}
38
39export type WatcherState = 'running' | 'waiting' | 'passed' | 'failed' | 'finished' | 'lost_touch'
40
41/** One watcher as band.json carries it: an agent's with its title and times, background work as its kind. */
42export type BandWatcher =
43 | {
44 source: 'agent'
45 title: string
46 short: string | null
47 state: WatcherState
48 done: number | null
49 total: number | null
50 startedAt: number | null
51 endedAt: number | null
52 }
53 | { source: 'automatic'; kind: string; state: WatcherState }
54
55/** Sanduhr's band.json, read: the looks for its segments, Reduce Motion, the watchers (null: switch off). */
56export type MetersBand = {
57 writtenAt: number
58 reduceMotion: boolean
59 styles: { session?: BandStyle; weekly?: BandStyle; resets?: BandStyle }
60 watchers: BandWatcher[] | null
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'sanduhr-meters': {
66 snapshot: MetersSnapshot | null
67 band: MetersBand | null
68 }
69 }
70}
71