SLOPSHOPPER

sanduhr-meters

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

newbandtoasttimer
A shopper browsing a rack in a slop shop
README

<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>


Install

macOS — native SwiftUI

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.

  • Developer ID signed + Apple-notarized → no Gatekeeper warnings.
  • NSVisualEffectView vibrancy. Credentials live in the macOS Keychain (service 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.
  • Auto-updates via Sparkle (24h check interval); brew upgrade --cask sanduhr also works.
  • Cask lives in [the personal tap][tap]. Submission to core Homebrew/homebrew-cask is pending the project's notability bar (90 forks / 90 watchers / 225 stars).
  • Requires macOS 14 (Sonoma) or newer.

[releases]: https://github.com/estevanhernandez-stack-ed/Sanduhr_f-r_Claude/releases [tap]: https://github.com/estevanhernandez-stack-ed/homebrew-tap

Windows 10/11 — native .NET 10 / WPF

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.

  • Win11 Mica glass backdrop (Win10 falls back to solid theme color). Windows 10 1809+ / x64.
  • Windows Credential Manager storage (service com.626labs.sanduhr). Uninstall does not clear these entries on either channel — use Sign Out first, or delete them from Credential Manager yourself.
  • Full source under 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.
  • Step-by-step sign-in, where your data lives, the .msix sideload note, and uninstall behaviour: INSTALL.md.

Claude Code integration — statusline + 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.
  • Install it from Settings ▸ Claude Usage ▸ Install MCP server…: pick the one Claude Code home that gets the registration and tick which homes the burn tool may read (none by default). The widget copies the server under %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.
  • Registering by hand still works, at user scope and never in a project .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.
  • Theme Studio. Settings ▸ Themes ▸ Studio edits the fourteen color tokens and two dials with the widget as the live preview, lint findings as you type, Save & apply, Revert, and Copy JSON. Start from any theme.
  • Themes from the terminal. With the MCP server installed, ask Claude Code for a theme ("make me a theme from this album cover") and it calls 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.
  • Details: docs/superpowers/specs/2026-07-12-statusline-mcp-design.md, docs/PRIVACY.md.

Features

Pacing

  • Burn-rate projection — "At current pace, expires in 3d 21h" warns before you run dry.
  • Always-on pace ghost — a vertical tick on every bar showing where pace says usage should be right now. Real fill sits to the left (under pace), at (on pace), or to the right (ahead). No math required.
  • Advanced pacing metrics — hover any tier card to reveal Cooldown required (how long at zero usage to get back on pace) and Surplus (burn-rate delta when under pace).
  • Horizon sparkline — classic Heer/Tufte 4-band horizon over the last 2 hours. Peaks stack into dense dark regions, lulls wash soft. More information per pixel than a line chart, toggle via the 📊 button.
  • Breathing glass — bars pulse softly toward each theme's accent color. Subliminal, not flickery.

Focus

  • Deep-work focus timer — swap the tier cards for a digitised 31×31 pixel hourglass that drains in real time. Inline minute picker, zero external deps.
  • Cooldown snake game — pure-Qt/pure-SwiftUI snake for when you've burned through your budget and need to kill a few minutes. Persistent high score.

Chrome & themes

  • Five hand-tuned themes — Obsidian, Aurora, Ember, Mint, Matrix — plus unlimited user-authored JSON themes via Settings → Themes or by dropping a .json into your platform's themes folder.
  • AI-agent theme prompt (docs/themes/AGENT_PROMPT.md) — hand any chat agent a reference image or vibe description and get back a drop-in theme JSON.
  • Win11 Mica glass / macOS NSVisualEffectView — real native vibrancy, no Electron, no WebView.
  • Edge-drag resize — hover any edge or corner, cursor changes, click-drag. Minimum bounds track your font metrics so text never clips. New geometry persists across launches.

Privacy & control

  • OS-native credential storage on Windows — Windows Credential Manager (service 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.
  • Multi-account support (Windows v2.2.0+) — track multiple Claude accounts (Personal + Work) in one install. Per-account credentials, per-account history, switch active account from the widget label or Settings → Accounts. Sign-out is account-scoped — the others stay intact.
  • 30-day local history (Windows v2.1.0+) — rolling per-account history file in %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.
  • One-click sign-out — Settings → Credentials → save with an empty sessionKey. Confirmation dialog, then that account's credentials and history are wiped from the OS store. Other accounts left intact.
  • Drag-anywhere, pin/unpin, compact mode, full keyboard shortcuts (Ctrl+R, Ctrl+,, Ctrl+D, Ctrl+H).
  • No telemetry, no analytics, no ads. One network destination: claude.ai, using your own session cookie. See SECURITY.md for why no data ever comes back to us.

Themes

<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/.


First-run setup

The easy way (Windows 11 native) — no DevTools

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.

Manual sessionKey (power-user / Python build)

Prefer to paste the key by hand, or running the cross-platform Python build?

  1. Go to claude.ai and sign in.
  2. Open DevTools (⌥⌘I on macOS, F12 on Windows).
  3. Navigate to Application → Cookies → claude.ai.
  4. Copy the value of the sessionKey cookie.
  5. Paste it into Sanduhr (Windows native: Settings → Accounts → Add by sessionKey).

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.


Controls

ActionEffect
🎨 ThemeOpen theme picker menu
⚙ SettingsCredentials · Themes · Pacing · Help tabs
📊 GraphCycle sparkline: Classic / Horizon
↕ CompactToggle compact mode (Ctrl+D)
⏳ FocusSwap tier cards for the deep-work hourglass
🐍 SnakePlay the cooldown snake game
RefreshForce a data refresh (Ctrl+R)
Pin / UnpinToggle always-on-top
Drag anywhereReposition the widget
Drag any edge or cornerResize the widget
Double-click anywhereToggle compact mode
Right-clickRefresh / Compact / Settings / Quit
×Close Sanduhr

Full keybindings documented in the in-app Settings → Help tab.


Docs


Roadmap

Shipped in v2.2.0 (Windows)

  • ☑ Multi-account support (Personal + Work in one install)
  • ☑ Per-account history files + aggregated overlay view in Settings → History
  • ☑ Active-account label in the widget (click to cycle)
  • ☑ Account-scoped sign-out (other accounts left intact)
  • ☑ extra_usage tier (API credit spend tracking)

Shipped in v2.1.0 (Windows)

  • ☑ Historical usage dashboard with CSV export (30-day retention)
  • ☑ Settings → History tab with stacked per-tier line charts (Week / Month)

Shipped in v2.0.4

  • ☑ Pace ghost (always-on pace position tick on every bar)
  • ☑ Horizon sparkline (replaces pulse histogram)
  • ☑ Breathing glass (subliminal accent pulse)
  • ☑ Edge-drag resize with dynamic minimum bounds
  • ☑ Deep-work focus timer with digitised hourglass
  • ☑ Cooldown snake game
  • ☑ Advanced pacing metrics (Cooldown required, Surplus)
  • ☑ One-click sign-out from Settings

Up next

  • ☑ Microsoft Store listing live (since v3.1.0 on the .NET build)
  • ☑ Homebrew install available via estevanhernandez-stack-ed/tap (repo)
  • ☐ Homebrew cask submission to core Homebrew/homebrew-cask (pending notability bar)
  • ☐ winget manifest (pending MS Store cert)
  • ☐ Routines daily-quota tracking (Claude Code routines — different endpoint, count-based shape)
  • ☐ Real-time mode via local Claude Code log read (alongside the /usage endpoint)
  • ☐ Mac parity for v2.1.0 / v2.2.0 features (history, multi-account)
  • ☐ Auto-start on boot (native builds)
  • ☐ Antigravity (Google Gemini IDE) quota tracking
  • ☐ Official Anthropic read-only usage endpoint support (pending Anthropic response)

Why "Sanduhr für Claude"?

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.


License

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>

Source 4 files
hooks/register.tsx 533 lines
1import { 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}
533
hooks/band.ts 529 lines
1import 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}
529
hooks/meters.ts 422 lines
1import 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}
422
types/index.d.ts 71 lines
1// 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