An animated progress band above the prompt: one row per plan with its name, an LED-matrix bar whose pixels twinkle and brighten toward the head over a…

Un mod Claude Code (function hooks) qui affiche un bandeau de progression animé au-dessus du prompt : une ligne par plan avec son nom, une barre en matrice de LED, une pastille lumineuse « étape n/total » qui suit la tête, le pourcentage et un bouton ✕. Claude signale lui-même son avancement en appelant l'outil report_progress que le mod enregistre. Un carillon joue quand une étape se termine, un arpège quand le plan entier est fini. Sous les plans, une ligne montre ce qu'il reste des limites de 5 heures et de 7 jours du forfait. Chaque dossier de projet a son propre bandeau. L'état vit dans $.store : il survit à un redémarrage.

Les vraies barres SVG du mod, rendues par Chrome image par image avec leurs animations SMIL et CSS figées à des instants exacts, sur un panneau qui imite le Code tab.
Dans l'app Desktop (Code tab), chaque barre est un SVG dessiné comme une image, sans fond propre, dans les deux thèmes :
path par niveau, pour que le document reste léger : 26 000 caractères au pire.

Dans le terminal, la même idée en cellules : la matrice est faite de caractères braille dont les points s'allument plus souvent vers la tête, en lavande sur la piste. La pastille est arrondie par des demi-blocs. Les points se redistribuent en permanence, environ 7 fois par seconde pendant le travail et 2 à 3 fois au repos. Une boucle $.clock.every repeint les deux Raster du bandeau avec $.ui.blit, sans repasser par ui.render, et s'arrête quand le bandeau est replié.
Sous les plans, une ligne compacte montre ce qu'il reste de chaque fenêtre du forfait : une mini-jauge en LED, 62% et le compte à rebours ↻ 2h 14m. La couleur passe du bleu à l'ambre sous 25 %, puis au rouge sous 10 %. La ligne s'affiche aussi seule quand aucun plan n'est en cours.

Le moteur expose le pourcentage utilisé de chaque fenêtre et son heure de remise à zéro, pas un nombre de tokens : le mod affiche donc ce qui reste en pourcentage. Les chiffres viennent de $.session.usage() au démarrage, puis de l'événement session.measure, qui les pousse après chaque tour et dès qu'une fenêtre bouge d'un point. Un minuteur d'une minute garde le compte à rebours à jour. Hors abonnement, le moteur n'a pas ces fenêtres et la ligne n'apparaît pas. L'option usage la masque.
Un clic directement sur la mini-jauge bleue d'une fenêtre (ou sur « 5h » / « 7d ») remplace la ligne des tokens par cette seule fenêtre. Sa jauge reste la même capsule bleue, en plus grand et centrée : elle se déploie depuis son milieu, un reflet la parcourt et l'encoche de la limite tombe en place avec un anneau de lumière ambre. À gauche, « ⚑ 5h » en jaune. À droite, la limite en cours (« 50% », ou « no limit »), puis « → 70% » pour le pourcentage sous la souris. Un clic sur la capsule y pose l'encoche, un nouveau clic la fait glisser ailleurs. La limite se cale sur des pas de 5 %, de 5 à 90 %. Remove retire l'encoche. Rien n'est enregistré avant le ✓ émeraude à droite, qui apparaît en rebondissant et trace sa coche à l'ouverture : il garde la limite (ou son retrait) et ramène les deux fenêtres. Leurs capsules se redéploient, et l'encoche de celle qui vient d'être réglée tombe en place. La limite s'affiche ensuite en ⚑ 50% après la jauge. Les limites valent pour tout le compte, comme les fenêtres.
Le survol et le clic passent par des éléments que toutes les surfaces savent dessiner : une rangée de 18 boutons invisibles posée sur la piste, reliés par des portées de survol à la lecture fixe à côté. Dans le terminal, ◀ et ▶, ou les touches h et l, déplacent la limite et ✓ (touche s) la valide. La souris y passe aussi par une couche Client (hooks/views/dial-drag.tsx) là où le terminal la transmet.

Quand une fenêtre descend à sa limite, le mod :
$.turn.abort ;report_progress, avec un message qui dit à Claude de s'arrêter ;fx/alert.wav et affiche une carte d'alerte animée : un emblème à anneaux sonar et arc en comète, le reste de la fenêtre, la limite et la remise à zéro, et une vague de LED qui défile.
La carte propose deux suites :
Écrire soi-même un message à Claude pendant la pause compte aussi comme une reprise.
Le store d'un plugin est partagé par toutes les sessions de la machine. Le mod range donc chaque plan sous le dossier racine du projet, $.session.root(), avec une clé plan:<dossier>:<plan>. Chaque conversation ne voit que les plans de son dossier : deux projets différents ont chacun leur bandeau, et deux conversations dans le même dossier partagent le leur. Le bandeau relit le store chaque minute : un plan lancé, avancé, terminé ou retiré par une autre conversation du dossier apparaît dans la minute, avec la même animation de remplissage et le même flash. Les sons restent réservés à la conversation qui fait le travail. /progress clear ne vide que le dossier courant.
Les plans de la première version n'avaient pas de dossier. Au chargement, ceux qui sont terminés sont supprimés. Un plan encore en cours n'est affiché nulle part : il est adopté par la première conversation qui le signale à nouveau.

Capturé dans une vraie session claude --plugin-dir ./progress (Claude Code 2.1.287), Claude appelant report_progress lui-même.
Un vrai bouton « ▴ Collapse », au bout de la ligne des quotas, replie tous les plans en une seule ligne, façon stepper : un anneau par plan, reliés par un fil. Un plan terminé est un disque émeraude avec sa coche. Un plan en cours est un arc lavande qui fait le tour de l'anneau à hauteur de son avancement, avec un cœur qui respire, et une étincelle tourne autour de celui sur lequel Claude travaille. Suivent le nom du plan en cours, son étape (« 4/5 · 4 done »), le pourcentage global et le bouton « ▾ 5 tasks », qui rouvre la liste. Quand tout est fini, la ligne affiche « All done » en émeraude. Au-delà de 12 plans, les plus anciens plans terminés se résument en « +3 ». Au repli, les anneaux apparaissent en fondu l'un après l'autre. À la réouverture, chaque barre se dévoile derrière un bord lumineux. La ligne des quotas reste en dessous. Le choix est retenu par dossier, dans view:<dossier>. Dans le terminal, la ligne repliée s'écrit ✓─✓─◑ Tests 2/4 · 1 done 63%, et la boucle d'animation ne repeint plus les lignes masquées.

Quand vous envoyez un message pendant que Claude travaille, il s'ajoute au bandeau comme une tâche en attente. Il est nommé d'après sa première ligne, en italique, avec un rond creux indigo. Sa barre ne se remplit pas : quelques LED indigo y respirent lentement, une lueur douce va et vient d'un bout à l'autre comme un scanner, et une pastille vitrée « ⧗ Waiting #1 » donne sa place dans la file. Le sablier se retourne de temps en temps. Dans le terminal, la pastille est suivie d'une piste pointillée où glisse la même lueur. Dans la ligne repliée, c'est un anneau en pointillés qui tourne lentement, et le résumé ajoute « · 1 waiting ».
Quand Claude commence un plan pour ce message, il appelle report_progress avec queued: true : la ligne en attente la plus ancienne devient ce plan, à la même place, et sa barre se dévoile derrière un bord lumineux. À la fin du tour, les messages encore en attente de cette conversation disparaissent, puisque Claude les a tous reçus. Les commandes /…, les notifications de tâches et les messages d'autres sessions ne créent pas de ligne. Les lignes en attente sont rangées par dossier comme les plans. Les autres conversations du dossier les voient donc, mais seule la conversation d'origine les efface. Une ligne orpheline de plus de 6 heures, après un plantage, est supprimée au chargement.

✕ qui dissoutLe ✕ ne fait pas disparaître la ligne d'un coup. Sur Desktop, chaque LED se détache, s'envole à son propre angle en s'illuminant de blanc puis s'éteint, en partant de la pastille. La pastille gonfle puis éclate. L'éclat est dessiné sur une couche à lui, posée au premier plan et plus grande que la ligne, pour que rien ne le coupe. Il enchaîne un flash radial, une onde de choc blanche et vive suivie d'une onde colorée et diffuse, une étoile de traînées d'étincelles et des paillettes qui retombent. La piste s'efface ensuite. Dans le terminal, les cellules se changent en étincelles ✦ puis · avant de s'éteindre, et la pastille s'effrite. Le nom du plan se raye pendant ce temps, avec le son fx/dissolve.wav : un souffle qui retombe et quelques reflets cristallins. Après un peu plus d'une seconde, le plan quitte le bandeau et le store.
| Hook | Ce qu'il fait |
|---|---|
session.start | Enregistre l'outil report_progress ($.tool.register) et la commande /progress ($.command.register, immediate), puis charge les plans depuis $.store. |
tool.describe sur l'outil | Le garde listé dans le prompt plutôt que derrière ToolSearch, pour que Claude l'utilise sans le chercher. |
tool.call sur mcp__progress__report_progress | Lit l'appel (plan, step, total, note, done, remove, queued), met le plan à jour (ou transforme le plus ancien message en attente en ce plan, avec queued), l'enregistre sous plan:<id>, lance l'animation de remplissage (et le flash si le plan se termine), redessine, puis joue fx/step-done.wav ou fx/plan-done.wav sans retenir la réponse de l'outil. Un .catch renvoie l'erreur au modèle plutôt que de laisser l'appel sans réponse. |
ui.render sur AbovePrompt | Dessine l'en-tête puis une ligne par plan, au-dessus de ce que dessinent les mods en dessous (await next(e)). Laisse le bandeau tel quel sans plan ou pendant un sondage. |
turn.start | Relit le store, pour afficher les plans signalés par une autre session du même dossier, et lance le spinner. |
session.measure | Met à jour les fenêtres de 5 h et de 7 jours quand le moteur les mesure. |
turn.complete | Arrête le spinner et le reflet à la fin du tour principal, et efface les messages en attente de cette conversation. |
prompt.submit | Ajoute une ligne en attente pour un message envoyé pendant un tour (e.turnId présent, venu de la personne) ; lève aussi la pause quand la personne écrit pendant une pause. |
session.end | Efface les messages en attente de la conversation qui se termine. |
command.run sur progress | /progress liste les plans, /progress clear les supprime tous, /progress remove <plan> en supprime un. |
prompt.compose | Ajoute une courte section au prompt système, quand l'outil est offert, pour demander à Claude de signaler son avancement. |
Appels sur $ : audio.play, clock.every, clock.now, command.register, session.root, session.usage, store.delete, store.get, store.keys, store.set, tool.register, ui.blit, ui.invalidate, ui.resolve.
report_progress| Champ | Type | Rôle |
|---|---|---|
plan | string, requis | Le nom du plan, étiquette de la ligne ; le même nom met la ligne à jour. |
step | integer ≥ 0 | Étapes terminées. |
total | integer ≥ 1 | Nombre d'étapes ; requis à la première mention d'un plan. |
note | string | L'étape qui commence, en un à trois mots (« Tests », « Deploy ») : le texte de la pastille. |
done | boolean | true quand le plan est fini : step devient total. |
remove | boolean | true pour retirer la ligne. |
queued | boolean | true quand ce nouveau plan prend en charge un message envoyé pendant le travail : la plus ancienne ligne en attente devient ce plan. |
La pastille affiche l'étape en cours, step + 1, et le pourcentage les étapes terminées : « Tests 3/5 » à 40 %. Le pourcentage plafonne à 99 % tant que step < total.
Les deux options sont des booléens, à true par défaut, visibles dans /config.
usage affiche la ligne des limites de 5 h et de 7 jours.sound joue les deux sons. fx/step-done.wav est un « ti-ding » de verre, deux notes à la quinte. fx/plan-done.wav est un arpège do-mi-sol-do avec une étincelle aiguë et une réverbération courte. Les deux sont synthétisés en Python, sans dépendance.progress/
├── .claude-plugin/
│ ├── plugin.json # manifeste (userConfig: sound)
│ └── types/ # écrit par Claude Code au chargement (ignoré par git)
├── hooks/
│ ├── hooks.json # "modules": ["./register.ts"]
│ ├── register.ts # les hooks, la boucle d'animation, les helpers qui prennent $
│ ├── plans.ts # le modèle : Plan, reportOf, applied, chipOf, les clés par dossier…
│ ├── usage.ts # les fenêtres de 5 h et de 7 jours : reste, couleur, compte à rebours
│ ├── guard.ts # les limites et la pause : dépassement, état, messages à Claude
│ ├── meter.ts # le terminal : pixels braille, pastille, pourcentage, icône, filet, dissolution
│ ├── motion.ts # les durées, l'easing, le spinner, le scintillement
│ ├── palette.ts # les couleurs, le bruit déterministe des pixels
│ ├── raster.ts # l'encodage base64 des cellules d'un Raster (et son décodage pour les tests)
│ └── views/
│ ├── band.tsx # le bandeau en JSX : terminal, Desktop, texte
│ ├── svg-bar.ts # les SVG du Desktop : barres, ligne en attente, anneaux, capsules de quota
│ ├── svg-alert.ts # l'emblème et la vague de l'alerte, la coche ✓ du sélecteur
│ ├── guard.tsx # la carte d'alerte, le sélecteur, les boutons de limite
│ └── dial-drag.tsx # la couche Client du sélecteur, dans le terminal
├── fx/
│ ├── step-done.wav
│ ├── plan-done.wav
│ ├── alert.wav
│ └── dissolve.wav
├── screenshots/ # le GIF et les captures de ce README
├── tests/ # 32 tests pour `claude plugin test`
└── tsconfig.json
Prérequis : Claude Code ≥ 2.1.287, où les mods sont actifs par défaut, et un CLI connecté (claude auth login). L'activation des mods est servie par compte : un CLI déconnecté refuse tout hooks module avec « the rollout switch served off ».
Depuis le marketplace nyxstudio-mods de ce dépôt :
claude plugin marketplace add Certicore/ClaudeMOD
claude plugin install progress@nyxstudio-mods
Ou pour une seule session, depuis un clone du dépôt :
claude --plugin-dir ./progress
Puis demander un travail en plusieurs étapes, par exemple « fais ce refactor en 4 étapes ». Le bandeau apparaît dès le premier appel de report_progress. /progress liste les plans. ✕, au clic ou au clavier après ctrl+x tab, retire une ligne. Le rendu est le plus beau dans un terminal true-color (Ghostty, iTerm2, WezTerm, kitty, Terminal de macOS 26).
claude plugin validate --strict ./progress # analyse statique : hooks et appels
npx -p typescript tsc -p ./progress # typage de hooks/ et tests/
claude plugin test ./progress # les 32 tests
Les tests stubbent le monde sous le mod (store.*, audio.play, ui.blit, tool.register, ui.render, l'horloge avec mock.clock), montent le bandeau avec $.ui.mount sur les surfaces terminal et Desktop, décodent les cellules des Raster pour lire la pastille et le pourcentage, lisent le SVG du Desktop, avancent l'horloge pour suivre le remplissage, et pressent ✕. Un test couvre le rechargement à chaud : un module rechargé qui reçoit un appel avant son session.start relit d'abord le store.
✕ dans l'une retire le plan dans l'autre.afplay sur macOS) ; sans lecteur, le bandeau se met à jour en silence.AbovePrompt est dessiné dans le terminal et le Code tab de l'app Desktop, pas dans VS Code ni en -p.hooks/register.ts 1279 lines1import type {
2 EngineInterface,
3 Frozen,
4 On,
5 PluginOptions,
6 PromptComposeSection,
7 RenderElement,
8 RenderInput,
9 Timer,
10} from 'claude-code'
11
12import {
13 breachOf,
14 CHECKPOINT_PROMPT,
15 LIMIT_START,
16 nudgedLimit,
17 denialOf,
18 guardStateOf,
19 limitsOf,
20 RESET_PROMPT,
21 RESUME_PROMPT,
22 type Limits,
23 type Pause,
24} from './guard'
25import type { FrameContext, PlanFrame } from './meter'
26import { quotasOf, type Quota } from './usage'
27import { flashProgress, FILL_MS, FRAME_MS, isFilling, shownShare, type Fill } from './motion'
28import {
29 applied,
30 excerptOf,
31 idOf,
32 isDone,
33 isPlan,
34 isWaiting,
35 folderKeyOf,
36 keyOf,
37 legacyKeyOf,
38 parseKey,
39 reportOf,
40 sortedPlans,
41 summaryOf,
42 WAITING_STALE_MS,
43 type Plan,
44} from './plans'
45import {
46 alertWaveWidthOf,
47 desktopBandView,
48 glyphsCellsOf,
49 GLYPHS_KEY,
50 metersCellsOf,
51 METERS_KEY,
52 plainBandView,
53 terminalBandView,
54 terminalLayoutOf,
55 type BandModel,
56 type Move,
57 type TerminalLayout,
58} from './views/band'
59import { DISSOLVE_MS } from './views/svg-bar'
60import { ALERT_WAVE_KEY, alertWaveCellsOf, type GuardView } from './views/guard'
61
62/** The tool's short name; the model calls it as `mcp__progress__report_progress`. */
63const TOOL_NAME = 'report_progress'
64/** The slash command, `/progress`. */
65const COMMAND_NAME = 'progress'
66/** The plugin's own clips, under `fx/`. */
67const STEP_SOUND = 'fx/step-done.wav'
68const PLAN_SOUND = 'fx/plan-done.wav'
69const ALERT_SOUND = 'fx/alert.wav'
70const REMOVE_SOUND = 'fx/dissolve.wav'
71/** The store key of the limits: one set for the account, as the windows are. */
72const LIMITS_KEY = 'limits'
73/** How long after a window's reset the work picks up, so the new window is in place. */
74const RESET_GRACE_MS = 20_000
75/** The loop stops when no blit has landed for this long (the band is collapsed or gone). */
76const STALE_MS = 3000
77
78const TOOL_DESCRIPTION =
79 'Report your progress on a multi-step plan. The user sees one row per plan ' +
80 'above their prompt: the name, a progress bar of animated pixels and a chip ' +
81 'riding its head that names the current stage and the step under way ' +
82 '("Tests 3/5"), the percentage and a ✕ to dismiss it. Call it when you start ' +
83 'a plan of two or more steps (step 0 with total), again after each step you ' +
84 'complete (step n), and once more with done: true when the whole plan is ' +
85 'finished. Reuse the exact same plan name to update a row, and give each call ' +
86 'a note naming the stage you are entering in one to three words. Set remove: ' +
87 'true to drop a row. A chime plays when a step completes. A message the user ' +
88 'sends while you work shows as a Waiting row; when you start a plan for it, ' +
89 'set queued: true and that row turns into your plan.'
90
91const INPUT_SCHEMA = {
92 type: 'object',
93 properties: {
94 plan: {
95 type: 'string',
96 description:
97 "The plan's name, the row's label (e.g. \"Migrate auth to OAuth\"). " +
98 'Reuse the exact same name to update that plan.',
99 },
100 step: {
101 type: 'integer',
102 minimum: 0,
103 description: 'How many steps are completed so far.',
104 },
105 total: {
106 type: 'integer',
107 minimum: 1,
108 description: 'How many steps the plan has in all. Required the first time a plan is reported.',
109 },
110 note: {
111 type: 'string',
112 description:
113 'The stage you are entering, one to three words (e.g. "Tests", "Deploy"); shown on the chip at the head of the bar.',
114 },
115 done: {
116 type: 'boolean',
117 description: 'true once the whole plan is finished (sets step to total).',
118 },
119 remove: {
120 type: 'boolean',
121 description: "true to remove the plan's row.",
122 },
123 queued: {
124 type: 'boolean',
125 description:
126 'true when this new plan takes up a message the user sent while you were working: ' +
127 'the oldest Waiting row turns into this plan.',
128 },
129 },
130 required: ['plan'],
131 additionalProperties: false,
132}
133
134/** The system-prompt section asking the model to report, sent while the tool is offered. */
135const PROMPT_SECTION: PromptComposeSection = {
136 id: 'progress:report',
137 text:
138 '# Progress band\n' +
139 'The user watches a progress bar above their prompt for every plan you report ' +
140 'with the mcp__progress__report_progress tool. Whenever you work through a ' +
141 'plan of two or more steps, call it once when you start (step 0, total), ' +
142 'again after each step you complete, and with done: true at the end, each ' +
143 'time with a note naming the next stage in one to three words. Keep the ' +
144 'calls short and do not narrate them. A message the user sends while you ' +
145 'work shows in the band as Waiting: when you start a plan for it, report ' +
146 'that plan with queued: true.',
147 scope: 'session',
148}
149
150/** Where the terminal band was last drawn, so the frame loop can repaint its Rasters. */
151type Mounted = {
152 requestId: string
153 layout: TerminalLayout
154 /** The plans' ids in the order the Rasters' rows draw them. */
155 ids: readonly string[]
156 /** The alert wave's width, while the alert is drawn. */
157 alertCells: number | null
158}
159
160/** The plans the band draws, by id; loaded from `$.store` at the start and before each turn. */
161let plans = new Map<string, Plan>()
162/**
163 * Whether this instance of the module has read the store yet: a hot reload
164 * can hand it a tool call or a drawing before its `session.start` has run.
165 */
166let hasLoaded = false
167/** The project folder the band shows the plans of, as the store names it; null until first read. */
168let folder: string | null = null
169/**
170 * Unfinished plans of the first release, which belonged to no folder: kept
171 * out of every band, and adopted by the first folder that reports one again.
172 */
173let legacy = new Map<string, Plan>()
174/** Each plan's fill on its way to a new share. */
175const fills = new Map<string, Fill>()
176/** When each plan that just finished starts its flash. */
177const flashes = new Map<string, number>()
178/** When each plan whose ✕ was pressed began to come apart. */
179const removing = new Map<string, number>()
180/** When each plan that took up a waiting message lit up: its bar sweeps in once. */
181const ignites = new Map<string, number>()
182/** How long a lit-up plan's sweep plays. */
183const IGNITE_MS = 900
184/** The `sound` option: chimes play while true. */
185let isSoundOn = true
186/** The `usage` option: the 5-hour and 7-day windows show under the plans while true. */
187let isUsageOn = true
188/** The 5-hour and 7-day windows as the engine last measured them; empty off a subscription. */
189let quotas: Quota[] = []
190/** Whether this instance of the module has asked the engine for the windows yet. */
191let hasUsage = false
192/** The minute tick: re-reads the folder's plans other sessions report, and keeps the windows' countdowns current. */
193let minuteTick: Timer | null = null
194/** How often the band re-reads the store for what other sessions of the folder reported. */
195const REFRESH_MS = 60_000
196/** Whether a model turn runs: the active plan spins and shimmers meanwhile. */
197let isWorking = false
198/** The running main-loop turn, as `turn.start` named it: what a pause stops. */
199let runningTurn: string | null = null
200/** The limits the person set, per window, in percent left. */
201let limits: Limits = {}
202/** Whether this folder's plans are folded into one line, and when that last changed (its animation plays a moment). */
203let isFolded = false
204let foldedAt = Number.NEGATIVE_INFINITY
205/** How long a fold or an unfold animates; a redraw after it draws the band at rest. */
206const FOLD_MS = 1600
207/** The window whose limit picker is open, in the quota row's place, and when it opened (its dial opens out). */
208let editing: string | null = null
209let editedAt = Number.NEGATIVE_INFINITY
210/** The window whose limit ✓ last validated, and when: the quota row comes back with it lit. */
211let confirmed: { kind: string; at: number } | null = null
212/** How long the picker's opening and the quota row's return animate. */
213const OPEN_MS = 1200
214const RETURN_MS = 1300
215/** The limit the picker shows, the one its knob last slid from, and when. */
216let draft: number | null = null
217let draftFrom: number | null = null
218let draftMovedAt = 0
219/** The surfaces whose dial drag region has said it runs; elsewhere the dial takes clicks instead. */
220const dragReady = new Set<string>()
221/** How long a nudge's slide plays: a redraw after it draws the knob at rest. */
222const NUDGE_MS = 450
223/** The pause at a limit, while one holds the work. */
224let pause: Pause | null = null
225/** Per window, the reset time of the window the person chose to go on in past its limit. */
226let acknowledged: Record<string, number> = {}
227/** The wake-up that resumes the work once a saved pause's window resets. */
228let resumeTimer: Timer | null = null
229let mounted: Mounted | null = null
230let loop: Timer | null = null
231/** The cells last blitted per Raster key, so an unchanged frame sends nothing. */
232const blitted = new Map<string, string>()
233let lastLandedAt = 0
234
235/**
236 * Registers the progress band: the `report_progress` tool and `/progress` at
237 * the session's start, the tool's hook that updates a plan, animates it and
238 * plays a chime, the band's drawing, the command, the store's re-read before
239 * each turn, the tool kept out of ToolSearch, and the prompt section that
240 * asks the model to report.
241 *
242 * @param on the engine's registrar
243 * @param options the plugin's options; `sound` (default true) plays the chimes
244 */
245export function register(on: On, options: PluginOptions): void {
246 isSoundOn = options.sound !== false
247 isUsageOn = options.usage !== false
248
249 on('session.start', async ($, e, next) => {
250 await $.tool.register({
251 name: TOOL_NAME,
252 description: TOOL_DESCRIPTION,
253 inputSchema: INPUT_SCHEMA,
254 })
255 await loadPlans($)
256 await loadGuard($)
257 await refreshUsage($)
258 startMinuteTick($)
259 await armResume($)
260
261 try {
262 await $.command.register({
263 name: COMMAND_NAME,
264 description: 'List the plans in the progress band; clear drops them all, remove <plan> one',
265 argumentHint: '[clear | remove <plan>]',
266 immediate: true,
267 })
268 } catch {
269 // Another plugin's /progress stands; the band and the tool work without it.
270 }
271
272 return next(e)
273 })
274
275 on('turn.start', async ($, e, next) => {
276 isWorking = true
277 runningTurn = e.turnId
278 // Another session may have reported since: its plans share the store.
279 await loadPlans($)
280 $.ui.invalidate('ui.render')
281
282 return next(e)
283 })
284
285 on('turn.complete', async ($, e, next) => {
286 if (e.agentId === undefined) {
287 isWorking = false
288 runningTurn = null
289 // The messages sent during the turn have reached Claude by its end: none waits any more.
290 await clearWaiting($)
291 $.ui.invalidate('ui.render')
292 }
293
294 return next(e)
295 })
296
297 on('session.end', async ($, e, next) => {
298 await clearWaiting($)
299
300 return next(e)
301 })
302
303 on('session.measure', ($, e, next) => {
304 // The windows' figures, pushed after each turn and whenever one moves a point.
305 if (e.changed.includes('rateLimits')) {
306 quotas = quotasOf(e.rateLimits)
307 hasUsage = true
308 $.ui.invalidate('ui.render')
309 void checkLimits($)
310 }
311
312 return next(e)
313 })
314
315 // The pause holds every tool but this plugin's own, outermost of its hooks.
316 on('tool.call', ($, e, next) => {
317 if (pause !== null && e.tool !== `mcp__progress__${TOOL_NAME}`) {
318 return { deny: denialOf(pause) }
319 }
320
321 return next(e)
322 })
323
324 on('prompt.submit', async ($, e, next) => {
325 // The person writing to Claude during a pause is the person going on.
326 if (pause !== null && e.origin.kind === 'composer') {
327 await resumeWork($, false)
328 $.ui.toast('Usage-limit pause lifted')
329 }
330
331 // A message the person sends while a turn runs waits its turn: it shows as a row of its own.
332 if (e.turnId !== undefined && isPersonOrigin(e.origin.kind) && e.text.trim() !== '' && !e.text.trim().startsWith('/')) {
333 await addWaiting($, e.text)
334 }
335
336 return next(e)
337 })
338
339 // The limit dial's drag region posts the limit under the pointer.
340 on('ui.message', { module: /dial-drag/ }, ($, e) => {
341 const data = e.data as { limit?: unknown; isFinal?: unknown; isReady?: unknown } | null
342
343 if (data?.isReady === true) {
344 if (!dragReady.has(e.surface)) {
345 dragReady.add(e.surface)
346 $.ui.invalidate('ui.render')
347 }
348
349 return {}
350 }
351
352 const limit = typeof data?.limit === 'number' && Number.isFinite(data.limit) ? nudgedLimit(data.limit, 0) : null
353
354 if (limit !== null && editing !== null && limit !== draft) {
355 draft = limit
356 draftFrom = null
357 $.ui.invalidate('ui.render')
358 }
359
360 return {}
361 })
362
363 on('tool.describe', { tool: 'mcp__progress__report_progress' }, async ($, e, next) => {
364 // Listed in the prompt, not behind ToolSearch: the model reaches for it unprompted.
365 const described = await next(e)
366
367 return { ...described, isDeferred: false }
368 })
369
370 on('tool.call', { tool: 'mcp__progress__report_progress' }, async ($, e) => {
371 await ensureLoaded($)
372 const report = reportOf(e)
373
374 if ('error' in report) {
375 return { result: `report_progress refused: ${report.error}` }
376 }
377
378 const id = idOf(report.name)
379 const adopted = plans.has(id) ? undefined : legacy.get(id)
380 const previous = plans.get(id) ?? adopted
381
382 if (report.isRemoved) {
383 await removePlan($, id)
384
385 return {
386 result:
387 previous === undefined
388 ? `No plan named "${report.name}" in the progress band.`
389 : `Removed "${previous.name}" from the progress band.`,
390 }
391 }
392
393 const now = await $.clock.now()
394 const owner = report.isQueued && previous === undefined ? await $.session.id() : null
395 const taken = owner === null ? undefined : sortedPlans(plans.values()).find(plan => plan.waiting?.owner === owner)
396 const reported = applied(previous, report, now)
397
398 if ('error' in reported) {
399 return { result: `report_progress refused: ${reported.error}` }
400 }
401
402 // A plan that takes up a waiting message stands in its row, and lights up there.
403 const updated = taken === undefined ? reported : { ...reported, createdAt: taken.createdAt }
404
405 if (taken !== undefined) {
406 await removePlan($, taken.id)
407 ignites.set(updated.id, now)
408 }
409
410 const fromShare = shareAt(previous, now)
411 const toShare = updated.step / updated.total
412 const wasDone = previous !== undefined && isDone(previous)
413 const hasFinished = isDone(updated) && !wasDone
414 const hasAdvanced = updated.step > (previous?.step ?? 0)
415
416 plans.set(updated.id, updated)
417
418 if (fromShare !== toShare) {
419 fills.set(updated.id, { from: fromShare, to: toShare, startedAt: now })
420 }
421
422 if (hasFinished) {
423 flashes.set(updated.id, now + FILL_MS * 0.7)
424 }
425
426 $.ui.invalidate('ui.render')
427 startLoop($)
428 await $.store.set(keyOf(await folderOf($), updated.id), updated)
429
430 if (adopted !== undefined) {
431 legacy.delete(id)
432 await $.store.delete(legacyKeyOf(id)).catch(() => undefined)
433 }
434
435 if (hasFinished) {
436 playSound($, PLAN_SOUND)
437 } else if (hasAdvanced) {
438 playSound($, STEP_SOUND)
439 }
440
441 return {
442 result: `${taken === undefined ? '' : `Took up the waiting message "${taken.name}". `}Progress band: ${summaryOf(updated)}`,
443 }
444 }).catch(($, e, next) => ({
445 result: `report_progress failed (${next.error.kind}): ${next.error.message}`,
446 }))
447
448 on('command.run', { command: 'progress' }, async ($, e) => {
449 await ensureLoaded($)
450 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
451 const target = rest.join(' ')
452
453 if (verb === 'clear') {
454 const count = plans.size
455
456 for (const id of [...plans.keys()]) {
457 await removePlan($, id)
458 }
459
460 return { text: count === 0 ? 'No plan to clear.' : `Cleared ${count} plan${count === 1 ? '' : 's'}.` }
461 }
462
463 if (verb === 'remove') {
464 const id = idOf(target)
465 const plan = plans.get(id)
466
467 if (target === '' || plan === undefined) {
468 return { text: `No plan named "${target}". /progress lists them.` }
469 }
470
471 await removePlan($, id)
472
473 return { text: `Removed "${plan.name}".` }
474 }
475
476 const lines = sortedPlans(plans.values()).map(
477 plan => `${isWaiting(plan) ? '◌' : isDone(plan) ? '✓' : '·'} ${summaryOf(plan)}`,
478 )
479
480 return {
481 text:
482 lines.length === 0
483 ? 'No plan in progress. Claude adds one by calling report_progress.'
484 : lines.join('\n'),
485 }
486 })
487
488 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
489 await ensureLoaded($)
490
491 if (e.props.hasSurvey || (plans.size === 0 && shownQuotas().length === 0 && pause === null)) {
492 if (e.surface === 'terminal') {
493 mounted = null
494 }
495
496 return next(e)
497 }
498
499 isWorking = e.props.isWorking
500 const theirs = await next(e)
501 const now = await $.clock.now()
502
503 return drawBand($, e, theirs, now)
504 })
505
506 on('prompt.compose', async ($, e, next) => {
507 const result = await next(e)
508
509 if (!e.tools.includes(`mcp__progress__${TOOL_NAME}`)) {
510 return result
511 }
512
513 const sections = result.sections.filter(section => section.id !== PROMPT_SECTION.id)
514
515 return { sections: [...sections, PROMPT_SECTION] }
516 })
517}
518
519/**
520 * Draws the band for the surface: the terminal's Rasters (and remembers
521 * where, for the frame loop), the desktop's SVG bars, plain text elsewhere.
522 */
523function drawBand(
524 $: EngineInterface,
525 e: Frozen<RenderInput<'AbovePrompt'>>,
526 theirs: RenderElement,
527 now: number,
528): RenderElement {
529 const frames = framesAt(now)
530 const context: FrameContext = { now, isWorking, isPaused: pause !== null }
531 const columns = e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? 80)
532 const model: BandModel = {
533 frames,
534 context,
535 columns,
536 theirs,
537 quotas: shownQuotas(),
538 guard: guardViewOf($, now, e.surface),
539 fold: {
540 isFolded,
541 isAnimating: now - foldedAt < FOLD_MS,
542 onToggle: () => {
543 void toggleFold($)
544 },
545 },
546 onRemove: id => {
547 void dissolvePlan($, id)
548 },
549 }
550
551 if (e.surface === 'terminal') {
552 const { Box, Text, Button, Input, Raster, Client } = $.ui.resolve(e)
553 const layout = terminalLayoutOf(columns, frames)
554 const alertCells = pause === null ? null : alertWaveWidthOf(columns)
555
556 const shownIds = isFolded ? [] : frames.map(frame => frame.plan.id)
557
558 mounted =
559 shownIds.length === 0 && alertCells === null
560 ? null
561 : { requestId: e.requestId, layout, ids: shownIds, alertCells }
562 blitted.clear()
563
564 if (isAnimating()) {
565 startLoop($)
566 }
567
568 return terminalBandView({ Box, Text, Button, Input, Raster, Client }, model, layout)
569 }
570
571 if (e.surface === 'desktop') {
572 const { Box, Text, Button, Input, Svg, Client } = $.ui.resolve(e)
573 const moves = new Map<string, Move>()
574
575 // The SVG eases the head itself: hand it where the head is now and the time left.
576 for (const [id, fill] of fills) {
577 if (isFilling(fill, now)) {
578 moves.set(id, { from: shownShare(fill, now), durationMs: FILL_MS - (now - fill.startedAt) })
579 }
580 }
581
582 return desktopBandView({ Box, Text, Button, Input, Svg, Client }, { ...model, moves })
583 }
584
585 const { Box, Text, Button } = $.ui.resolve(e)
586
587 return plainBandView({ Box, Text, Button }, model)
588}
589
590/** The plans as the instant `now` shows them, in the order they were first reported. */
591function framesAt(now: number): PlanFrame[] {
592 const ordered = sortedPlans(plans.values())
593 const active = ordered
594 .filter(plan => !isDone(plan) && !isWaiting(plan))
595 .reduce<Plan | null>((latest, plan) => (latest === null || plan.updatedAt > latest.updatedAt ? plan : latest), null)
596
597 const queue = ordered.filter(isWaiting).map(plan => plan.id)
598
599 return ordered.map(plan => {
600 const removedAt = removing.get(plan.id)
601 const position = queue.indexOf(plan.id)
602
603 return {
604 plan,
605 share: shareAt(plan, now),
606 flash: flashProgress(flashes.get(plan.id), now),
607 isActive: active !== null && plan.id === active.id,
608 dissolve: removedAt === undefined ? null : Math.max(0, Math.min(1, (now - removedAt) / DISSOLVE_MS)),
609 isIgniting: now - (ignites.get(plan.id) ?? Number.NEGATIVE_INFINITY) < IGNITE_MS,
610 ...(position === -1 ? {} : { queue: position + 1 }),
611 }
612 })
613}
614
615/** The share a plan's bar shows at `now`: mid-fill when it is animating. */
616function shareAt(plan: Plan | undefined, now: number): number {
617 if (plan === undefined) {
618 return 0
619 }
620
621 const fill = fills.get(plan.id)
622
623 return fill !== undefined && isFilling(fill, now) ? shownShare(fill, now) : plan.step / plan.total
624}
625
626/** True while the band shows a plan or the alert: their pixels never stop moving. */
627function isAnimating(): boolean {
628 return plans.size > 0 || pause !== null
629}
630
631/** Starts the frame loop unless it runs. */
632function startLoop($: EngineInterface): void {
633 if (loop !== null) {
634 return
635 }
636
637 lastLandedAt = Number.POSITIVE_INFINITY
638 loop = $.clock.every(FRAME_MS, () => {
639 void onFrame($)
640 })
641}
642
643function stopLoop(): void {
644 loop?.cancel()
645 loop = null
646}
647
648/**
649 * One frame: repaints the glyphs and the meters of the band last drawn,
650 * each only when its cells changed, and stops the loop once nothing moves
651 * or nothing has landed for STALE_MS.
652 */
653async function onFrame($: EngineInterface): Promise<void> {
654 const now = await $.clock.now()
655
656 if (lastLandedAt === Number.POSITIVE_INFINITY) {
657 lastLandedAt = now
658 }
659
660 const target = mounted
661 const isStale = now - lastLandedAt > STALE_MS
662
663 if (target === null || isStale || !isAnimating()) {
664 settleAnimations(now)
665 stopLoop()
666
667 if (target !== null) {
668 await paint($, target, now)
669 }
670
671 return
672 }
673
674 await paint($, target, now)
675}
676
677/** Blits the band's two Rasters for `now`, skipping a frame whose rows changed shape. */
678async function paint($: EngineInterface, target: Mounted, now: number): Promise<void> {
679 const frames = framesAt(now)
680 const ids = frames.map(row => row.plan.id)
681
682 const context: FrameContext = { now, isWorking, isPaused: pause !== null }
683 const hasPlanRasters = target.ids.length > 0 && ids.join('\n') === target.ids.join('\n')
684 const repaints: [string, string, number, number][] =
685 !hasPlanRasters
686 ? []
687 : [
688 [GLYPHS_KEY, glyphsCellsOf(frames, context), 1, frames.length],
689 [METERS_KEY, metersCellsOf(frames, context, target.layout), target.layout.meter, frames.length],
690 ]
691
692 if (pause !== null && target.alertCells !== null) {
693 repaints.push([ALERT_WAVE_KEY, alertWaveCellsOf(pause, target.alertCells, now), target.alertCells, 1])
694 }
695
696 for (const [key, cells, columns, rows] of repaints) {
697 if (blitted.get(key) === cells) {
698 continue
699 }
700
701 const result = await $.ui.blit({ requestId: target.requestId, key, cells, columns, rows })
702
703 if (result.deny === undefined) {
704 blitted.set(key, cells)
705 lastLandedAt = now
706 }
707 }
708}
709
710/** Forgets fills and flashes that have run their course. */
711function settleAnimations(now: number): void {
712 for (const [id, fill] of fills) {
713 if (!isFilling(fill, now)) {
714 fills.delete(id)
715 }
716 }
717
718 for (const [id, startedAt] of flashes) {
719 if (now >= startedAt + FILL_MS * 2) {
720 flashes.delete(id)
721 }
722 }
723}
724
725/** What the band's guard controls do, bound to this `$`. */
726function guardViewOf($: EngineInterface, now: number, surface: string): GuardView {
727 return {
728 limits,
729 editing,
730 draft,
731 draftFrom: draftFrom !== null && now - draftMovedAt < NUDGE_MS ? draftFrom : null,
732 isOpening: now - editedAt < OPEN_MS,
733 justSet: confirmed !== null && now - confirmed.at < RETURN_MS ? confirmed.kind : null,
734 isDragReady: dragReady.has(surface),
735 onPick: limit => {
736 void pickLimit($, limit)
737 },
738 pause,
739 onEdit: kind => {
740 void openEditor($, kind)
741 },
742 onNudge: delta => {
743 void nudgeDraft($, delta)
744 },
745 onConfirm: () => {
746 void confirmLimit($)
747 },
748 onClear: () => {
749 draftFrom = null
750 draft = null
751 $.ui.invalidate('ui.render')
752 },
753 onSave: () => {
754 void saveAndWait($)
755 },
756 onResume: () => {
757 void resumeWork($, true)
758 $.ui.toast('Resumed past the limit')
759 },
760 }
761}
762
763/**
764 * Opens a window's limit picker in the quota row's place, on the limit set
765 * (or none); its dial opens out of the middle. The window's label pressed
766 * again while it is open leaves the limit as it was.
767 */
768async function openEditor($: EngineInterface, kind: string): Promise<void> {
769 const now = await $.clock.now()
770
771 if (editing === kind) {
772 editing = null
773 confirmed = { kind, at: now }
774 } else {
775 editing = kind
776 editedAt = now
777 confirmed = null
778 }
779
780 draft = limits[kind] ?? null
781 draftFrom = null
782 $.ui.invalidate('ui.render')
783 // Redraws once the animation is over, so later redraws draw it at rest.
784 $.clock.after(Math.max(OPEN_MS, RETURN_MS) + 50, () => {
785 $.ui.invalidate('ui.render')
786 })
787}
788
789/**
790 * A click on the dial's track: the bar moves there and slides over from
791 * where it stood; nothing is kept until the ✓.
792 */
793async function pickLimit($: EngineInterface, limit: number): Promise<void> {
794 const picked = nudgedLimit(limit, 0)
795
796 if (editing === null || picked === draft) {
797 return
798 }
799
800 draftFrom = draft
801 draft = picked
802 draftMovedAt = await $.clock.now()
803 $.ui.invalidate('ui.render')
804}
805
806/** The ✓: keeps the limit the dial shows (none, after Remove), closes the picker, and the quota row comes back lit. */
807async function confirmLimit($: EngineInterface): Promise<void> {
808 const kind = editing
809
810 if (kind === null) {
811 return
812 }
813
814 editing = null
815 confirmed = { kind, at: await $.clock.now() }
816 $.clock.after(RETURN_MS + 50, () => {
817 $.ui.invalidate('ui.render')
818 })
819
820 if (draft === (limits[kind] ?? null)) {
821 $.ui.invalidate('ui.render')
822
823 return
824 }
825
826 await setLimit($, kind, draft)
827}
828
829/** Moves the picker's knob a step, remembering where it slides from. */
830async function nudgeDraft($: EngineInterface, delta: number): Promise<void> {
831 const moved = nudgedLimit(draft ?? LIMIT_START, delta)
832
833 if (moved === draft) {
834 return
835 }
836
837 draftFrom = draft
838 draft = moved
839 draftMovedAt = await $.clock.now()
840 $.ui.invalidate('ui.render')
841}
842
843/** The store key of this folder's guard: its pause and the windows resumed past their limit. */
844async function guardKeyOf($: EngineInterface): Promise<string> {
845 return `guard:${await folderOf($)}`
846}
847
848/** The store key of this folder's view: whether its plans are folded. */
849async function viewKeyOf($: EngineInterface): Promise<string> {
850 return `view:${await folderOf($)}`
851}
852
853/** Folds the plans into one line, or opens them again; remembered for the folder, animated once. */
854async function toggleFold($: EngineInterface): Promise<void> {
855 isFolded = !isFolded
856 foldedAt = await $.clock.now()
857 $.ui.invalidate('ui.render')
858 // A redraw once the animation is over, so later redraws draw the band at rest.
859 $.clock.after(FOLD_MS + 50, () => {
860 $.ui.invalidate('ui.render')
861 })
862
863 try {
864 await $.store.set(await viewKeyOf($), { isFolded })
865 } catch {
866 // The fold holds for this session.
867 }
868}
869
870/** Reads the limits and this folder's pause and view from the store. */
871async function loadGuard($: EngineInterface): Promise<void> {
872 try {
873 const view = (await $.store.get(await viewKeyOf($))) as { isFolded?: unknown } | undefined
874
875 isFolded = view?.isFolded === true
876 } catch {
877 // An unreadable store: the plans show open.
878 }
879
880 try {
881 limits = limitsOf(await $.store.get(LIMITS_KEY))
882
883 const state = guardStateOf(await $.store.get(await guardKeyOf($)))
884
885 pause = state.pause
886 acknowledged = state.acknowledged
887 } catch {
888 // An unreadable store: no limit, no pause.
889 }
890}
891
892async function saveGuard($: EngineInterface): Promise<void> {
893 try {
894 await $.store.set(await guardKeyOf($), { pause, acknowledged })
895 } catch {
896 // The pause holds for this session; the next one starts without it.
897 }
898}
899
900/** Sets (or, with null, lifts) a window's limit, closes the picker, and checks the windows against it. */
901async function setLimit($: EngineInterface, kind: string, limit: number | null): Promise<void> {
902 const next: Limits = { ...limits }
903
904 if (limit === null) {
905 delete next[kind]
906 } else {
907 next[kind] = limit
908 }
909
910 limits = next
911 editing = null
912 draft = limit
913 delete acknowledged[kind]
914 $.ui.invalidate('ui.render')
915 $.ui.toast(limit === null ? 'Limit removed' : `Work pauses when ${kind === 'seven_day' ? '7d' : '5h'} has ${limit}% left`)
916
917 try {
918 await $.store.set(LIMITS_KEY, limits)
919 } catch {
920 // The limit holds for this session.
921 }
922
923 await saveGuard($)
924 await checkLimits($)
925}
926
927/** Pauses the work when a window has reached the limit the person set, unless it already is. */
928async function checkLimits($: EngineInterface): Promise<void> {
929 if (pause !== null) {
930 return
931 }
932
933 const breach = breachOf(quotas, limits, acknowledged)
934
935 if (breach === null) {
936 return
937 }
938
939 const turn = runningTurn
940
941 pause = {
942 kind: breach.kind,
943 label: breach.label,
944 limit: breach.limit,
945 remaining: breach.remaining,
946 since: await $.clock.now(),
947 hasInterrupted: turn !== null,
948 phase: 'alert',
949 ...(breach.resetsAt === undefined ? {} : { resetsAt: breach.resetsAt }),
950 }
951
952 if (turn !== null) {
953 $.turn.abort({ turnId: turn }).catch(() => {
954 // The turn had already ended; the tool guard holds whatever comes next.
955 })
956 }
957
958 playSound($, ALERT_SOUND)
959 $.ui.toast(`⏸ ${breach.label} limit reached: the work is paused`)
960 $.ui.invalidate('ui.render')
961 startLoop($)
962 await saveGuard($)
963}
964
965/** Save & wait: Claude writes a checkpoint without tools, and the work resumes once the window resets. */
966async function saveAndWait($: EngineInterface): Promise<void> {
967 if (pause === null || pause.phase === 'waiting') {
968 return
969 }
970
971 pause = { ...pause, phase: 'waiting' }
972 $.ui.invalidate('ui.render')
973 await saveGuard($)
974 await armResume($)
975 $.ui.toast(pause.resetsAt === undefined ? 'Saved: resume whenever you are ready' : 'Saved: Claude picks up on its own after the reset')
976 $.prompt.submit({ text: CHECKPOINT_PROMPT }).catch(() => {
977 // No checkpoint: the wait holds all the same.
978 })
979}
980
981/** Arms the wake-up of a saved pause: shortly after its window resets, or at once when that is past. */
982async function armResume($: EngineInterface): Promise<void> {
983 resumeTimer?.cancel()
984 resumeTimer = null
985
986 if (pause === null || pause.phase !== 'waiting' || pause.resetsAt === undefined) {
987 return
988 }
989
990 const delay = Math.max(0, pause.resetsAt + RESET_GRACE_MS - (await $.clock.now()))
991
992 resumeTimer = $.clock.after(delay, () => {
993 void resumeAfterReset($)
994 })
995}
996
997/** The window has reset after a save: lift the pause and ask Claude to pick up from its checkpoint. */
998async function resumeAfterReset($: EngineInterface): Promise<void> {
999 if (pause === null || pause.phase !== 'waiting') {
1000 return
1001 }
1002
1003 const label = pause.label
1004
1005 pause = null
1006 resumeTimer = null
1007 await saveGuard($)
1008 await refreshUsage($)
1009 playSound($, STEP_SOUND)
1010 $.ui.toast(`${label} window reset: resuming`)
1011 $.ui.invalidate('ui.render')
1012 $.prompt.submit({ text: RESET_PROMPT }).catch(() => {
1013 // The person resumes by hand.
1014 })
1015}
1016
1017/**
1018 * Lifts the pause: the window stays quiet until it resets, and with
1019 * `shouldContinue` Claude is asked to go on when a turn was stopped or a
1020 * checkpoint written.
1021 */
1022async function resumeWork($: EngineInterface, shouldContinue: boolean): Promise<void> {
1023 const lifted = pause
1024
1025 if (lifted === null) {
1026 return
1027 }
1028
1029 acknowledged = { ...acknowledged, [lifted.kind]: lifted.resetsAt ?? 0 }
1030 pause = null
1031 resumeTimer?.cancel()
1032 resumeTimer = null
1033 $.ui.invalidate('ui.render')
1034 await saveGuard($)
1035
1036 if (shouldContinue && (lifted.hasInterrupted || lifted.phase === 'waiting')) {
1037 $.prompt.submit({ text: RESUME_PROMPT }).catch(() => {
1038 // The person goes on by hand.
1039 })
1040 }
1041}
1042
1043/** Reads the store, and asks for the windows, unless this instance of the module already has. */
1044async function ensureLoaded($: EngineInterface): Promise<void> {
1045 if (!hasLoaded) {
1046 await loadPlans($)
1047 }
1048
1049 if (!hasUsage) {
1050 await refreshUsage($)
1051 }
1052}
1053
1054/** The windows the band draws: none while the `usage` option is off. */
1055function shownQuotas(): Quota[] {
1056 return isUsageOn ? quotas : []
1057}
1058
1059/** Asks the engine for the 5-hour and 7-day windows; a session that cannot say keeps what it had. */
1060async function refreshUsage($: EngineInterface): Promise<void> {
1061 hasUsage = true
1062
1063 try {
1064 const usage = await $.session.usage()
1065
1066 quotas = quotasOf(usage.rateLimits)
1067 await checkLimits($)
1068 } catch {
1069 // No reading: the row stays as it was, or absent.
1070 }
1071}
1072
1073/** Redraws once a minute while a window shows, so `resets in 2h 14m` stays true. */
1074function startMinuteTick($: EngineInterface): void {
1075 if (minuteTick !== null) {
1076 return
1077 }
1078
1079 minuteTick = $.clock.every(REFRESH_MS, () => {
1080 void refreshFromStore($)
1081 })
1082}
1083
1084/**
1085 * Re-reads this folder's plans: another conversation in the folder may have
1086 * started, moved, finished or removed one. A plan that moved fills to its
1087 * new share and one that finished flashes, as if reported here; no chime,
1088 * which plays only where the work is done. Redraws when anything changed,
1089 * and in any case while a window's countdown shows.
1090 */
1091async function refreshFromStore($: EngineInterface): Promise<void> {
1092 const before = plans
1093
1094 await loadPlans($)
1095
1096 const now = await $.clock.now()
1097 let hasChanged = before.size !== plans.size
1098
1099 for (const [id, plan] of plans) {
1100 const previous = before.get(id)
1101
1102 if (previous === undefined) {
1103 hasChanged = true
1104 continue
1105 }
1106
1107 if (previous.updatedAt === plan.updatedAt && previous.step === plan.step && previous.total === plan.total) {
1108 continue
1109 }
1110
1111 hasChanged = true
1112
1113 const from = previous.step / previous.total
1114 const to = plan.step / plan.total
1115
1116 if (from !== to && !removing.has(id)) {
1117 fills.set(id, { from, to, startedAt: now })
1118 }
1119
1120 if (isDone(plan) && !isDone(previous)) {
1121 flashes.set(id, now + FILL_MS * 0.7)
1122 }
1123 }
1124
1125 if (hasChanged) {
1126 startLoop($)
1127 }
1128
1129 if (hasChanged || shownQuotas().length > 0) {
1130 $.ui.invalidate('ui.render')
1131 }
1132}
1133
1134/** The session's project folder as the store names it, read afresh: a `/cd` moves it. */
1135async function folderOf($: EngineInterface): Promise<string> {
1136 try {
1137 folder = folderKeyOf(await $.session.root())
1138 } catch {
1139 folder ??= folderKeyOf('')
1140 }
1141
1142 return folder
1143}
1144
1145/**
1146 * Reads this folder's plans from the store, and the legacy ones of no
1147 * folder: a finished legacy plan is dropped, an unfinished one kept for
1148 * adoption. A store that cannot be read leaves the plans as they are.
1149 */
1150async function loadPlans($: EngineInterface): Promise<void> {
1151 let keys: string[]
1152
1153 try {
1154 keys = await $.store.keys()
1155 } catch {
1156 return
1157 }
1158
1159 const mine = await folderOf($)
1160 const now = await $.clock.now()
1161 const loaded = new Map<string, Plan>()
1162 const orphans = new Map<string, Plan>()
1163
1164 for (const key of keys) {
1165 const parsed = parseKey(key)
1166
1167 if (parsed === null || ('folder' in parsed && parsed.folder !== mine)) {
1168 continue
1169 }
1170
1171 const value = await $.store.get(key).catch(() => undefined)
1172
1173 if (!isPlan(value)) {
1174 continue
1175 }
1176
1177 if ('folder' in parsed && isWaiting(value) && now - value.createdAt > WAITING_STALE_MS) {
1178 // A waiting row its session never cleared (it crashed): nothing waits on it any more.
1179 await $.store.delete(key).catch(() => undefined)
1180 } else if ('folder' in parsed) {
1181 loaded.set(value.id, value)
1182 } else if (isDone(value)) {
1183 await $.store.delete(key).catch(() => undefined)
1184 } else {
1185 orphans.set(value.id, value)
1186 }
1187 }
1188
1189 plans = loaded
1190 legacy = orphans
1191 hasLoaded = true
1192}
1193
1194/**
1195 * The ✕: the row comes apart (its LEDs fly off, its chip bursts, a soft
1196 * dissolve sound plays), then the plan leaves the band and the store.
1197 */
1198async function dissolvePlan($: EngineInterface, id: string): Promise<void> {
1199 if (removing.has(id) || !plans.has(id)) {
1200 returnhooks/guard.ts 171 lines1import type { Quota } from './usage'
2
3/**
4 * The limits the person set, per window: pause the work once that window
5 * has this many percent left or fewer. A window with no entry has no limit.
6 */
7export type Limits = Partial<Record<string, number>>
8
9/** The windows a limit can be set on. */
10export const LIMIT_KINDS = ['five_hour', 'seven_day'] as const
11
12/** The picker's range and step, in percent left, and where it starts when the window has no limit yet. */
13export const LIMIT_MIN = 5
14export const LIMIT_MAX = 90
15export const LIMIT_STEP = 5
16export const LIMIT_START = 25
17
18/** A picker value moved by `delta` steps, kept in range and on the grid. */
19export function nudgedLimit(value: number, delta: number): number {
20 const stepped = Math.round(value / LIMIT_STEP) * LIMIT_STEP + delta * LIMIT_STEP
21
22 return Math.max(LIMIT_MIN, Math.min(LIMIT_MAX, stepped))
23}
24
25/**
26 * The work stopped at a limit: which window, at what level, and where the
27 * pause stands: `alert` waits for the person's choice, `waiting` waits for
28 * the window to reset after a save.
29 */
30export type Pause = {
31 kind: string
32 label: string
33 /** The limit that was reached, in percent left. */
34 limit: number
35 /** What was left of the window when the pause began. */
36 remaining: number
37 /** When the window resets, milliseconds since the epoch, when the engine said. */
38 resetsAt?: number
39 /** When the pause began. */
40 since: number
41 /** Whether a turn was running and got stopped, so resuming has work to pick up. */
42 hasInterrupted: boolean
43 phase: 'alert' | 'waiting'
44}
45
46/** What a folder keeps of the guard between sessions: the pause, and the windows resumed past their limit. */
47export type GuardState = {
48 pause: Pause | null
49 /** Per window, the reset time of the window the person chose to go on in. */
50 acknowledged: Record<string, number>
51}
52
53/** A limit as the person typed it: `20`, `20%`, ` 7 % ` → a whole percent from 1 to 99; anything else null. */
54export function parseLimit(text: string): number | null {
55 const match = /^\s*(\d{1,2}(?:[.,]\d+)?)\s*%?\s*$/.exec(text)
56
57 if (match === null) {
58 return null
59 }
60
61 const value = Math.round(Number((match[1] ?? '').replace(',', '.')))
62
63 return value >= 1 && value <= 99 ? value : null
64}
65
66/** The limits read back from the store: only the two windows, only whole percents from 1 to 99. */
67export function limitsOf(value: unknown): Limits {
68 const limits: Limits = {}
69
70 if (typeof value !== 'object' || value === null) {
71 return limits
72 }
73
74 for (const kind of LIMIT_KINDS) {
75 const limit = (value as Record<string, unknown>)[kind]
76
77 if (typeof limit === 'number' && Number.isInteger(limit) && limit >= 1 && limit <= 99) {
78 limits[kind] = limit
79 }
80 }
81
82 return limits
83}
84
85/** The guard state read back from the store; a missing or damaged one is no pause and nothing acknowledged. */
86export function guardStateOf(value: unknown): GuardState {
87 const state: GuardState = { pause: null, acknowledged: {} }
88
89 if (typeof value !== 'object' || value === null) {
90 return state
91 }
92
93 const record = value as Record<string, unknown>
94 const pause = record.pause as Record<string, unknown> | null | undefined
95
96 if (
97 pause !== null &&
98 typeof pause === 'object' &&
99 typeof pause.kind === 'string' &&
100 typeof pause.label === 'string' &&
101 typeof pause.limit === 'number' &&
102 typeof pause.remaining === 'number' &&
103 typeof pause.since === 'number' &&
104 (pause.phase === 'alert' || pause.phase === 'waiting')
105 ) {
106 state.pause = {
107 kind: pause.kind,
108 label: pause.label,
109 limit: pause.limit,
110 remaining: pause.remaining,
111 since: pause.since,
112 hasInterrupted: pause.hasInterrupted === true,
113 phase: pause.phase,
114 ...(typeof pause.resetsAt === 'number' ? { resetsAt: pause.resetsAt } : {}),
115 }
116 }
117
118 if (typeof record.acknowledged === 'object' && record.acknowledged !== null) {
119 for (const [kind, at] of Object.entries(record.acknowledged as Record<string, unknown>)) {
120 if (typeof at === 'number') {
121 state.acknowledged[kind] = at
122 }
123 }
124 }
125
126 return state
127}
128
129/**
130 * The first window at or past its limit that the person has not chosen to
131 * go on in: a window resumed past its limit stays quiet until it resets
132 * (its reset time changes), then its limit holds again.
133 */
134export function breachOf(quotas: readonly Quota[], limits: Limits, acknowledged: Record<string, number>): (Quota & { limit: number }) | null {
135 for (const quota of quotas) {
136 const limit = limits[quota.kind]
137
138 if (limit === undefined || quota.remaining > limit) {
139 continue
140 }
141
142 if (acknowledged[quota.kind] === (quota.resetsAt ?? 0)) {
143 continue
144 }
145
146 return { ...quota, limit }
147 }
148
149 return null
150}
151
152/** The note the model reads when a tool call meets the pause. */
153export function denialOf(pause: Pause): string {
154 return (
155 `Paused by the progress band: the ${pause.label} usage limit the user set (pause at ${pause.limit}% left) ` +
156 'was reached. Stop here and do not retry; the user will resume the work.'
157 )
158}
159
160/** What the model is asked when the person saves and waits: a checkpoint, no tools. */
161export const CHECKPOINT_PROMPT =
162 'The usage limit the user set was reached, so the work is paused until the window resets. ' +
163 'Do not call any tools. In three short lines, write down where the work stands and the very next step, ' +
164 'so it can resume cleanly after the reset.'
165
166/** What the model is asked when the person resumes a turn the pause stopped. */
167export const RESUME_PROMPT = 'The user resumed the work after the usage-limit pause. Continue exactly where you stopped.'
168
169/** What the model is asked when the window has reset after a save. */
170export const RESET_PROMPT = 'The usage window has reset. Resume the work from the checkpoint you wrote, starting with the next step.'
171hooks/meter.ts 385 lines1import { easeOutCubic, spinnerAt, twinkleTick } from './motion'
2import {
3 AMBER_BRIGHT,
4 AMBER_DEEP,
5 AMBER_DIM,
6 DEFAULT_COLOR,
7 EMERALD,
8 EMERALD_DEEP,
9 EMERALD_DIM,
10 INDIGO,
11 INDIGO_BRIGHT,
12 LAVENDER,
13 LAVENDER_DEEP,
14 LAVENDER_DIM,
15 mix,
16 MUTED,
17 noise,
18 DANGER,
19 RULE_FROM,
20 RULE_TO,
21 seedOf,
22 TRACK,
23 WHITE,
24 type Rgb,
25} from './palette'
26import { chipOf, isDone, type Plan } from './plans'
27import { BLANK, cellOf, textCells, type Cell } from './raster'
28
29/** One plan as one frame draws it: where its bar stands and whether it glows. */
30export type PlanFrame = {
31 plan: Plan
32 /** The share of the bar shown now, 0 to 1 (mid-animation, not the target). */
33 share: number
34 /** How far into its completion flash, 0 to 1, or null. */
35 flash: number | null
36 /** The plan Claude reported last among the unfinished ones: it spins and twinkles. */
37 isActive: boolean
38 /** How far the plan's row has come apart since its ✕ was pressed, 0 to 1, or null. */
39 dissolve?: number | null
40 /** The plan just took up a waiting message: its bar sweeps in once. */
41 isIgniting?: boolean
42 /** A waiting message's place in the queue, from 1; absent for a plan. */
43 queue?: number
44}
45
46/** What every row of a frame shares: the instant, whether Claude is working, whether a limit holds the work. */
47export type FrameContext = {
48 now: number
49 isWorking: boolean
50 isPaused?: boolean
51}
52
53/** How wide a meter's bar is, its rounded ends included. */
54export type MeterWidths = {
55 bar: number
56}
57
58const GAP = 2
59const PERCENT_CELLS = 4
60/** The braille cell with no dot: an empty pixel of the dither. */
61const NO_DOTS = 0x2800
62/** The eight dots of a braille cell, as bits of its code point. */
63const DOTS = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]
64
65/** Cells across one meter: the bar, a gap, the percent. */
66export function meterWidthOf(widths: MeterWidths): number {
67 return widths.bar + GAP + PERCENT_CELLS
68}
69
70/** The percent a share reads as: 100 only once it is whole, so a bar still filling never says 100%. */
71export function shownPercent(share: number): number {
72 if (share >= 1) {
73 return 100
74 }
75
76 return Math.max(0, Math.min(99, Math.round(share * 100)))
77}
78
79/**
80 * One meter: a rounded track; behind the head a dither of braille pixels,
81 * sparse at the start and dense at the head, that reshuffles while Claude
82 * works; the chip (`Stage 3/5`) riding the head; the percent.
83 */
84export function meterRow(frame: PlanFrame, context: FrameContext, widths: MeterWidths): Cell[] {
85 if (frame.dissolve !== undefined && frame.dissolve !== null) {
86 return dissolvingRow(frame, context, widths, frame.dissolve)
87 }
88
89 if (frame.queue !== undefined) {
90 return waitingRow(frame.queue, context, widths)
91 }
92
93 const { plan } = frame
94 const done = isDone(plan) && frame.share >= 0.999
95 const flash = frame.flash === null ? 0 : 1 - easeOutCubic(frame.flash)
96 const inner = Math.max(4, widths.bar - 2)
97 const paused = context.isPaused === true && !done
98 const chip = chipCellsOf(plan, done, flash, inner, paused)
99 const head = Math.round(Math.max(0, Math.min(1, frame.share)) * inner)
100 const chipEnd = Math.min(inner, Math.max(head, chip.length))
101 const chipStart = chipEnd - chip.length
102 const tick = twinkleTick(context.now, context.isWorking && frame.isActive && !done)
103 const seed = seedOf(plan.id)
104 const cells: Cell[] = [cellOf('▐', TRACK)]
105
106 for (let i = 0; i < inner; i += 1) {
107 if (i >= chipStart && i < chipEnd) {
108 cells.push(chip[i - chipStart] ?? BLANK)
109 } else if (i < chipStart) {
110 cells.push(ditherCell(i, chipStart, seed, tick, done, flash, paused))
111 } else {
112 cells.push(cellOf(' ', TRACK, TRACK))
113 }
114 }
115
116 cells.push(cellOf('▌', TRACK))
117
118 for (let i = 0; i < GAP; i += 1) {
119 cells.push(BLANK)
120 }
121
122 const percentColor = done ? mix(EMERALD, WHITE, flash) : MUTED
123
124 cells.push(...textCells(`${shownPercent(frame.share)}%`.padStart(PERCENT_CELLS), percentColor))
125
126 return cells
127}
128
129/**
130 * A row coming apart after its ✕: the cells nearest the chip go first,
131 * each flaring into a sparkle (`✦`, then `·`) before it goes out; the chip
132 * flashes white and crumbles into sparkles; the track, the percent and the
133 * caps fade away last.
134 */
135/** How long the waiting scanner takes to cross the track and come back. */
136const SCAN_MS = 5200
137
138/**
139 * A waiting message's row on the terminal: an indigo chip `⧗ Waiting #1` at
140 * the start, a dotted track, and a soft glow gliding back and forth along
141 * it; no percent, as nothing has started.
142 */
143function waitingRow(position: number, context: FrameContext, widths: MeterWidths): Cell[] {
144 const inner = Math.max(4, widths.bar - 2)
145 const ground: Rgb = mix(INDIGO, TRACK, 0.55)
146 const text = [...`⧗ Waiting #${position}`].slice(0, Math.max(1, inner - 2))
147 const chip = [cellOf('▐', ground, TRACK), ...text.map(glyph => cellOf(glyph, INDIGO_BRIGHT, ground)), cellOf('▌', ground, TRACK)]
148 const span = Math.max(1, inner - chip.length - 1)
149 const phase = (context.now % SCAN_MS) / SCAN_MS
150 const glide = (1 - Math.cos(phase * 2 * Math.PI)) / 2
151 const centre = chip.length + glide * span
152 const cells: Cell[] = [cellOf('▐', TRACK), ...chip]
153
154 for (let i = chip.length; i < inner; i += 1) {
155 const light = Math.max(0, 1 - Math.abs(i - centre) / 4)
156
157 cells.push(cellOf(light > 0.6 ? '⠶' : light > 0.15 ? '⠤' : '⠂', mix(mix(TRACK, INDIGO, 0.35), INDIGO_BRIGHT, light), TRACK))
158 }
159
160 cells.push(cellOf('▌', TRACK))
161
162 for (let i = 0; i < GAP + PERCENT_CELLS; i += 1) {
163 cells.push(BLANK)
164 }
165
166 return cells
167}
168
169function dissolvingRow(frame: PlanFrame, context: FrameContext, widths: MeterWidths, progress: number): Cell[] {
170 const done = isDone(frame.plan)
171 const living = meterRow({ ...frame, dissolve: null }, context, widths)
172 const inner = Math.max(4, widths.bar - 2)
173 const seed = seedOf(frame.plan.id)
174 const [, bright] = done ? [EMERALD_DIM, EMERALD] : [LAVENDER_DIM, LAVENDER]
175 const p = Math.max(0, Math.min(1, progress))
176 const trackGone = p > 0.82
177
178 return living.map((cell, index) => {
179 if (index === 0 || index === inner + 1) {
180 return trackGone ? BLANK : cell
181 }
182
183 if (index > inner + 1) {
184 return p > 0.45 ? BLANK : cell
185 }
186
187 const i = index - 1
188 const isChip = cell.bg !== TRACK && cell.bg !== DEFAULT_COLOR
189 const order = isChip ? 0.05 + 0.1 * noise(seed, i, 12) : 0.12 + 0.5 * (1 - i / inner) + 0.18 * noise(seed, i, 11)
190 const ground = trackGone ? DEFAULT_COLOR : TRACK
191
192 if (p < order) {
193 return isChip && p > 0.02 ? cellOf(cell.glyph, cell.fg, mix(cell.bg, WHITE, Math.min(1, p / 0.08) * 0.7)) : cell
194 }
195
196 const since = p - order
197
198 if (since < 0.1) {
199 return cellOf('✦', mix(WHITE, bright, since / 0.1), ground)
200 }
201
202 if (since < 0.22) {
203 return cellOf('·', mix(bright, TRACK, (since - 0.1) / 0.12), ground)
204 }
205
206 return trackGone ? BLANK : cellOf(' ', TRACK, TRACK)
207 })
208}
209
210/** One pixel of the dither: dots lit with a chance that grows toward the head. */
211function ditherCell(i: number, span: number, seed: number, tick: number, done: boolean, flash: number, paused: boolean): Cell {
212 const t = span <= 1 ? 1 : (i + 0.5) / span
213 const density = done ? 0.35 + 0.45 * t : 0.06 + 0.74 * t ** 1.6
214 let code = NO_DOTS
215
216 DOTS.forEach((bit, dot) => {
217 if (noise(seed + tick * 7919, i, dot) < density) {
218 code |= bit
219 }
220 })
221
222 const [dim, bright] = done ? [EMERALD_DIM, EMERALD] : paused ? [AMBER_DIM, AMBER_BRIGHT] : [LAVENDER_DIM, LAVENDER]
223 const fg = mix(mix(dim, bright, t ** 1.2), WHITE, flash * 0.8)
224
225 return cellOf(String.fromCodePoint(code), fg, TRACK)
226}
227
228/**
229 * The chip's cells: half-block caps round it, the label in white and the
230 * count a touch softer on lavender (emerald once done); the label is cut,
231 * then dropped, when the bar is too short to hold it.
232 */
233function chipCellsOf(plan: Plan, done: boolean, flash: number, inner: number, paused: boolean): Cell[] {
234 const chip = chipOf(plan)
235 const label = paused ? 'Paused' : chip.label
236 const { count } = chip
237 const ground: Rgb = mix(done ? EMERALD_DEEP : paused ? AMBER_DEEP : LAVENDER_DEEP, WHITE, flash * 0.6)
238 const room = inner - 2
239 let text = `${label} ${count}`
240
241 if ([...text].length > room) {
242 const cut = room - count.length - 2
243 text = cut >= 3 ? `${[...label].slice(0, cut).join('')}… ${count}` : count
244 }
245
246 const labelLength = [...text].length - count.length
247 const body = [...text].map((glyph, index) => cellOf(glyph, index < labelLength ? WHITE : mix(WHITE, ground, 0.25), ground))
248
249 return [cellOf('▐', ground, TRACK), ...body, cellOf('▌', ground, TRACK)]
250}
251
252/** A finished plan's check; the active plan's spinner while Claude works; a dot otherwise; a red ✕ fading as its row goes. */
253export function glyphCell(frame: PlanFrame, context: FrameContext): Cell {
254 if (frame.dissolve !== undefined && frame.dissolve !== null) {
255 return frame.dissolve > 0.6 ? BLANK : cellOf('✕', mix(DANGER, TRACK, frame.dissolve / 0.6))
256 }
257
258 if (frame.queue !== undefined) {
259 return cellOf('◌', mix(INDIGO, TRACK, 0.3 + 0.3 * Math.sin(context.now / 500)))
260 }
261
262 if (isDone(frame.plan)) {
263 const color = frame.flash === null ? EMERALD : mix(WHITE, EMERALD, easeOutCubic(frame.flash))
264
265 return cellOf('✓', color)
266 }
267
268 if (context.isWorking && frame.isActive) {
269 return cellOf(spinnerAt(context.now), LAVENDER)
270 }
271
272 return cellOf('•', LAVENDER)
273}
274
275/** The header's rule: `─` fading from the accent into the background. */
276export function ruleCells(width: number): Cell[] {
277 const cells: Cell[] = []
278
279 for (let i = 0; i < width; i += 1) {
280 const t = width <= 1 ? 0 : i / (width - 1)
281
282 cells.push(cellOf('─', mix(RULE_FROM, RULE_TO, Math.sqrt(t)), DEFAULT_COLOR))
283 }
284
285 return cells
286}
287
288/**
289 * A quota's meter on the terminal: a short rounded track whose braille
290 * pixels fill what is left of the window, in the quota's color.
291 */
292export function quotaCells(remaining: number, color: Rgb, width: number, seed: number): Cell[] {
293 const inner = Math.max(2, width - 2)
294 const lit = Math.round((Math.max(0, Math.min(100, remaining)) / 100) * inner)
295 const cells: Cell[] = [cellOf('▐', TRACK)]
296
297 for (let i = 0; i < inner; i += 1) {
298 if (i >= lit) {
299 cells.push(cellOf(' ', TRACK, TRACK))
300 continue
301 }
302
303 let code = NO_DOTS
304
305 DOTS.forEach((bit, dot) => {
306 if (noise(seed, i, dot) < 0.72) {
307 code |= bit
308 }
309 })
310
311 cells.push(cellOf(String.fromCodePoint(code), mix(color, WHITE, 0.12), TRACK))
312 }
313
314 cells.push(cellOf('▌', TRACK))
315
316 return cells
317}
318
319/**
320 * The terminal alert's wave: braille LEDs in the alert's color, whose dots
321 * gather under a crest that travels left to right without end.
322 */
323export function alertWaveCells(width: number, now: number, color: Rgb, dim: Rgb): Cell[] {
324 const cells: Cell[] = []
325 const period = 2200
326 const crest = ((now % period) / period) * (width + 16) - 8
327
328 for (let i = 0; i < width; i += 1) {
329 const near = Math.exp(-((i - crest) ** 2) / 18)
330 const density = 0.12 + 0.8 * near
331 let code = NO_DOTS
332
333 DOTS.forEach((bit, dot) => {
334 if (noise(41 + Math.floor(now / 160), i, dot) < density) {
335 code |= bit
336 }
337 })
338
339 cells.push(cellOf(String.fromCodePoint(code), mix(dim, color, 0.35 + 0.65 * near)))
340 }
341
342 return cells
343}
344
345/**
346 * The terminal limit dial: the window's braille LEDs in its color, the zone
347 * where the work would pause shaded amber, and an amber knob at the limit.
348 */
349export function limitDialCells(remaining: number, limit: number, color: Rgb, width: number, kind: string): Cell[] {
350 const inner = Math.max(4, width - 2)
351 const lit = Math.round((Math.max(0, Math.min(100, remaining)) / 100) * inner)
352 const knob = Math.min(inner - 1, Math.max(0, Math.round((limit / 100) * inner)))
353 const seed = seedOf(`dial:${kind}`)
354 const cells: Cell[] = [cellOf('▐', TRACK)]
355
356 for (let i = 0; i < inner; i += 1) {
357 if (i === knob) {
358 cells.push(cellOf('◆', AMBER_BRIGHT, TRACK))
359 continue
360 }
361
362 const ground = i < knob ? mix(TRACK, AMBER_DIM, 0.55) : TRACK
363
364 if (i >= lit) {
365 cells.push(cellOf(i < knob ? '╱' : ' ', mix(AMBER_DIM, AMBER_BRIGHT, 0.25), ground))
366 continue
367 }
368
369 let code = NO_DOTS
370
371 DOTS.forEach((bit, dot) => {
372 if (noise(seed, i, dot) < 0.7) {
373 code |= bit
374 }
375 })
376
377 cells.push(cellOf(String.fromCodePoint(code), i < knob ? AMBER_BRIGHT : mix(color, WHITE, 0.12), ground))
378 }
379
380 cells.push(cellOf('▌', TRACK))
381
382 return cells
383}
384
385hooks/usage.ts 108 lines1import type { SessionRateLimit } from 'claude-code'
2
3import { DANGER, type Rgb } from './palette'
4
5/** One plan-limit window as the band shows it: what is left of it and when it starts over. */
6export type Quota = {
7 /** `five_hour` or `seven_day`, as the engine names the window. */
8 kind: string
9 /** `5h`, `7d`. */
10 label: string
11 /** The share of the window left, 0 to 100. */
12 remaining: number
13 /** When the window resets, milliseconds since the epoch; absent when the engine did not say. */
14 resetsAt?: number
15}
16
17/** The windows the band shows, in its order, with their labels. */
18const WINDOWS: readonly (readonly [string, string])[] = [
19 ['five_hour', '5h'],
20 ['seven_day', '7d'],
21]
22
23/** Plenty left. */
24export const QUOTA_OK: Rgb = 0x8ab4ff
25/** Under a quarter left. */
26export const QUOTA_LOW: Rgb = 0xfbbf24
27/** Under a tenth left. */
28export const QUOTA_CRITICAL: Rgb = DANGER
29
30/** The 5-hour and 7-day windows among the engine's rate limits, each as a quota; the others left out. */
31export function quotasOf(limits: readonly SessionRateLimit[]): Quota[] {
32 const quotas: Quota[] = []
33
34 for (const [kind, label] of WINDOWS) {
35 const limit = limits.find(each => each.kind === kind)
36
37 if (limit === undefined || !Number.isFinite(limit.percentUsed)) {
38 continue
39 }
40
41 const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
42 const quota: Quota = { kind, label, remaining: Math.max(0, Math.min(100, 100 - limit.percentUsed)) }
43
44 if (Number.isFinite(resetsAt)) {
45 quota.resetsAt = resetsAt
46 }
47
48 quotas.push(quota)
49 }
50
51 return quotas
52}
53
54/** The quota's color: blue with plenty left, amber under a quarter, red under a tenth. */
55export function quotaColorOf(remaining: number): Rgb {
56 if (remaining < 10) {
57 return QUOTA_CRITICAL
58 }
59
60 return remaining < 25 ? QUOTA_LOW : QUOTA_OK
61}
62
63/** `62% left`, whole percents, `<1% left` for a sliver. */
64export function remainingTextOf(remaining: number): string {
65 if (remaining > 0 && remaining < 1) {
66 return '<1% left'
67 }
68
69 return `${Math.round(remaining)}% left`
70}
71
72/** `resets in 2h 14m`, `in 4d 6h`, `in 9m`; empty when the reset time is unknown. */
73export function resetTextOf(quota: Quota, now: number): string {
74 if (quota.resetsAt === undefined) {
75 return ''
76 }
77
78 const minutes = Math.max(0, Math.round((quota.resetsAt - now) / 60_000))
79
80 if (minutes < 1) {
81 return 'resets now'
82 }
83
84 if (minutes < 60) {
85 return `resets in ${minutes}m`
86 }
87
88 const hours = Math.floor(minutes / 60)
89
90 if (hours < 24) {
91 return `resets in ${hours}h ${String(minutes % 60).padStart(2, '0')}m`
92 }
93
94 return `resets in ${Math.floor(hours / 24)}d ${hours % 24}h`
95}
96
97/** The quota row's short form of the reset: `↻ 2h 14m`, `↻ 4d 6h`; empty when unknown. */
98export function resetShortOf(quota: Quota, now: number): string {
99 const text = resetTextOf(quota, now)
100
101 return text === '' ? '' : text === 'resets now' ? '↻ now' : text.replace(/^resets in /, '↻ ')
102}
103
104/** The quota row's short form of what is left: `62%`, `<1%`. */
105export function remainingShortOf(remaining: number): string {
106 return remainingTextOf(remaining).replace(/ left$/, '')
107}
108hooks/motion.ts 85 lines1/**
2 * Time in the band: how long a fill and a completion flash take, where a
3 * bar stands mid-animation, and what the spinner and the shimmer show at a
4 * given instant. All pure: the clock's reading comes in as `now`.
5 */
6
7/** How long a bar takes to move to its new fill. */
8export const FILL_MS = 900
9/** How long a finished plan's bar glows white before it settles to emerald. */
10export const FLASH_MS = 1100
11/** One sweep of the shimmer across the filled part. */
12export const SHIMMER_MS = 1700
13/** The frame loop's period: 20 frames a second. */
14export const FRAME_MS = 50
15/** How often the dither's pixels reshuffle while Claude works on the plan, and at rest. */
16export const TWINKLE_MS = 140
17export const TWINKLE_IDLE_MS = 420
18
19/** A fill moving from one share to another, started at `startedAt`. */
20export type Fill = {
21 from: number
22 to: number
23 startedAt: number
24}
25
26const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
27const SPINNER_MS = 80
28
29/** Fast at first, settling at the end. */
30export function easeOutCubic(t: number): number {
31 const k = Math.max(0, Math.min(1, t))
32
33 return 1 - (1 - k) ** 3
34}
35
36/** The share of the bar shown at `now`, `fill`'s target once it is over. */
37export function shownShare(fill: Fill, now: number): number {
38 const t = (now - fill.startedAt) / FILL_MS
39
40 return fill.from + (fill.to - fill.from) * easeOutCubic(t)
41}
42
43export function isFilling(fill: Fill | undefined, now: number): boolean {
44 return fill !== undefined && now - fill.startedAt < FILL_MS
45}
46
47/** How far into its flash a plan is, 0 to 1, or null when it has none running. */
48export function flashProgress(startedAt: number | undefined, now: number): number | null {
49 if (startedAt === undefined) {
50 return null
51 }
52
53 const t = (now - startedAt) / FLASH_MS
54
55 return t >= 0 && t < 1 ? t : null
56}
57
58/** The spinner's glyph at `now`. */
59export function spinnerAt(now: number): string {
60 return SPINNER[Math.floor(now / SPINNER_MS) % SPINNER.length] ?? '⣾'
61}
62
63/**
64 * Where the shimmer's crest is at `now`, in cells, over a filled part
65 * `filled` cells long: it enters from the left and leaves past the head.
66 */
67export function shimmerCrest(now: number, filled: number): number {
68 const lead = 6
69 const t = (now % SHIMMER_MS) / SHIMMER_MS
70
71 return -lead + t * (filled + lead * 2)
72}
73
74/** How bright the shimmer makes a cell `distance` cells from its crest, 0 to 1. */
75export function shimmerGlow(distance: number): number {
76 const spread = 2.4
77
78 return Math.exp(-(distance * distance) / (2 * spread * spread))
79}
80
81/** The dither's frame at `now`: it never stops, and runs faster while Claude works on the plan. */
82export function twinkleTick(now: number, isLive: boolean): number {
83 return Math.floor(now / (isLive ? TWINKLE_MS : TWINKLE_IDLE_MS))
84}
85hooks/plans.ts 307 lines1/**
2 * The plans the band draws: one per name, each a count of steps done over a
3 * total, kept as plain JSON so `$.store` holds them as they are.
4 */
5export type Plan = {
6 /** The store's name for the plan: `idOf(name)`. */
7 id: string
8 /** The row's label, as the model reported it. */
9 name: string
10 /** Steps completed so far; never more than `total`. */
11 step: number
12 /** Steps in all; at least 1. */
13 total: number
14 /** What the current step is, when the model said; null when it did not. */
15 note: string | null
16 /** When the plan was first reported, milliseconds since the epoch. */
17 createdAt: number
18 /** When the plan was last reported. */
19 updatedAt: number
20 /**
21 * A message the person sent while Claude was busy, waiting its turn: a row
22 * of its own until Claude takes it up (a report with `queued`) or the turn
23 * ends; `owner` is the session it was sent in, which clears it.
24 */
25 waiting?: { owner: string }
26}
27
28/** What one `report_progress` call asks, once its input is read. */
29export type Report = {
30 name: string
31 step?: number
32 total?: number
33 note?: string
34 isDone: boolean
35 isRemoved: boolean
36 /** The plan takes up the oldest message waiting its turn: that row turns into it. */
37 isQueued: boolean
38}
39
40/** Why a `report_progress` input was refused. */
41export type ReportError = {
42 error: string
43}
44
45/**
46 * Every plan is stored under its own key, `plan:<folder>:<id>`: the store is
47 * shared by every session on the machine, so the folder keeps one project's
48 * plans out of another's band, and the id keeps two plans apart. A key of
49 * the first release, `plan:<id>`, belongs to no folder (a legacy key).
50 */
51export const STORE_PREFIX = 'plan:'
52
53const NAME_MAX = 80
54const NOTE_MAX = 120
55const ID_MAX = 48
56
57/**
58 * The store's name for a plan: the name without its accents, lowercased,
59 * runs of anything but a letter or digit folded to one dash, at most ID_MAX
60 * characters.
61 */
62export function idOf(name: string): string {
63 const slug = name
64 .normalize('NFKD')
65 .replace(/\p{M}/gu, '')
66 .toLowerCase()
67 .replace(/[^a-z0-9]+/g, '-')
68 .replace(/^-+|-+$/g, '')
69 .slice(0, ID_MAX)
70 .replace(/-+$/g, '')
71
72 return slug === '' ? 'plan' : slug
73}
74
75/** The store key of plan `id` in `folder` (a `folderKeyOf`). */
76export function keyOf(folder: string, id: string): string {
77 return `${STORE_PREFIX}${folder}:${id}`
78}
79
80/** The key a plan had before plans were kept per folder. */
81export function legacyKeyOf(id: string): string {
82 return STORE_PREFIX + id
83}
84
85/** What a store key holds: one folder's plan, a legacy plan of no folder, or something else. */
86export function parseKey(key: string): { folder: string; id: string } | { legacy: string } | null {
87 if (!key.startsWith(STORE_PREFIX)) {
88 return null
89 }
90
91 const rest = key.slice(STORE_PREFIX.length)
92 const colon = rest.lastIndexOf(':')
93
94 return colon === -1 ? { legacy: rest } : { folder: rest.slice(0, colon), id: rest.slice(colon + 1) }
95}
96
97/**
98 * A folder's name in the store: its last segment as a slug (readable when
99 * one looks at the file) and a hash of the whole path (two `app` folders
100 * stay apart), as `claudemod-3f9a1c2e`.
101 */
102export function folderKeyOf(path: string): string {
103 const trimmed = path.replace(/[\\/]+$/, '')
104 const base = trimmed.split(/[\\/]/).pop() ?? ''
105 let h = 0x811c9dc5
106
107 for (const char of trimmed) {
108 h = Math.imul(h ^ (char.codePointAt(0) ?? 0), 0x01000193)
109 }
110
111 const slug = idOf(base).slice(0, 24)
112
113 return `${slug}-${(h >>> 0).toString(16).padStart(8, '0')}`
114}
115
116/** True when `value` read back from the store is a plan as `Plan` spells it. */
117export function isPlan(value: unknown): value is Plan {
118 if (typeof value !== 'object' || value === null) {
119 return false
120 }
121
122 const plan = value as Record<string, unknown>
123
124 return (
125 typeof plan.id === 'string' &&
126 typeof plan.name === 'string' &&
127 isCount(plan.step) &&
128 isCount(plan.total) &&
129 plan.total >= 1 &&
130 (plan.note === null || typeof plan.note === 'string') &&
131 typeof plan.createdAt === 'number' &&
132 typeof plan.updatedAt === 'number' &&
133 (plan.waiting === undefined || (typeof plan.waiting === 'object' && plan.waiting !== null && typeof (plan.waiting as { owner?: unknown }).owner === 'string'))
134 )
135}
136
137/** True for a message waiting its turn rather than a plan. */
138export function isWaiting(plan: Pick<Plan, 'waiting'>): boolean {
139 return plan.waiting !== undefined
140}
141
142/** How long a waiting row outlives a session that never cleared it (a crash): then it is dropped on load. */
143export const WAITING_STALE_MS = 6 * 3_600_000
144
145const EXCERPT_MAX = 56
146
147/** A waiting row's name: the message's first line, its spaces folded, cut at EXCERPT_MAX with an ellipsis. */
148export function excerptOf(text: string): string {
149 const line = text.split('\n').map(each => each.trim()).find(each => each !== '') ?? ''
150 const folded = [...line.replace(/\s+/g, ' ')]
151
152 return folded.length > EXCERPT_MAX ? `${folded.slice(0, EXCERPT_MAX - 1).join('').trimEnd()}…` : folded.join('')
153}
154
155function isCount(value: unknown): value is number {
156 return typeof value === 'number' && Number.isInteger(value) && value >= 0
157}
158
159/** The plan's completion, 0 to 100, rounded. */
160export function percentOf(plan: Pick<Plan, 'step' | 'total'>): number {
161 if (plan.total <= 0) {
162 return 0
163 }
164
165 if (plan.step >= plan.total) {
166 return 100
167 }
168
169 return Math.max(0, Math.min(99, Math.round((plan.step / plan.total) * 100)))
170}
171
172export function isDone(plan: Pick<Plan, 'step' | 'total'>): boolean {
173 return plan.step >= plan.total
174}
175
176/**
177 * Reads a `report_progress` call's arguments: the name is required and
178 * bounded, `step` and `total` are integers when given, `done` and `remove`
179 * are booleans when given.
180 */
181export function reportOf(input: Record<string, unknown>): Report | ReportError {
182 const rawName = input.plan
183
184 if (typeof rawName !== 'string' || rawName.trim() === '') {
185 return { error: '"plan" is required: the plan\'s name, a non-empty string' }
186 }
187
188 const name = rawName.trim().replace(/\s+/g, ' ').slice(0, NAME_MAX)
189 const report: Report = {
190 name,
191 isDone: input.done === true,
192 isRemoved: input.remove === true,
193 isQueued: input.queued === true,
194 }
195
196 if (input.step !== undefined) {
197 if (!isCount(input.step)) {
198 return { error: '"step" must be an integer of 0 or more' }
199 }
200
201 report.step = input.step
202 }
203
204 if (input.total !== undefined) {
205 if (!isCount(input.total) || input.total < 1) {
206 return { error: '"total" must be an integer of 1 or more' }
207 }
208
209 report.total = input.total
210 }
211
212 if (input.note !== undefined && input.note !== null) {
213 if (typeof input.note !== 'string') {
214 return { error: '"note" must be a string' }
215 }
216
217 const note = input.note.trim().replace(/\s+/g, ' ').slice(0, NOTE_MAX)
218
219 if (note !== '') {
220 report.note = note
221 }
222 }
223
224 if (input.done !== undefined && typeof input.done !== 'boolean') {
225 return { error: '"done" must be true or false' }
226 }
227
228 if (input.remove !== undefined && typeof input.remove !== 'boolean') {
229 return { error: '"remove" must be true or false' }
230 }
231
232 if (input.queued !== undefined && typeof input.queued !== 'boolean') {
233 return { error: '"queued" must be true or false' }
234 }
235
236 return report
237}
238
239/**
240 * The plan after a report: a new one needs `total`; an existing one keeps
241 * what the report leaves out; `done` sets the step to the total; a step over
242 * the total is clamped; a note of `""` clears the one kept.
243 */
244export function applied(
245 previous: Plan | undefined,
246 report: Report,
247 now: number,
248): Plan | ReportError {
249 const total = report.total ?? previous?.total
250
251 if (total === undefined) {
252 return {
253 error: `"total" is required the first time a plan is reported ("${report.name}" is new)`,
254 }
255 }
256
257 const reportedStep = report.step ?? previous?.step ?? 0
258 const step = report.isDone ? total : Math.min(reportedStep, total)
259 const note = report.isDone ? null : (report.note ?? previous?.note ?? null)
260
261 return {
262 id: previous?.id ?? idOf(report.name),
263 name: report.name,
264 step,
265 total,
266 note,
267 createdAt: previous?.createdAt ?? now,
268 updatedAt: now,
269 }
270}
271
272/** The plans in the order they were first reported. */
273export function sortedPlans(plans: Iterable<Plan>): Plan[] {
274 return [...plans].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
275}
276
277/** One line for a plan: `name: 3/5 (60%)`, `done` when finished, the note after; `name: waiting` for a waiting message. */
278export function summaryOf(plan: Plan): string {
279 if (isWaiting(plan)) {
280 return `${plan.name}: waiting`
281 }
282
283 const state = isDone(plan) ? 'done' : `${percentOf(plan)}%`
284 const note = plan.note === null ? '' : ` — ${plan.note}`
285
286 return `${plan.name}: ${plan.step}/${plan.total} (${state})${note}`
287}
288
289/** How many characters of the note the bar's chip shows. */
290export const CHIP_LABEL_MAX = 18
291
292/**
293 * What the chip riding the bar's head says: the current stage (the note,
294 * cut to CHIP_LABEL_MAX) and the step under way, `Done` once finished.
295 * At 2 of 5 done the step under way is the 3rd, so the chip reads `3/5`.
296 */
297export function chipOf(plan: Pick<Plan, 'step' | 'total' | 'note'>): { label: string; count: string } {
298 if (isDone(plan)) {
299 return { label: 'Done', count: `${plan.total}/${plan.total}` }
300 }
301
302 const note = plan.note ?? 'Step'
303 const label = [...note].length > CHIP_LABEL_MAX ? `${[...note].slice(0, CHIP_LABEL_MAX - 1).join('').trimEnd()}…` : note
304
305 return { label, count: `${Math.min(plan.step + 1, plan.total)}/${plan.total}` }
306}
307hooks/views/band.tsx 721 lines1import type {
2 BoxProps,
3 ButtonProps,
4 ClientProps,
5 ElementConstructor,
6 InputProps,
7 RasterProps,
8 RenderElement,
9 SvgProps,
10 TextProps,
11} from 'claude-code'
12
13import {
14 glyphCell,
15 meterRow,
16 meterWidthOf,
17 quotaCells,
18 ruleCells,
19 shownPercent,
20 type FrameContext,
21 type MeterWidths,
22 type PlanFrame,
23} from '../meter'
24import { AMBER, DANGER, EMERALD, hexOf, INDIGO, LAVENDER, MUTED, seedOf, WHITE } from '../palette'
25import { chipOf, isDone, isWaiting } from '../plans'
26import { encodeRows } from '../raster'
27import { quotaColorOf, remainingShortOf, remainingTextOf, resetShortOf, resetTextOf, type Quota } from '../usage'
28import {
29 desktopAlertCard,
30 desktopLimitPicker,
31 capsuleButtonOf,
32 limitFlagOf,
33 limitLabelButton,
34 terminalAlertCard,
35 terminalLimitPicker,
36 type GuardView,
37} from './guard'
38import { burstLayerOf, chipCentreOf, chipGeometryOf, quotaBarOf, ringsOf, RINGS_PX, svgBarOf, waitingBarOf } from './svg-bar'
39
40type Box = ElementConstructor<BoxProps>
41type Text = ElementConstructor<TextProps>
42type Button = ElementConstructor<ButtonProps>
43
44/** The terminal's elements: the shared three and the cell grid. */
45export type TerminalKit = {
46 Box: Box
47 Text: Text
48 Button: Button
49 Input: ElementConstructor<InputProps>
50 Raster: ElementConstructor<RasterProps>
51 Client: ElementConstructor<ClientProps>
52}
53/** The desktop's elements: the shared three and the vector drawing. */
54export type DesktopKit = {
55 Box: Box
56 Text: Text
57 Button: Button
58 Input: ElementConstructor<InputProps>
59 Svg: ElementConstructor<SvgProps>
60 Client: ElementConstructor<ClientProps>
61}
62/** Any other surface's: the shared three. */
63export type PlainKit = { Box: Box; Text: Text; Button: Button }
64
65/** A move under way on the desktop: where the head starts and how long it has left. */
66export type Move = { from: number; durationMs: number }
67
68/** What the band draws: the plans as this instant shows them. */
69export type BandModel = {
70 frames: readonly PlanFrame[]
71 context: FrameContext
72 /** Cells across the band (`e.props.bodyColumns`). */
73 columns: number
74 onRemove: (id: string) => void
75 /** What the mods beneath drew in the band, kept under the rows. */
76 theirs: RenderElement | null
77 /** For the desktop: each moving plan's head, from where and for how long. */
78 moves?: ReadonlyMap<string, Move>
79 /** The 5-hour and 7-day windows, drawn under the plans; empty to draw none. */
80 quotas: readonly Quota[]
81 /** The usage guard: the limits, their editor, the pause and its alert. */
82 guard: GuardView
83 /** Whether the plans are folded into one line, whether the fold just changed (its animation plays), and the toggle. */
84 fold: { isFolded: boolean; isAnimating: boolean; onToggle: () => void }
85}
86
87/** How the terminal band shares its width. */
88export type TerminalLayout = MeterWidths & {
89 name: number
90 meter: number
91}
92
93/** The band's keys: what `$.ui.blit` repaints between renders. */
94export const GLYPHS_KEY = 'glyphs'
95export const METERS_KEY = 'meters'
96
97const TITLE = 'Progress'
98const PADDING = 1
99/** The band's own `[-]` collapse mark, drawn by Claude Code at the header's right end. */
100const COLLAPSE_MARK = 4
101/** CSS pixels per cell on the desktop: an under-estimate, so a row never overflows. */
102const DESKTOP_CELL_PX = 7.2
103/** The desktop bar's thickness, in CSS pixels, and the air above and below it. */
104const DESKTOP_BAR_PX = 26
105const DESKTOP_BAR_GAP_PX = 5
106
107/** The header's right side: how many plans run and how many are done. */
108export function summaryOf(frames: readonly PlanFrame[]): string {
109 const waiting = frames.filter(frame => isWaiting(frame.plan)).length
110 const done = frames.filter(frame => isDone(frame.plan)).length
111 const running = frames.length - done - waiting
112 const queued = waiting === 0 ? '' : ` · ${waiting} waiting`
113
114 if (running === 0 && done > 0) {
115 return (done === 1 ? 'done ✓' : `all ${done} done ✓`) + queued
116 }
117
118 if (running === 0) {
119 return `${waiting} waiting`
120 }
121
122 return (done === 0 ? `${running} running` : `${running} running · ${done} done`) + queued
123}
124
125/** The name column: the longest name, within limits set by the band's width. */
126function nameWidthOf(columns: number, frames: readonly PlanFrame[]): number {
127 const inner = Math.max(20, columns - PADDING * 2)
128 const longest = frames.reduce((most, frame) => Math.max(most, [...frame.plan.name].length), 0)
129
130 return clamp(longest, 6, clamp(Math.floor(inner * 0.28), 10, 30))
131}
132
133/** Shares the terminal band's width: the names, then the bar takes what the percent and the ✕ leave. */
134export function terminalLayoutOf(columns: number, frames: readonly PlanFrame[]): TerminalLayout {
135 const inner = Math.max(20, columns - PADDING * 2)
136 const name = nameWidthOf(columns, frames)
137 const fixed = 1 + 1 + name + 1 + meterWidthOf({ bar: 0 }) + 1 + 1
138 const bar = clamp(inner - fixed, 16, 80)
139
140 return { name, bar, meter: meterWidthOf({ bar }) }
141}
142
143/** The glyph column's cells: one spinner, check or dot per plan. */
144export function glyphsCellsOf(frames: readonly PlanFrame[], context: FrameContext): string {
145 return encodeRows(frames.map(frame => [glyphCell(frame, context)]))
146}
147
148/** The meter column's cells: one bar and percent per plan. */
149export function metersCellsOf(frames: readonly PlanFrame[], context: FrameContext, layout: MeterWidths): string {
150 return encodeRows(frames.map(frame => meterRow(frame, context, layout)))
151}
152
153/**
154 * The terminal band: a header (◆ Progress, a rule fading out, the count),
155 * then the plans in aligned columns. The glyphs and the meters are one
156 * Raster each, so the frame loop repaints them with `$.ui.blit` alone; the
157 * names and the ✕ Buttons light up together under the pointer.
158 */
159export function terminalBandView(ui: TerminalKit, model: BandModel, layout: TerminalLayout): RenderElement {
160 const { Box, Text, Button, Raster } = ui
161 const { frames, context } = model
162 const summary = summaryOf(frames)
163 const inner = Math.max(20, model.columns - PADDING * 2)
164 const rule = inner - (1 + 1 + TITLE.length + 1 + [...summary].length + 1 + [...foldLabelOf(model)].length + 1) - COLLAPSE_MARK
165
166 const pause = model.guard.pause
167
168 return (
169 <Box flexDirection="column" paddingX={PADDING}>
170 {pause === null ? null : terminalAlertCard(ui, model.guard, pause, context.now, alertWaveWidthOf(model.columns))}
171 {frames.length > 0 ? (
172 <Box flexDirection="row" gap={1}>
173 <Text bold color={hexOf(LAVENDER)}>
174 ◆
175 </Text>
176 <Text bold>{TITLE}</Text>
177 {rule >= 3 ? <Raster key="rule" columns={rule} rows={1} cells={encodeRows([ruleCells(rule)])} /> : null}
178 <Text dimColor>{summary}</Text>
179 {foldButton(Button, model, false)}
180 </Box>
181 ) : null}
182 {frames.length > 0 && model.fold.isFolded ? terminalRingsRow(ui, model) : null}
183 {frames.length > 0 && !model.fold.isFolded ? (
184 <Box flexDirection="row" gap={1}>
185 <Raster key={GLYPHS_KEY} columns={1} rows={frames.length} cells={glyphsCellsOf(frames, context)} />
186 <Box flexDirection="column" width={layout.name} flexShrink={0}>
187 {frames.map(frame => (
188 <Text
189 bold={!isDone(frame.plan) && !isGoing(frame) && !isWaiting(frame.plan)}
190 dimColor={isDone(frame.plan) || isGoing(frame) || isWaiting(frame.plan)}
191 italic={isWaiting(frame.plan)}
192 strikethrough={isGoing(frame)}
193 wrap="truncate-end"
194 hover={{ scope: scopeOf(frame), color: hexOf(WHITE), dimColor: false }}
195 >
196 {frame.plan.name}
197 </Text>
198 ))}
199 </Box>
200 <Raster
201 key={METERS_KEY}
202 columns={layout.meter}
203 rows={frames.length}
204 cells={metersCellsOf(frames, context, layout)}
205 />
206 <Box flexDirection="column" flexShrink={0}>
207 {frames.map(frame => (isGoing(frame) ? <Text> </Text> : removeButton(Button, model, frame)))}
208 </Box>
209 </Box>
210 ) : null}
211 {model.quotas.length > 0 ? (editedOf(model) === undefined ? terminalQuotaRow(ui, model) : terminalLimitPicker(ui, model.guard, editedOf(model) as Quota)) : null}
212 {model.theirs}
213 </Box>
214 )
215}
216
217/** Cells of one quota meter on the terminal, its caps included. */
218const QUOTA_CELLS = 12
219
220/**
221 * The terminal's quota row: `◷`, then each window's label, a short braille
222 * meter of what is left in its color, `62% left` and when it resets.
223 */
224function terminalQuotaRow(ui: TerminalKit, model: BandModel): RenderElement {
225 const { Box, Text, Raster } = ui
226
227 return (
228 <Box key="quotas" flexDirection="row" gap={1}>
229 <Text dimColor>◷</Text>
230 {model.quotas.map((quota, index) => {
231 const color = quotaColorOf(quota.remaining)
232 const reset = resetShortOf(quota, model.context.now)
233
234 return (
235 <Box key={`quota:${quota.kind}`} flexDirection="row" gap={1} flexShrink={0} marginLeft={index === 0 ? 0 : 2}>
236 {limitLabelButton(ui.Button, model.guard, quota)}
237 <Raster
238 key={`quota:${quota.kind}`}
239 columns={QUOTA_CELLS}
240 rows={1}
241 cells={encodeRows([quotaCells(quota.remaining, color, QUOTA_CELLS, seedOf(quota.kind))])}
242 />
243 <Text color={hexOf(color)}>{remainingShortOf(quota.remaining)}</Text>
244 {reset === '' ? null : <Text dimColor>{reset}</Text>}
245 {limitFlagOf(ui, model.guard, quota)}
246 </Box>
247 )
248 })}
249 </Box>
250 )
251}
252
253/** The window whose limit picker stands in the quota row's place, if any. */
254function editedOf(model: BandModel): Quota | undefined {
255 return model.quotas.find(each => each.kind === model.guard.editing)
256}
257
258/** The terminal alert wave's width: the band less the frame and its padding. */
259export function alertWaveWidthOf(columns: number): number {
260 return clamp(columns - PADDING * 2 - 4, 10, 200)
261}
262
263/**
264 * The desktop's quota row, under the plans: each window's label, a slim
265 * LED capsule of what is left in its color, `62% left` and when it resets.
266 */
267function desktopQuotaRow(ui: DesktopKit, model: BandModel): RenderElement {
268 const { Box, Text, Svg } = ui
269
270 return (
271 <Box key="quotas" flexDirection="row" gap={1} alignItems="center" marginTop={model.frames.length > 0 ? 1 : 0}>
272 <Text color={hexOf(MUTED)}>◷</Text>
273 {model.quotas.map((quota, index) => {
274 const color = quotaColorOf(quota.remaining)
275 const reset = resetShortOf(quota, model.context.now)
276
277 return (
278 <Box key={`quota:${quota.kind}`} flexDirection="row" gap={1} alignItems="center" flexShrink={0} marginLeft={index === 0 ? 0 : 3}>
279 {limitLabelButton(ui.Button, model.guard, quota)}
280 <Box key={`quota-bar:${quota.kind}`} position="relative" flexShrink={0}>
281 <Svg
282 source={quotaBarOf(
283 {
284 kind: quota.kind,
285 remaining: quota.remaining,
286 color,
287 limit: model.guard.limits[quota.kind],
288 isGrowing: model.guard.justSet !== null,
289 isLanding: model.guard.justSet === quota.kind,
290 },
291 QUOTA_BAR_PX,
292 10,
293 )}
294 alt={`${quota.label}: ${remainingTextOf(quota.remaining)}`}
295 width={QUOTA_BAR_PX}
296 height={10}
297 />
298 <Box
299 key={`quota-lit:${quota.kind}`}
300 position="absolute"
301 top={0}
302 left={0}
303 display="none"
304 hover={{ scope: `quota:${quota.kind}`, display: 'flex' }}
305 >
306 <Svg
307 source={quotaBarOf({ kind: quota.kind, remaining: quota.remaining, color, limit: model.guard.limits[quota.kind], isHovered: true }, QUOTA_BAR_PX, 10)}
308 alt={`Set a ${quota.label} limit`}
309 width={QUOTA_BAR_PX}
310 height={10}
311 />
312 </Box>
313 {capsuleButtonOf(ui, model.guard, quota)}
314 </Box>
315 <Text color={hexOf(color)} wrap="truncate-end">
316 {remainingShortOf(quota.remaining)}
317 </Text>
318 {reset === '' ? null : (
319 <Text color={hexOf(MUTED)} wrap="truncate-end">
320 {reset}
321 </Text>
322 )}
323 {limitFlagOf(ui, model.guard, quota)}
324 </Box>
325 )
326 })}
327 {model.frames.length > 0 && !model.fold.isFolded ? (
328 <Box key="band-toggle-box" marginLeft={3} flexShrink={0}>
329 {foldButton(ui.Button, model, true)}
330 </Box>
331 ) : null}
332 </Box>
333 )
334}
335
336/** The desktop quota capsule's width, in CSS pixels. */
337const QUOTA_BAR_PX = 80
338
339/**
340 * The desktop band, after the reference shot: no header, one row per plan
341 * (a dot, the name, the SVG bar with its pixel field and riding
342 * chip, the percent in a quiet grey, the ✕), the bars all one width so the
343 * percents line up, the whole band centred.
344 */
345export function desktopBandView(ui: DesktopKit, model: BandModel): RenderElement {
346 const { Box, Text, Svg } = ui
347 const { frames, context } = model
348 const name = nameWidthOf(model.columns, frames)
349 const fixedCells = PADDING * 2 + 2 + name + 1 + 1 + 5 + 1 + 2
350 const barPx = clamp(Math.floor((model.columns - fixedCells) * DESKTOP_CELL_PX), 160, 960)
351
352 const pause = model.guard.pause
353
354 return (
355 <Box flexDirection="column" alignItems="center" paddingX={PADDING}>
356 {pause === null ? null : desktopAlertCard(ui, model.guard, pause, context.now, clamp(barPx, 160, 520))}
357 {frames.length > 0 && model.fold.isFolded ? desktopRingsRow(ui, model) : null}
358 {(model.fold.isFolded ? [] : frames).map((frame, index) => {
359 const { plan } = frame
360 const done = isDone(plan)
361 const target = done ? 1 : plan.step / plan.total
362 const move = model.moves?.get(plan.id)
363 const chip = chipOf(plan)
364 const isPaused = pause !== null && !done
365 const label = isPaused ? 'Paused' : chip.label
366 const { count } = chip
367 const enterDelay = model.fold.isAnimating ? { enterDelay: index * 0.07 } : frame.isIgniting === true ? { enterDelay: 0 } : {}
368
369 if (frame.queue !== undefined) {
370 return desktopWaitingRow(ui, model, frame, frame.queue, name, barPx, enterDelay)
371 }
372
373 const source = svgBarOf({
374 id: plan.id,
375 width: barPx,
376 height: DESKTOP_BAR_PX,
377 share: target,
378 from: move?.from ?? null,
379 durationMs: move?.durationMs ?? 0,
380 total: plan.total,
381 step: plan.step,
382 label,
383 count,
384 isDone: done,
385 isLive: context.isWorking && frame.isActive,
386 isPaused,
387 padY: DESKTOP_BAR_GAP_PX,
388 isDissolving: isGoing(frame),
389 ...enterDelay,
390 })
391
392 return (
393 <Box key={`row:${plan.id}`} flexDirection="row" gap={1} alignItems="center">
394 <Text color={hexOf(done ? EMERALD : LAVENDER)}>•</Text>
395 <Box width={name} flexShrink={0}>
396 <Text dimColor={done || isGoing(frame)} strikethrough={isGoing(frame)} wrap="truncate-end">
397 {plan.name}
398 </Text>
399 </Box>
400 <Box key={`bar:${plan.id}`} position="relative" flexShrink={0}>
401 <Svg
402 source={source}
403 alt={`${plan.name}: ${label} ${count}, ${shownPercent(target)}%`}
404 width={barPx}
405 height={DESKTOP_BAR_PX + DESKTOP_BAR_GAP_PX * 2}
406 />
407 {isGoing(frame) ? burstOverlayOf(ui, plan.id, barPx, label, count, target, done) : null}
408 </Box>
409 <Box width={5} flexShrink={0}>
410 <Text color={hexOf(done ? EMERALD : MUTED)} dimColor={isGoing(frame)}>
411 {isGoing(frame) ? '' : `${shownPercent(target)}%`.padStart(4)}
412 </Text>
413 </Box>
414 {isGoing(frame) ? <Text> </Text> : removeButton(ui.Button, model, frame)}
415 </Box>
416 )
417 })}
418 {model.quotas.length > 0 ? (
419 editedOf(model) === undefined ? (
420 desktopQuotaRow(ui, model)
421 ) : (
422 desktopLimitPicker(ui, model.guard, editedOf(model) as Quota, frames.length > 0 ? 1 : 0)
423 )
424 ) : frames.length > 0 && !model.fold.isFolded ? (
425 <Box key="quotas" flexDirection="row" marginTop={1}>
426 {foldButton(ui.Button, model, true)}
427 </Box>
428 ) : null}
429 {model.theirs}
430 </Box>
431 )
432}
433
434/** Any other surface: the bar as text, `████░░░░`, and the chip's words. */
435export function plainBandView(ui: PlainKit, model: BandModel): RenderElement {
436 const { Box, Text, Button } = ui
437 const pause = model.guard.pause
438
439 return (
440 <Box flexDirection="column" paddingX={PADDING}>
441 {pause === null ? null : (
442 <Box key="alert" flexDirection="row" gap={1}>
443 <Text bold color={hexOf(pause.phase === 'alert' ? AMBER : INDIGO)}>
444 {pause.phase === 'alert' ? `Paused · ${pause.label} limit reached` : `Saved · waiting for the ${pause.label} reset`}
445 </Text>
446 {pause.phase === 'alert' ? (
447 <Button key="pause:save" variant="primary" onPress={model.guard.onSave}>
448 {'Save & wait'}
449 </Button>
450 ) : null}
451 <Button key="pause:resume" onPress={model.guard.onResume}>
452 {pause.phase === 'alert' ? 'Resume' : 'Resume now'}
453 </Button>
454 </Box>
455 )}
456 {model.frames.map(frame => {
457 if (isWaiting(frame.plan)) {
458 return (
459 <Box key={`row:${frame.plan.id}`} flexDirection="row" gap={1}>
460 <Text color={hexOf(INDIGO)}>◦</Text>
461 <Text italic wrap="truncate-end">
462 {frame.plan.name}
463 </Text>
464 <Text color={hexOf(INDIGO)}>{`Waiting #${frame.queue ?? 1}`}</Text>
465 {removeButton(ui.Button, model, frame)}
466 </Box>
467 )
468 }
469
470 const done = isDone(frame.plan)
471 const target = done ? 1 : frame.plan.step / frame.plan.total
472 const filled = Math.round(target * 20)
473 const { label, count } = chipOf(frame.plan)
474
475 return (
476 <Box key={`row:${frame.plan.id}`} flexDirection="row" gap={1}>
477 <Text color={hexOf(done ? EMERALD : LAVENDER)}>•</Text>
478 <Text wrap="truncate-end">{frame.plan.name}</Text>
479 <Text color={hexOf(done ? EMERALD : LAVENDER)}>{'█'.repeat(filled) + '░'.repeat(20 - filled)}</Text>
480 <Text bold>{`${label} ${count}`}</Text>
481 <Text color={hexOf(MUTED)}>{`${shownPercent(target)}%`}</Text>
482 {removeButton(ui.Button, model, frame)}
483 </Box>
484 )
485 })}
486 {model.quotas.length > 0 ? (
487 <Text color={hexOf(MUTED)}>
488 {model.quotas
489 .map(quota => [quota.label, remainingTextOf(quota.remaining), resetTextOf(quota, model.context.now)].filter(Boolean).join(' '))
490 .join(' ')}
491 </Text>
492 ) : null}
493 {model.theirs}
494 </Box>
495 )
496}
497
498/**
499 * A message waiting its turn on the desktop: a hollow indigo dot, its first
500 * line in italics, the waiting bar (a scanner glow and an hourglass chip with
501 * its place in the queue), no percent, and the ✕.
502 */
503function desktopWaitingRow(
504 ui: DesktopKit,
505 model: BandModel,
506 frame: PlanFrame,
507 position: number,
508 name: number,
509 barPx: number,
510 enterDelay: { enterDelay?: number },
511): RenderElement {
512 const { Box, Text, Svg } = ui
513 const { plan } = frame
514
515 return (
516 <Box key={`row:${plan.id}`} flexDirection="row" gap={1} alignItems="center">
517 <Text color={hexOf(INDIGO)}>◦</Text>
518 <Box width={name} flexShrink={0}>
519 <Text italic dimColor strikethrough={isGoing(frame)} wrap="truncate-end">
520 {plan.name}
521 </Text>
522 </Box>
523 <Box key={`bar:${plan.id}`} position="relative" flexShrink={0}>
524 <Svg
525 source={waitingBarOf({ id: plan.id, width: barPx, height: DESKTOP_BAR_PX, position, padY: DESKTOP_BAR_GAP_PX, ...enterDelay })}
526 alt={`${plan.name}: waiting, #${position} in line`}
527 width={barPx}
528 height={DESKTOP_BAR_PX + DESKTOP_BAR_GAP_PX * 2}
529 />
530 </Box>
531 <Box width={5} flexShrink={0}>
532 <Text> </Text>
533 </Box>
534 {isGoing(frame) ? <Text> </Text> : removeButton(ui.Button, model, frame)}
535 </Box>
536 )
537}
538
539function removeButton(Button: Button, model: BandModel, frame: PlanFrame): RenderElement {
540 return (
541 <Button
542 key={`remove:${frame.plan.id}`}
543 plain
544 dimColor
545 hover={{ scope: scopeOf(frame), color: hexOf(DANGER) }}
546 onPress={() => model.onRemove(frame.plan.id)}
547 >
548 ✕
549 </Button>
550 )
551}
552
553/** How tall and how much wider than its bar the burst layer is: the margins the burst can spill into. */
554const BURST_PX = DESKTOP_BAR_PX + DESKTOP_BAR_GAP_PX * 2 + 96
555const BURST_SPILL_PX = 80
556
557/**
558 * The ✕'s burst on the desktop: a layer laid over the bar and spilling past
559 * it on every side, in front of the rows around it, centred on the bar so
560 * its burst sits on the chip whatever the surface's cell size.
561 */
562function burstOverlayOf(ui: DesktopKit, id: string, barPx: number, label: string, count: string, share: number, done: boolean): RenderElement {
563 const { Box, Svg } = ui
564 const bar = { width: barPx, label, count, share }
565 const { chipW } = chipGeometryOf(bar)
566
567 return (
568 <Box key={`burst:${id}`} position="absolute" top={-3} bottom={-3} left={-12} right={-12} justifyContent="center" alignItems="center">
569 <Svg
570 source={burstLayerOf({ width: barPx + BURST_SPILL_PX * 2, height: BURST_PX, cx: chipCentreOf(bar) + BURST_SPILL_PX, chipW, isDone: done })}
571 alt=""
572 width={barPx + BURST_SPILL_PX * 2}
573 height={BURST_PX}
574 />
575 </Box>
576 )
577}
578
579/** `3 tasks`, `1 task`. */
580function tasksWordOf(frames: readonly PlanFrame[]): string {
581 return frames.length === 1 ? '1 task' : `${frames.length} tasks`
582}
583
584/** What the fold toggle says: `▴ Collapse` over the open list, `▾ 3 tasks` on the folded line. */
585function foldLabelOf(model: BandModel): string {
586 return model.fold.isFolded ? `▾ ${tasksWordOf(model.frames)}` : '▴ Collapse'
587}
588
589/**
590 * The fold toggle, a labelled control rather than a bare mark: a native
591 * secondary button on the desktop (`▴ Collapse`, `▾ 3 tasks`), lit text on
592 * the terminal.
593 */
594function foldButton(Button: Button, model: BandModel, isNative: boolean): RenderElement {
595 return isNative ? (
596 <Button key="band-toggle" variant="secondary" onPress={model.fold.onToggle}>
597 {foldLabelOf(model)}
598 </Button>
599 ) : (
600 <Button key="band-toggle" plain hover={{ scope: 'band-toggle', color: hexOf(LAVENDER) }} onPress={model.fold.onToggle}>
601 {foldLabelOf(model)}
602 </Button>
603 )
604}
605
606/** The folded line's words: the plan under way (or `All done`), its step and how many are done, and the overall percent. */
607function foldedFactsOf(frames: readonly PlanFrame[]): { title: string; detail: string; percent: number; isAllDone: boolean } {
608 const waiting = frames.filter(frame => isWaiting(frame.plan))
609 const plans = frames.filter(frame => !isWaiting(frame.plan))
610 const done = plans.filter(frame => isDone(frame.plan)).length
611 const running = plans.filter(frame => !isDone(frame.plan))
612 const active = running.find(frame => frame.isActive) ?? running.at(-1)
613 const overall = plans.reduce((sum, frame) => sum + (isDone(frame.plan) ? 1 : frame.plan.step / frame.plan.total), 0) / Math.max(1, plans.length)
614 const queued = waiting.length > 0 ? ` · ${waiting.length} waiting` : ''
615
616 if (active === undefined) {
617 return plans.length === 0
618 ? { title: waiting[0]?.plan.name ?? 'Waiting', detail: `${waiting.length} waiting`, percent: 0, isAllDone: false }
619 : { title: 'All done', detail: `${tasksWordOf(plans)}${queued}`, percent: 100, isAllDone: true }
620 }
621
622 const others = running.length > 1 ? ` · ${running.length - 1} more` : ''
623
624 return {
625 title: active.plan.name,
626 detail: `${chipOf(active.plan).count}${done > 0 ? ` · ${done} done` : ''}${others}${queued}`,
627 percent: shownPercent(overall),
628 isAllDone: false,
629 }
630}
631
632/**
633 * The folded list on the desktop, one centred line: a ring per plan (an
634 * emerald check when finished, a lavender arc as far round as its share
635 * while running, a spark circling the one Claude works on), the plan under
636 * way and its step, the overall percent, and `▾ 3 tasks` to open the list.
637 */
638function desktopRingsRow(ui: DesktopKit, model: BandModel): RenderElement {
639 const { Box, Text, Svg } = ui
640 const { frames, context } = model
641 const facts = foldedFactsOf(frames)
642 const rings = ringsOf(
643 frames.map(frame => ({
644 id: frame.plan.id,
645 share: isDone(frame.plan) ? 1 : frame.plan.step / frame.plan.total,
646 isDone: isDone(frame.plan),
647 isLive: context.isWorking && frame.isActive,
648 isWaiting: isWaiting(frame.plan),
649 })),
650 model.fold.isAnimating,
651 )
652
653 return (
654 <Box key="summary" flexDirection="row" gap={1} alignItems="center">
655 <Svg source={rings.source} alt={`${facts.title}, ${facts.detail}`} width={rings.width} height={RINGS_PX} />
656 <Box flexShrink={1}>
657 <Text bold color={facts.isAllDone ? hexOf(EMERALD) : undefined} wrap="truncate-end">
658 {facts.title}
659 </Text>
660 </Box>
661 <Text color={hexOf(MUTED)} wrap="truncate-end">
662 {facts.detail}
663 </Text>
664 <Box width={5} flexShrink={0}>
665 <Text color={hexOf(facts.isAllDone ? EMERALD : MUTED)}>{`${facts.percent}%`.padStart(4)}</Text>
666 </Box>
667 <Box key="band-toggle-box" marginLeft={1} flexShrink={0}>
668 {foldButton(ui.Button, model, true)}
669 </Box>
670 </Box>
671 )
672}
673
674/** A running plan's ring on the terminal, filled as far as its share. */
675function ringGlyphOf(share: number): string {
676 return ['○', '◔', '◑', '◕', '●'][Math.min(4, Math.round(share * 4))] ?? '○'
677}
678
679/** The folded list on the terminal: `✓─✓─◑`, the plan under way and its step, the overall percent. */
680function terminalRingsRow(ui: TerminalKit, model: BandModel): RenderElement {
681 const { Box, Text } = ui
682 const { frames } = model
683 const facts = foldedFactsOf(frames)
684
685 return (
686 <Box key="summary" flexDirection="row" gap={1}>
687 <Box flexDirection="row" flexShrink={0}>
688 {frames.map((frame, index) => (
689 <Box key={`ring:${frame.plan.id}`} flexDirection="row">
690 {index > 0 ? <Text color={hexOf(isDone(frames[index - 1]?.plan ?? frame.plan) ? EMERALD : MUTED)} dimColor>─</Text> : null}
691 <Text bold color={hexOf(isWaiting(frame.plan) ? INDIGO : isDone(frame.plan) ? EMERALD : LAVENDER)}>
692 {isWaiting(frame.plan) ? '◌' : isDone(frame.plan) ? '✓' : ringGlyphOf(frame.plan.step / frame.plan.total)}
693 </Text>
694 </Box>
695 ))}
696 </Box>
697 <Text bold color={facts.isAllDone ? hexOf(EMERALD) : undefined} wrap="truncate-end">
698 {facts.title}
699 </Text>
700 <Text dimColor wrap="truncate-end">
701 {facts.detail}
702 </Text>
703 <Text color={hexOf(facts.isAllDone ? EMERALD : MUTED)}>{`${facts.percent}%`}</Text>
704 </Box>
705 )
706}
707
708/** Whether a plan's row is coming apart after its ✕. */
709function isGoing(frame: PlanFrame): boolean {
710 return frame.dissolve !== undefined && frame.dissolve !== null
711}
712
713/** The hover group of one plan's row: its name and its ✕ light together. */
714function scopeOf(frame: PlanFrame): string {
715 return `row:${frame.plan.id}`.slice(0, 64)
716}
717
718function clamp(value: number, min: number, max: number): number {
719 return Math.max(min, Math.min(max, value))
720}
721hooks/views/svg-bar.ts 795 lines1import { AMBER, AMBER_DEEP, EMERALD, hexOf, INDIGO, INDIGO_BRIGHT, LAVENDER, mix, noise, seedOf, WHITE, type Rgb } from '../palette'
2
3/** What one desktop bar draws: its size, where it stands, how it moves, what its chip says. */
4export type SvgBar = {
5 /** The plan's id: the document's ids and the pixels' grain derive from it. */
6 id: string
7 /** CSS pixels across. */
8 width: number
9 /** CSS pixels down: the track's thickness. */
10 height: number
11 /** The share the bar stands at once any animation is over, 0 to 1. */
12 share: number
13 /** The share the head moves from, while it is moving; null at rest. */
14 from: number | null
15 /** How long the move takes from now, in milliseconds. */
16 durationMs: number
17 /** How many steps the plan has, and how many are done. */
18 total: number
19 step: number
20 /** The chip's stage and its `n/total`. */
21 label: string
22 count: string
23 isDone: boolean
24 /** Claude works on this plan now: the pixels twinkle, a light sweeps, the glow breathes. */
25 isLive: boolean
26 /** A usage limit holds the work: the bar turns amber and only breathes. */
27 isPaused?: boolean
28 /** CSS pixels of air above and below the track, so stacked bars never touch. */
29 padY?: number
30 /** The ✕ was pressed: the bar comes apart, LED by LED, and the chip bursts. */
31 isDissolving?: boolean
32 /** The list just unfolded: the bar sweeps in from the left after this many seconds; absent at rest. */
33 enterDelay?: number
34}
35
36/** How long a dissolving bar takes to come apart, its last LED's delay included. */
37export const DISSOLVE_MS = 1100
38
39/** The colors one state of the bar is drawn in. */
40type Tone = {
41 /** The pixels far from the head, at the head, and the chip. */
42 dim: Rgb
43 bright: Rgb
44 chip: Rgb
45}
46
47const RUNNING: Tone = { dim: 0x4c4766, bright: 0xe6e1ff, chip: LAVENDER }
48const FINISHED: Tone = { dim: 0x24543f, bright: 0xc6f7e2, chip: 0x22b07d }
49const PAUSED: Tone = { dim: 0x4f3c1c, bright: 0xffe2a8, chip: AMBER_DEEP }
50
51const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', system-ui, sans-serif"
52const FONT_SIZE = 12
53/** One LED of the matrix, and the pitch between two. */
54const LED = 3
55const PITCH = 4
56/** How far the wash's glow runs on past the head before it is gone. */
57const FEATHER = 28
58const CHIP_PAD = 11
59const COUNT_GAP = 5
60const EASE = '0.22 1 0.36 1'
61/** The opacity steps the LEDs are sorted into: one path each, so the document stays small. */
62const LEVELS = [0.16, 0.3, 0.46, 0.66, 0.86, 1]
63
64/**
65 * The desktop's bar, after the band in the reference shot: a softly domed
66 * track; behind the head a matrix of square LEDs, sparse and dim at the
67 * start, dense and bright at the head, each its own brightness, over a wash
68 * of light that runs on under the chip and fades out past it, with no edge
69 * anywhere; a bloom
70 * of light behind the chip; the chip itself, `Stage 3/5`, a lit gradient
71 * pill riding the head. The LEDs always twinkle one by one, a slow light
72 * crosses the field now and then; while Claude works on the plan both
73 * quicken and the bloom breathes. A move eases it all.
74 * Drawn as an image: no frame, no ground of its own, in either theme.
75 */
76export function svgBarOf(bar: SvgBar): string {
77 const { width: w, height: h } = bar
78 const key = bar.id.replace(/[^a-z0-9]/gi, '').slice(0, 24) || 'p'
79 const isPaused = bar.isPaused === true && !bar.isDone
80 const tone = bar.isDone ? FINISHED : isPaused ? PAUSED : RUNNING
81 const accent = hexOf(bar.isDone ? EMERALD : isPaused ? AMBER : tone.chip)
82 const live = bar.isLive && !bar.isDone && !isPaused
83 const chipH = h - 4
84 const { chipW, headOf } = chipGeometryOf(bar)
85 const head = headOf(bar.share)
86 const from = bar.from === null ? null : headOf(bar.from)
87 const moving = from !== null && Math.abs(from - head) > 0.5 && bar.durationMs > 0
88 const dur = `${Math.max(0.05, bar.durationMs / 1000).toFixed(2)}s`
89 const spline = `dur="${dur}" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="${EASE}"`
90 const reveal = Math.round(head + FEATHER)
91 const revealFrom = from === null ? reveal : Math.round(from + FEATHER)
92 const chipTo = Math.round(head - chipW)
93 const chipFrom = from === null ? chipTo : Math.round(from - chipW)
94 // Where the field shows its brightest: at the chip's left cap, not under it.
95 const peakOf = (x: number) => Math.max(8, Math.round(x - chipW + chipH / 2))
96 const peak = peakOf(head)
97 const peakFrom = from === null ? peak : peakOf(from)
98 const follow = (attribute: string, a: number, b: number) =>
99 moving ? `<animate attributeName="${attribute}" from="${a}" to="${b}" ${spline}/>` : ''
100
101 const padY = Math.max(0, Math.round(bar.padY ?? 0))
102 const isDissolving = bar.isDissolving === true
103 const enter = bar.enterDelay === undefined ? null : unfoldOf(`u${key}`, w, h, padY, bar.enterDelay)
104
105 return (
106 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + padY * 2}" viewBox="0 ${-padY} ${w} ${h + padY * 2}">` +
107 `<style>` +
108 `@keyframes tw{0%,100%{opacity:.15}50%{opacity:1}}` +
109 `@keyframes tz{0%,100%{opacity:.35}50%{opacity:1}}` +
110 `@keyframes br{0%,100%{opacity:.4}50%{opacity:.85}}` +
111 `.a{animation:tw 1.4s ease-in-out infinite}.b{animation:tw 2.1s ease-in-out .7s infinite}.c{animation:tw 1.1s ease-in-out .35s infinite}` +
112 `.d{animation:tz 3.1s ease-in-out infinite}.e{animation:tz 4.3s ease-in-out 1.4s infinite}.f{animation:tz 2.5s ease-in-out .8s infinite}` +
113 `.br{animation:br 1.9s ease-in-out infinite}` +
114 (bar.isDissolving === true
115 ? `@keyframes fl{0%{transform:translate(0,0);opacity:1}30%{opacity:1;fill:#fff}100%{transform:translate(var(--dx),var(--dy)) scale(.4);opacity:0}}` +
116 `.fl{transform-box:fill-box;transform-origin:center;animation:fl .7s cubic-bezier(.2,.7,.3,1) both}` +
117 `@keyframes fo{to{opacity:0}}.fo{animation:fo .55s ease-out .25s both}` +
118 `@keyframes cp{0%{transform:scale(1)}28%{transform:scale(1.14);opacity:1}100%{transform:scale(.15);opacity:0}}` +
119 `.cp{transform-box:fill-box;transform-origin:center;animation:cp .5s cubic-bezier(.5,0,.75,0) both}`
120 : '') +
121 `</style>` +
122 `<defs>` +
123 `<clipPath id="k${key}"><rect x="0" y="0" width="${w}" height="${h}" rx="${h / 2}"/></clipPath>` +
124 // The LEDs end under the chip's left cap, wherever the chip is on its way.
125 `<clipPath id="r${key}"><rect x="0" y="0" width="${peak + 2}" height="${h}">${follow('width', peakFrom + 2, peak + 2)}</rect></clipPath>` +
126 // The LEDs' ramp: dim at the start, bright at the head.
127 `<linearGradient id="p${key}" gradientUnits="userSpaceOnUse" x1="0" x2="${peak}" y1="0" y2="0">` +
128 `<stop offset="0" stop-color="${hexOf(tone.dim)}"/><stop offset="0.6" stop-color="${hexOf(mix(tone.dim, tone.bright, 0.55))}"/><stop offset="1" stop-color="${hexOf(tone.bright)}"/>` +
129 `${follow('x2', peakFrom, peak)}</linearGradient>` +
130 // The wash: nothing at the start, a glow at the head that runs on and fades past it.
131 `<linearGradient id="w${key}" gradientUnits="userSpaceOnUse" x1="0" x2="${reveal}" y1="0" y2="0">` +
132 `<stop offset="0" stop-color="${accent}" stop-opacity="0"/>` +
133 `<stop offset="${((peak * 0.55) / reveal).toFixed(3)}" stop-color="${accent}" stop-opacity="0.07"/>` +
134 `<stop offset="${(peak / reveal).toFixed(3)}" stop-color="${accent}" stop-opacity="0.36"/>` +
135 `<stop offset="${(head / reveal).toFixed(3)}" stop-color="${accent}" stop-opacity="0.26"/>` +
136 `<stop offset="1" stop-color="${accent}" stop-opacity="0"/>` +
137 `${follow('x2', revealFrom, reveal)}</linearGradient>` +
138 // The track's dome: a breath of light on its upper half.
139 `<linearGradient id="d${key}" x1="0" x2="0" y1="0" y2="1">` +
140 `<stop offset="0" stop-color="#fff" stop-opacity="0.09"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.08"/>` +
141 `</linearGradient>` +
142 `<linearGradient id="s${key}" x1="0" x2="1" y1="0" y2="0">` +
143 `<stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.22"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>` +
144 `</linearGradient>` +
145 `<linearGradient id="c${key}" x1="0" x2="0" y1="0" y2="1">` +
146 `<stop offset="0" stop-color="${hexOf(mix(tone.chip, WHITE, 0.22))}"/><stop offset="1" stop-color="${hexOf(tone.chip)}"/>` +
147 `</linearGradient>` +
148 `<filter id="g${key}" x="-60%" y="-120%" width="220%" height="340%"><feGaussianBlur stdDeviation="7"/></filter>` +
149 (enter?.defs ?? '') +
150 `</defs>` +
151 (enter?.open ?? '') +
152 `<g clip-path="url(#k${key})"${isDissolving ? ' class="fo"' : ''}>` +
153 `<rect x="0" y="0" width="${w}" height="${h}" fill="#8e8e96" fill-opacity="0.17"/>` +
154 // The wash needs no clip: its gradient fades to nothing past the head, and moves with it.
155 `<rect x="0" y="0" width="${w}" height="${h}" fill="url(#w${key})"/>` +
156 `<g clip-path="url(#r${key})">` +
157 (isDissolving ? '' : ledsOf(bar, key, head, peak, h, live)) +
158 (live
159 ? `<rect x="-80" y="0" width="80" height="${h}" fill="url(#s${key})" transform="skewX(-18)"><animate attributeName="x" from="-80" to="${w + 40}" dur="2.4s" repeatCount="indefinite"/></rect>`
160 : `<rect x="-80" y="0" width="80" height="${h}" fill="url(#s${key})" opacity="0.55" transform="skewX(-18)"><animate attributeName="x" values="-80;${w + 40};${w + 40}" keyTimes="0;0.55;1" dur="6.5s" repeatCount="indefinite"/></rect>`) +
161 `</g>` +
162 `<rect x="0" y="0" width="${w}" height="${h}" fill="url(#d${key})"/>` +
163 (isDissolving ? flyingLedsOf(bar, key, head, peak, h, tone, chipTo + chipW / 2) : '') +
164 `<g transform="translate(${chipTo} 0)">` +
165 (moving ? `<animateTransform attributeName="transform" type="translate" from="${chipFrom} 0" to="${chipTo} 0" ${spline}/>` : '') +
166 `<ellipse cx="${chipW / 2}" cy="${h / 2}" rx="${Math.round(chipW * 0.62)}" ry="${h}" fill="${accent}" filter="url(#g${key})"${live ? ' class="br"' : ' opacity="0.42"'}/>` +
167 `</g>` +
168 `</g>` +
169 `<g transform="translate(${chipTo} 0)">` +
170 (moving ? `<animateTransform attributeName="transform" type="translate" from="${chipFrom} 0" to="${chipTo} 0" ${spline}/>` : '') +
171 (isDissolving ? `<g class="cp">` : '') +
172 `<rect x="0" y="2" width="${chipW}" height="${chipH}" rx="${chipH / 2}" fill="url(#c${key})"/>` +
173 `<rect x="0.5" y="2.5" width="${chipW - 1}" height="${chipH - 1}" rx="${(chipH - 1) / 2}" fill="none" stroke="#fff" stroke-opacity="0.28"/>` +
174 `<text x="${chipW / 2}" y="${h / 2 + 0.5}" text-anchor="middle" dominant-baseline="central" font-family="${FONT}" font-size="${FONT_SIZE}" fill="#fff">` +
175 `<tspan font-weight="600">${escapeXml(bar.label)}</tspan>` +
176 `<tspan dx="${COUNT_GAP}" font-weight="500" fill-opacity="0.8">${escapeXml(bar.count)}</tspan>` +
177 `</text>` +
178 (isDissolving ? `</g>` : '') +
179 `</g>` +
180 (enter?.close ?? '') +
181 `</svg>`
182 )
183}
184
185/** What a waiting message's bar draws. */
186export type WaitingBar = {
187 id: string
188 width: number
189 height: number
190 /** Its place in the queue, from 1. */
191 position: number
192 padY?: number
193 /** The list just unfolded: the bar sweeps in after this many seconds. */
194 enterDelay?: number
195}
196
197/**
198 * A message waiting its turn: the same track, dark but for a sparse field
199 * of indigo LEDs breathing slowly, a soft glow gliding back and forth
200 * across it like a scanner, and at its start a glassy indigo chip,
201 * `⧗ Waiting #1`, its hourglass turning over now and then. Nothing fills:
202 * nothing has started.
203 */
204export function waitingBarOf(bar: WaitingBar): string {
205 const { width: w, height: h } = bar
206 const key = `q${bar.id.replace(/[^a-z0-9]/gi, '').slice(-20)}`
207 const padY = Math.max(0, Math.round(bar.padY ?? 0))
208 const chipH = h - 4
209 const label = 'Waiting'
210 const count = `#${bar.position}`
211 const icon = 12
212 const chipW = Math.round(icon + 5 + textWidth(label, 600) + COUNT_GAP + textWidth(count, 500) + CHIP_PAD * 2)
213 const seed = seedOf(bar.id)
214 const rows = Math.max(1, Math.floor((h - 4) / PITCH))
215 const top = Math.round((h - rows * PITCH + (PITCH - LED)) / 2)
216 const indigo = hexOf(INDIGO)
217 const enter = bar.enterDelay === undefined ? null : unfoldOf(`u${key}`, w, h, padY, bar.enterDelay)
218 const glide = 'calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"'
219 let leds = ''
220
221 for (let x = chipW + 6, column = 0; x + LED <= w - 4; x += PITCH, column += 1) {
222 for (let row = 0; row < rows; row += 1) {
223 if (noise(seed, column * 31 + row, 1) > 0.17) {
224 continue
225 }
226
227 const phase = ['d', 'e', 'f'][Math.floor(noise(seed, column * 31 + row, 2) * 3)] ?? 'd'
228
229 leds += `<rect x="${x}" y="${top + row * PITCH}" width="${LED}" height="${LED}" rx="0.6" fill="${indigo}" class="${phase}"/>`
230 }
231 }
232
233 const cy = h / 2
234 const hx = CHIP_PAD + icon / 2
235
236 return (
237 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + padY * 2}" viewBox="0 ${-padY} ${w} ${h + padY * 2}">` +
238 `<style>@keyframes tz{0%,100%{opacity:.08}50%{opacity:.32}}` +
239 `.d{animation:tz 3.6s ease-in-out infinite}.e{animation:tz 4.8s ease-in-out 1.2s infinite}.f{animation:tz 4.1s ease-in-out 2.4s infinite}</style>` +
240 `<defs>` +
241 `<clipPath id="k${key}"><rect x="0" y="0" width="${w}" height="${h}" rx="${h / 2}"/></clipPath>` +
242 `<linearGradient id="d${key}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="0.09"/><stop offset="0.5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.08"/></linearGradient>` +
243 `<radialGradient id="s${key}"><stop offset="0" stop-color="${hexOf(INDIGO_BRIGHT)}" stop-opacity="0.6"/><stop offset="0.45" stop-color="${indigo}" stop-opacity="0.28"/><stop offset="1" stop-color="${indigo}" stop-opacity="0"/></radialGradient>` +
244 `<linearGradient id="c${key}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${hexOf(mix(INDIGO, 0x1c1c22, 0.35))}"/><stop offset="1" stop-color="${hexOf(mix(INDIGO, 0x1c1c22, 0.6))}"/></linearGradient>` +
245 (enter?.defs ?? '') +
246 `</defs>` +
247 (enter?.open ?? '') +
248 `<g clip-path="url(#k${key})">` +
249 `<rect x="0" y="0" width="${w}" height="${h}" fill="#8e8e96" fill-opacity="0.13"/>` +
250 leds +
251 // The scanner: a soft glow gliding from the chip to the end and back.
252 `<ellipse cx="${chipW}" cy="${cy}" rx="70" ry="${h * 0.9}" fill="url(#s${key})">` +
253 `<animate attributeName="cx" values="${chipW};${w - 30};${chipW}" dur="5.2s" repeatCount="indefinite" ${glide}/>` +
254 `</ellipse>` +
255 `<rect x="0" y="0" width="${w}" height="${h}" fill="url(#d${key})"/>` +
256 `</g>` +
257 `<rect x="0" y="2" width="${chipW}" height="${chipH}" rx="${chipH / 2}" fill="url(#c${key})"/>` +
258 `<rect x="0.5" y="2.5" width="${chipW - 1}" height="${chipH - 1}" rx="${(chipH - 1) / 2}" fill="none" stroke="${indigo}" stroke-opacity="0.75"/>` +
259 // The hourglass, turning over every few seconds.
260 `<g transform="translate(${hx} ${cy})"><g>` +
261 `<animateTransform attributeName="transform" type="rotate" values="0;0;180;180" keyTimes="0;0.72;0.86;1" dur="3s" repeatCount="indefinite"/>` +
262 `<path d="M-4 -5.5h8M-4 5.5h8M-3.2 -5.5c0 3 6.4 3 6.4 5.5s-6.4 2.5-6.4 5.5M3.2 -5.5c0 3-6.4 3-6.4 5.5s6.4 2.5 6.4 5.5" fill="none" stroke="${hexOf(INDIGO_BRIGHT)}" stroke-width="1.3" stroke-linecap="round"/>` +
263 `<path d="M-1.8 3.6h3.6l-1.8-2z" fill="${hexOf(INDIGO_BRIGHT)}"/>` +
264 `</g></g>` +
265 `<text x="${CHIP_PAD + icon + 5}" y="${cy + 0.5}" dominant-baseline="central" font-family="${FONT}" font-size="${FONT_SIZE}" fill="${hexOf(INDIGO_BRIGHT)}">` +
266 `<tspan font-weight="600">${label}</tspan>` +
267 `<tspan dx="${COUNT_GAP}" font-weight="500" fill-opacity="0.75">${count}</tspan>` +
268 `</text>` +
269 (enter?.close ?? '') +
270 `</svg>`
271 )
272}
273
274/**
275 * A bar's unfold, as the list opens: the drawing is revealed left to right
276 * behind a travelling edge of light that flares as it goes and fades out at
277 * the end, each row a beat after the one above.
278 */
279function unfoldOf(id: string, w: number, h: number, padY: number, delay: number): { defs: string; open: string; close: string } {
280 const begin = `${delay.toFixed(2)}s`
281 const spline = `begin="${begin}" dur="0.62s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.65 0 0.25 1"`
282
283 return {
284 defs:
285 `<clipPath id="${id}"><rect x="-4" y="${-padY}" width="0" height="${h + padY * 2}"><animate attributeName="width" from="0" to="${w + 8}" ${spline}/></rect></clipPath>` +
286 `<linearGradient id="${id}e" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.7" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`,
287 open: `<g clip-path="url(#${id})">`,
288 close:
289 `</g>` +
290 `<rect x="-40" y="0" width="40" height="${h}" rx="${h / 2}" fill="url(#${id}e)" opacity="0">` +
291 `<animate attributeName="x" from="-40" to="${w - 4}" ${spline}/>` +
292 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.8;1" begin="${begin}" dur="0.7s" fill="freeze"/>` +
293 `</rect>`,
294 }
295}
296
297/**
298 * The LED matrix from the start of the track to under the chip: on a
299 * regular grid, each LED lit with a chance that climbs toward the head and
300 * given a brightness of its own, brighter near the head; sorted into a few
301 * paths by brightness and twinkle phase (brisk while Claude works, calm
302 * otherwise, a quarter never twinkling) so a bar
303 * of hundreds of LEDs stays a handful of elements.
304 */
305function ledsOf(bar: SvgBar, key: string, head: number, peak: number, h: number, live: boolean): string {
306 const seed = seedOf(bar.id)
307 const rows = Math.max(1, Math.floor((h - 4) / PITCH))
308 const top = Math.round((h - rows * PITCH + (PITCH - LED)) / 2)
309 const end = head - 4
310 const paths = new Map<string, string>()
311
312 for (let x = 3, column = 0; x + LED <= end; x += PITCH, column += 1) {
313 const t = Math.min(1, x / Math.max(1, peak))
314
315 for (let row = 0; row < rows; row += 1) {
316 const coin = noise(seed, column * 31 + row, 1)
317
318 if (coin > (bar.isDone ? 0.55 + 0.4 * t : 0.38 + 0.6 * t ** 1.2)) {
319 continue
320 }
321
322 const glow = Math.min(1, 0.12 + 0.88 * noise(seed, column * 31 + row, 2) ** 1.3 * (0.3 + 0.95 * t))
323 const level = LEVELS.findIndex(step => glow <= step + 0.001)
324 const phase = (live ? ['', 'a', 'b', 'c'] : ['', 'd', 'e', 'f'])[Math.floor(noise(seed, column * 31 + row, 3) * 4)] ?? ''
325 const bucket = `${level === -1 ? LEVELS.length - 1 : level}:${phase}`
326
327 paths.set(bucket, `${paths.get(bucket) ?? ''}M${x} ${top + row * PITCH}h${LED}v${LED}h-${LED}z`)
328 }
329 }
330
331 let out = ''
332
333 for (const [bucket, d] of paths) {
334 const [level = '0', phase = ''] = bucket.split(':')
335 const opacity = LEVELS[Number(level)] ?? 1
336
337 out += `<path d="${d}" fill="url(#p${key})" fill-opacity="${opacity}"${phase === '' ? '' : ` class="${phase}"`}/>`
338 }
339
340 return out
341}
342
343/** A text's width in the chip's font, from rough per-glyph advances; the chip centres it, so a small error only shifts the padding. */
344function textWidth(text: string, weight: 500 | 600): number {
345 let width = 0
346
347 for (const char of text) {
348 if (/[ilj.,:;'|!]/.test(char)) {
349 width += 3.3
350 } else if (/[ frt/()\-]/.test(char)) {
351 width += 4.4
352 } else if (/[mwMW]/.test(char)) {
353 width += 10
354 } else if (/[A-Z]/.test(char)) {
355 width += 8
356 } else if (/[0-9]/.test(char)) {
357 width += 7
358 } else {
359 width += 6.7
360 }
361 }
362
363 return width * (FONT_SIZE / 12) * (weight === 600 ? 1.04 : 1)
364}
365
366function escapeXml(text: string): string {
367 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
368}
369
370/** What a quota capsule draws, and how it comes in. */
371export type QuotaBar = {
372 kind: string
373 /** What is left of the window, 0 to 100, and its color. */
374 remaining: number
375 color: Rgb
376 /** The limit's notch, in percent left; none when undefined. */
377 limit?: number
378 /** Where the notch slides from, when it just moved. */
379 limitFrom?: number | null
380 /** The capsule grows out of its middle (the quota row coming back, a limit picker opening). */
381 isGrowing?: boolean
382 /** A gleam runs along it and its notch drops in with a ring of amber light. */
383 isLanding?: boolean
384 /** CSS pixels of air above and below the capsule. */
385 padY?: number
386 /** Under the pointer: the whole capsule lights, a lavender rim around it and a sheen running along it. */
387 isHovered?: boolean
388}
389
390/**
391 * A quota's bar on the desktop: a slim capsule whose LEDs fill what is left
392 * of the window, brightest at its end, in the quota's color, calmly
393 * twinkling; the empty part a groove; the limit a white notch in an amber
394 * glow. The limit picker draws the same capsule, wider.
395 *
396 * Growing, it opens out of its middle; landing, a gleam runs along it and
397 * its notch drops in with a ring of amber light. A moved notch slides.
398 */
399export function quotaBarOf(quota: QuotaBar, width: number, height: number): string {
400 const key = `q${quota.kind.replace(/[^a-z0-9]/gi, '')}`
401 const seed = seedOf(quota.kind)
402 const fill = Math.round((Math.max(0, Math.min(100, quota.remaining)) / 100) * width)
403 const color = hexOf(quota.color)
404 const led = 2
405 const pitch = 3
406 const rows = Math.max(1, Math.floor((height - 2) / pitch))
407 const top = Math.round((height - rows * pitch + (pitch - led)) / 2)
408 const paths = new Map<string, string>()
409
410 for (let x = 2, column = 0; x + led <= fill - 1; x += pitch, column += 1) {
411 const t = Math.min(1, x / Math.max(1, fill))
412
413 for (let row = 0; row < rows; row += 1) {
414 if (noise(seed, column * 17 + row, 1) > 0.62 + 0.36 * t) {
415 continue
416 }
417
418 const level = Math.min(2, Math.floor(noise(seed, column * 17 + row, 2) * 3))
419 const phase = ['', 'd', 'e'][Math.floor(noise(seed, column * 17 + row, 3) * 3)] ?? ''
420 const bucket = `${level}:${phase}`
421
422 paths.set(bucket, `${paths.get(bucket) ?? ''}M${x} ${top + row * pitch}h${led}v${led}h-${led}z`)
423 }
424 }
425
426 let leds = ''
427
428 for (const [bucket, d] of paths) {
429 const [level = '0', phase = ''] = bucket.split(':')
430
431 leds += `<path d="${d}" fill="url(#p${key})" fill-opacity="${[0.45, 0.72, 1][Number(level)] ?? 1}"${phase === '' ? '' : ` class="${phase}"`}/>`
432 }
433
434 const returning = quota.isGrowing === true
435 const lit = quota.isLanding === true
436 const padY = quota.padY ?? 0
437 const hovered = quota.isHovered === true
438 // Under the pointer: a lift of light over the whole capsule, a sheen gliding along it, and a lavender rim.
439 const glow = hovered
440 ? `<rect x="0" y="0" width="${width}" height="${height}" fill="#fff" fill-opacity="0.06"/>` +
441 `<rect x="-30" y="0" width="30" height="${height}" fill="url(#h${key})"><animate attributeName="x" from="-30" to="${width}" dur="1.6s" repeatCount="indefinite"/></rect>`
442 : ''
443 const rim = hovered
444 ? `<rect x="0.6" y="0.6" width="${width - 1.2}" height="${height - 1.2}" rx="${(height - 1.2) / 2}" fill="none" stroke="${hexOf(LAVENDER)}" stroke-width="1.2"/>`
445 : ''
446 const ease = 'calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1"'
447 const grow = returning
448 ? `<animate attributeName="x" from="${width / 2 - 4}" to="0" dur="0.45s" fill="freeze" ${ease}/>` +
449 `<animate attributeName="width" from="8" to="${width}" dur="0.45s" fill="freeze" ${ease}/>`
450 : ''
451 const gleam = lit
452 ? `<rect x="-24" y="0" width="24" height="${height}" fill="url(#g${key})" opacity="0">` +
453 `<animate attributeName="x" from="-24" to="${width}" begin="0.35s" dur="0.6s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.6 0 0.4 1"/>` +
454 `<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.15;0.8;1" begin="0.35s" dur="0.6s" fill="freeze"/>` +
455 `</rect>`
456 : ''
457
458 return (
459 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height + padY * 2}" viewBox="0 ${-padY} ${width} ${height + padY * 2}" overflow="visible">` +
460 `<style>@keyframes tz{0%,100%{opacity:.4}50%{opacity:1}}.d{animation:tz 3.4s ease-in-out infinite}.e{animation:tz 2.6s ease-in-out 1.1s infinite}</style>` +
461 `<defs>` +
462 `<clipPath id="k${key}"><rect x="${returning ? width / 2 - 4 : 0}" y="0" width="${returning ? 8 : width}" height="${height}" rx="${height / 2}">${grow}</rect></clipPath>` +
463 (hovered
464 ? `<linearGradient id="h${key}" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.45"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`
465 : '') +
466 (lit
467 ? `<linearGradient id="g${key}" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.65" stop-color="#fff" stop-opacity="0.7"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`
468 : '') +
469 `<linearGradient id="p${key}" gradientUnits="userSpaceOnUse" x1="0" x2="${Math.max(fill, 1)}" y1="0" y2="0">` +
470 `<stop offset="0" stop-color="${hexOf(mix(quota.color, 0x1c1c22, 0.55))}"/><stop offset="1" stop-color="${hexOf(mix(quota.color, WHITE, 0.25))}"/>` +
471 `</linearGradient>` +
472 `<linearGradient id="f${key}" gradientUnits="userSpaceOnUse" x1="0" x2="${Math.max(fill, 1)}" y1="0" y2="0">` +
473 `<stop offset="0" stop-color="${color}" stop-opacity="0.18"/><stop offset="1" stop-color="${color}" stop-opacity="0.62"/>` +
474 `</linearGradient>` +
475 `<linearGradient id="w${key}" gradientUnits="userSpaceOnUse" x1="0" x2="${Math.max(fill, 1) + 14}" y1="0" y2="0">` +
476 `<stop offset="0" stop-color="${color}" stop-opacity="0.04"/><stop offset="${(fill / (fill + 14)).toFixed(3)}" stop-color="${color}" stop-opacity="0.3"/><stop offset="1" stop-color="${color}" stop-opacity="0"/>` +
477 `</linearGradient>` +
478 `</defs>` +
479 `<g clip-path="url(#k${key})">` +
480 `<rect x="0" y="0" width="${width}" height="${height}" fill="#8e8e96" fill-opacity="0.17"/>` +
481 `<rect x="0" y="0" width="${width}" height="${height}" fill="url(#w${key})"/>` +
482 (fill > 0 ? `<rect x="0" y="0" width="${fill}" height="${height}" rx="${height / 2}" fill="url(#f${key})"/>` : '') +
483 leds +
484 gleam +
485 glow +
486 `</g>` +
487 rim +
488 limitMarkOf(quota.limit, width, height, lit, quota.limitFrom ?? null) +
489 `</svg>`
490 )
491}
492
493/** Where a quota's limit sits on its capsule: a bright notch with a soft amber glow. */
494function limitMarkOf(limit: number | undefined, width: number, height: number, isLanding = false, from: number | null = null): string {
495 if (limit === undefined) {
496 return ''
497 }
498
499 const xOf = (percent: number) => Math.max(1.5, Math.min(width - 1.5, (percent / 100) * width))
500 const x = xOf(limit).toFixed(1)
501 const glow = Math.max(4, height * 0.42)
502 const core = Math.max(1.4, height * 0.16)
503 const shift = from === null ? 0 : xOf(from) - xOf(limit)
504 const lines =
505 `<line x1="${x}" x2="${x}" y1="0" y2="${height}" stroke="${hexOf(AMBER)}" stroke-opacity="0.45" stroke-width="${glow.toFixed(1)}" stroke-linecap="round"/>` +
506 `<line x1="${x}" x2="${x}" y1="0.5" y2="${height - 0.5}" stroke="#fff" stroke-width="${core.toFixed(1)}" stroke-linecap="round"/>`
507 // A moved notch slides over from where it stood.
508 const mark =
509 Math.abs(shift) > 0.5
510 ? `<g><animateTransform attributeName="transform" type="translate" from="${shift.toFixed(1)} 0" to="0 0" dur="0.42s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.22 1 0.36 1"/>${lines}</g>`
511 : lines
512
513 if (!isLanding) {
514 return mark
515 }
516
517 // Just validated: the notch drops in with a bounce, and a ring of amber light spreads from it.
518 return (
519 `<g opacity="0">` +
520 `<animateTransform attributeName="transform" type="translate" values="0 -12;0 1.5;0 -0.5;0 0" keyTimes="0;0.55;0.8;1" begin="0.45s" dur="0.5s" fill="freeze"/>` +
521 `<animate attributeName="opacity" values="0;1" begin="0.45s" dur="0.15s" fill="freeze"/>` +
522 mark +
523 `</g>` +
524 `<ellipse cx="${x}" cy="${height / 2}" rx="1" ry="1" fill="none" stroke="${hexOf(AMBER)}" stroke-width="1.4" opacity="0">` +
525 `<animate attributeName="rx" values="1;${Math.round(height * 1.6)}" begin="0.72s" dur="0.55s" fill="freeze"/>` +
526 `<animate attributeName="ry" values="1;${Math.round(height * 0.9)}" begin="0.72s" dur="0.55s" fill="freeze"/>` +
527 `<animate attributeName="opacity" values="0;0.95;0" keyTimes="0;0.15;1" begin="0.72s" dur="0.55s" fill="freeze"/>` +
528 `</ellipse>`
529 )
530}
531
532/**
533 * A dissolving bar's LEDs: each its own square, flying up and away at its
534 * own angle as it flares white and fades, the ones nearest the chip first.
535 */
536function flyingLedsOf(bar: SvgBar, key: string, head: number, peak: number, h: number, tone: Tone, chipCentre: number): string {
537 const seed = seedOf(bar.id)
538 const rows = Math.max(1, Math.floor((h - 4) / PITCH))
539 const top = Math.round((h - rows * PITCH + (PITCH - LED)) / 2)
540 const end = Math.min(head - 4, peak + 2)
541 let out = ''
542 let count = 0
543
544 for (let x = 3, column = 0; x + LED <= end && count < 900; x += PITCH, column += 1) {
545 const t = Math.min(1, x / Math.max(1, peak))
546
547 for (let row = 0; row < rows; row += 1) {
548 const n = column * 31 + row
549
550 if (noise(seed, n, 1) > (bar.isDone ? 0.55 + 0.4 * t : 0.38 + 0.6 * t ** 1.2)) {
551 continue
552 }
553
554 const dx = ((noise(seed, n, 4) - 0.25) * 30).toFixed(1)
555 const dy = (-(5 + noise(seed, n, 5) * 20) * (noise(seed, n, 6) < 0.18 ? -0.6 : 1)).toFixed(1)
556 const delay = ((1 - t) * 0.38 + noise(seed, n, 7) * 0.08).toFixed(2)
557 const color = hexOf(mix(tone.dim, tone.bright, 0.25 + 0.75 * t))
558
559 out += `<rect class="fl" x="${x}" y="${top + row * PITCH}" width="${LED}" height="${LED}" fill="${color}" style="--dx:${dx}px;--dy:${dy}px;animation-delay:${delay}s"/>`
560 count += 1
561 }
562 }
563
564 void chipCentre
565
566 return out
567}
568
569
570/** Where a bar's chip sits: its width, and the head (its right edge) for a share. */
571export function chipGeometryOf(bar: Pick<SvgBar, 'width' | 'label' | 'count'>): { chipW: number; headOf: (share: number) => number } {
572 const w = bar.width
573 const chipW = Math.round(textWidth(bar.label, 600) + COUNT_GAP + textWidth(bar.count, 500) + CHIP_PAD * 2)
574
575 return { chipW, headOf: (share: number) => Math.min(w - 2, Math.max(chipW + 2, 2 + share * (w - 4))) }
576}
577
578/** The chip's centre in a bar's SVG, in CSS pixels from its left edge. */
579export function chipCentreOf(bar: Pick<SvgBar, 'width' | 'label' | 'count' | 'share'>): number {
580 const { chipW, headOf } = chipGeometryOf(bar)
581
582 return Math.round(headOf(bar.share) - chipW / 2)
583}
584
585/** What the burst layer draws: how big, where the chip was, in which color. */
586export type Burst = {
587 width: number
588 height: number
589 /** The chip's centre, from the layer's left edge, in CSS pixels; the layer is centred on the bar. */
590 cx: number
591 /** The chip's width, which sets the burst's scale. */
592 chipW: number
593 isDone: boolean
594}
595
596/**
597 * The ✕'s burst, drawn on a layer of its own over the band, in front of
598 * everything and taller than the row so nothing cuts it: a radial flash
599 * where the chip pops, a sharp white shockwave and a wide colored one in
600 * its wake, both glowing, a star of spark trails streaking outward, and
601 * glitter drifting down as it all fades. Played once, as the layer mounts.
602 */
603export function burstLayerOf(burst: Burst): string {
604 const { width: w, height: h, cx } = burst
605 const cy = h / 2
606 const accent = hexOf(burst.isDone ? EMERALD : LAVENDER)
607 const bright = hexOf(burst.isDone ? 0xc6f7e2 : 0xe6e1ff)
608 const reach = Math.max(42, Math.min(h / 2 - 4, burst.chipW * 0.62))
609 const once = (attribute: string, values: string, begin: number, dur: number, keyTimes?: string) =>
610 `<animate attributeName="${attribute}" values="${values}" begin="${begin}s" dur="${dur}s" fill="freeze"` +
611 (keyTimes === undefined ? '' : ` keyTimes="${keyTimes}"`) +
612 ` calcMode="${keyTimes === undefined ? 'spline' : 'linear'}"` +
613 (keyTimes === undefined ? ` keyTimes="0;1" keySplines="0.16 1 0.3 1"` : '') +
614 `/>`
615 let trails = ''
616
617 for (let i = 0; i < 18; i += 1) {
618 const angle = (i / 18) * Math.PI * 2 + (noise(23, i, 1) - 0.5) * 0.3
619 const length = reach * (0.75 + noise(23, i, 2) * 0.55) * (1.35 - 0.35 * Math.abs(Math.sin(angle)))
620 const x2 = (cx + Math.cos(angle) * length).toFixed(1)
621 const y2 = (cy + Math.sin(angle) * length * 0.82).toFixed(1)
622 const dash = Math.round(length)
623 const color = i % 3 === 0 ? '#fff' : i % 3 === 1 ? bright : accent
624 const begin = (0.1 + noise(23, i, 3) * 0.08).toFixed(2)
625
626 trails +=
627 `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${(1.2 + noise(23, i, 4)).toFixed(1)}" stroke-linecap="round" ` +
628 `stroke-dasharray="${Math.round(dash * 0.32)} ${dash * 2}" stroke-dashoffset="${Math.round(dash * 0.32)}" opacity="0">` +
629 `<animate attributeName="stroke-dashoffset" values="${Math.round(dash * 0.32)};${-Math.round(dash * 0.7)}" begin="${begin}s" dur="0.62s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1"/>` +
630 `<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.12;1" begin="${begin}s" dur="0.62s" fill="freeze"/>` +
631 `</line>`
632 }
633
634 let glitter = ''
635
636 for (let i = 0; i < 14; i += 1) {
637 const angle = noise(29, i, 1) * Math.PI * 2
638 const r = reach * (0.35 + noise(29, i, 2) * 0.6)
639 const x = (cx + Math.cos(angle) * r).toFixed(1)
640 const y = cy + Math.sin(angle) * r * 0.6
641 const begin = (0.22 + noise(29, i, 3) * 0.2).toFixed(2)
642
643 glitter +=
644 `<circle cx="${x}" cy="${y.toFixed(1)}" r="${(0.9 + noise(29, i, 4) * 0.9).toFixed(1)}" fill="${i % 2 === 0 ? '#fff' : bright}" opacity="0">` +
645 `<animate attributeName="opacity" values="0;1;0.8;0" keyTimes="0;0.15;0.5;1" begin="${begin}s" dur="0.75s" fill="freeze"/>` +
646 `<animate attributeName="cy" values="${y.toFixed(1)};${(y + 10 + noise(29, i, 5) * 8).toFixed(1)}" begin="${begin}s" dur="0.75s" fill="freeze"/>` +
647 `</circle>`
648 }
649
650 return (
651 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
652 `<defs>` +
653 `<radialGradient id="bf"><stop offset="0" stop-color="#fff" stop-opacity="1"/><stop offset="0.35" stop-color="${bright}" stop-opacity="0.75"/>` +
654 `<stop offset="0.7" stop-color="${accent}" stop-opacity="0.25"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
655 `<filter id="bg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3"/></filter>` +
656 `<filter id="bs" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.2"/></filter>` +
657 `</defs>` +
658 // the flash where the chip pops
659 `<ellipse cx="${cx}" cy="${cy}" rx="4" ry="3" fill="url(#bf)" opacity="0">` +
660 once('rx', `4;${Math.round(burst.chipW * 0.7)}`, 0.06, 0.4) +
661 once('ry', `3;${Math.round(reach * 0.55)}`, 0.06, 0.4) +
662 `<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.2;1" begin="0.06s" dur="0.45s" fill="freeze"/>` +
663 `</ellipse>` +
664 // the wide colored shockwave, glowing
665 `<ellipse cx="${cx}" cy="${cy}" rx="6" ry="4" fill="none" stroke="${accent}" stroke-width="5" opacity="0" filter="url(#bg)">` +
666 once('rx', `6;${Math.round(reach * 1.25)}`, 0.12, 0.75) +
667 once('ry', `4;${Math.round(reach * 0.78)}`, 0.12, 0.75) +
668 `<animate attributeName="opacity" values="0;0.9;0" keyTimes="0;0.15;1" begin="0.12s" dur="0.75s" fill="freeze"/>` +
669 `<animate attributeName="stroke-width" values="5;1" begin="0.12s" dur="0.75s" fill="freeze"/>` +
670 `</ellipse>` +
671 // the sharp white shockwave ahead of it
672 `<ellipse cx="${cx}" cy="${cy}" rx="5" ry="3" fill="none" stroke="#fff" stroke-width="1.8" opacity="0" filter="url(#bs)">` +
673 once('rx', `5;${Math.round(reach * 1.05)}`, 0.08, 0.5) +
674 once('ry', `3;${Math.round(reach * 0.62)}`, 0.08, 0.5) +
675 `<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.1;1" begin="0.08s" dur="0.5s" fill="freeze"/>` +
676 `<animate attributeName="stroke-width" values="1.8;0.3" begin="0.08s" dur="0.5s" fill="freeze"/>` +
677 `</ellipse>` +
678 trails +
679 glitter +
680 `</svg>`
681 )
682}
683
684/** One plan as the folded list's rings draw it. */
685export type RingSegment = {
686 id: string
687 /** Its share, 0 to 1. */
688 share: number
689 isDone: boolean
690 /** Claude works on it now: a spark circles its ring. */
691 isLive: boolean
692 /** A message waiting its turn: a dashed indigo ring, slowly turning. */
693 isWaiting?: boolean
694}
695
696/** The rings' height, a ring's diameter and the pitch between two, in CSS pixels. */
697export const RINGS_PX = 24
698const RING_D = 20
699const RING_PITCH = 32
700/** The most rings drawn; older finished plans beyond them fold into a `+n`. */
701const RINGS_MAX = 12
702
703/**
704 * The folded list: one ring per plan, joined by a thread, as a stepper
705 * reads. A finished plan is an emerald disc with its check; a running one a
706 * lavender arc around a grey track, as far round as its share, its core
707 * breathing, and a spark circling it while Claude works on it. More plans
708 * than fit fold their oldest finished ones into `+n`. Just folded, the rings
709 * fade in one after the other.
710 */
711export function ringsOf(segments: readonly RingSegment[], isEntering: boolean): { source: string; width: number } {
712 let shown = [...segments]
713 let hidden = 0
714
715 while (shown.length > RINGS_MAX) {
716 const index = shown.findIndex(segment => segment.isDone)
717
718 shown.splice(index === -1 ? 0 : index, 1)
719 hidden += 1
720 }
721
722 const lead = hidden > 0 ? 26 : 0
723 const width = lead + 2 + RING_D + Math.max(0, shown.length - 1) * RING_PITCH + 2
724 const cy = RINGS_PX / 2
725 const r = RING_D / 2 - 2
726 const circumference = 2 * Math.PI * r
727 const emerald = hexOf(EMERALD)
728 const lavender = hexOf(LAVENDER)
729 let body = hidden > 0 ? `<text x="${lead / 2}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${FONT}" font-size="11" font-weight="600" fill="#9a9aa3">+${hidden}</text>` : ''
730
731 shown.forEach((segment, index) => {
732 const cx = lead + 2 + RING_D / 2 + index * RING_PITCH
733 const enter = isEntering
734 ? ` opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${(index * 0.05).toFixed(2)}s" dur="0.3s" fill="freeze"/>`
735 : '>'
736
737 if (index > 0) {
738 const done = shown[index - 1]?.isDone === true
739
740 body += `<line x1="${cx - RING_PITCH + RING_D / 2 + 3}" x2="${cx - RING_D / 2 - 3}" y1="${cy}" y2="${cy}" stroke="${done ? emerald : '#8e8e96'}" stroke-opacity="${done ? 0.55 : 0.3}" stroke-width="2" stroke-linecap="round"/>`
741 }
742
743 if (segment.isWaiting === true) {
744 body +=
745 `<g${enter}` +
746 `<g class="wt" style="transform-origin:${cx}px ${cy}px"><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${hexOf(INDIGO)}" stroke-width="2" stroke-dasharray="3.2 3.2" stroke-linecap="round"/></g>` +
747 `<circle class="br" cx="${cx}" cy="${cy}" r="2.4" fill="${hexOf(INDIGO)}"/>` +
748 `</g>`
749
750 return
751 }
752
753 if (segment.isDone) {
754 body +=
755 `<g${enter}` +
756 `<circle cx="${cx}" cy="${cy}" r="${r + 1.5}" fill="url(#rd)"/>` +
757 `<ellipse cx="${cx}" cy="${cy - r * 0.45}" rx="${r * 0.6}" ry="${r * 0.3}" fill="#fff" fill-opacity="0.22"/>` +
758 `<path d="M${cx - 4.2} ${cy + 0.2}l2.8 2.8 5.4-5.6" fill="none" stroke="#0b3b2a" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>` +
759 `</g>`
760
761 return
762 }
763
764 const arc = Math.max(0, Math.min(1, segment.share)) * circumference
765
766 body +=
767 `<g${enter}` +
768 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#8e8e96" stroke-opacity="0.28" stroke-width="2.6"/>` +
769 (arc > 0.5
770 ? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${lavender}" stroke-opacity="0.35" stroke-width="5" stroke-dasharray="${arc.toFixed(1)} ${circumference.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})" filter="url(#rg)"/>` +
771 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ra)" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="${arc.toFixed(1)} ${circumference.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/>`
772 : '') +
773 `<circle class="br" cx="${cx}" cy="${cy}" r="3" fill="${lavender}"/>` +
774 (segment.isLive
775 ? `<g class="or" style="transform-origin:${cx}px ${cy}px"><circle cx="${cx}" cy="${cy - r}" r="3.2" fill="${lavender}" fill-opacity="0.45" filter="url(#rg)"/><circle cx="${cx}" cy="${cy - r}" r="1.6" fill="#fff"/></g>`
776 : '') +
777 `</g>`
778 })
779
780 return {
781 width,
782 source:
783 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${RINGS_PX}" viewBox="0 0 ${width} ${RINGS_PX}">` +
784 `<style>@keyframes br{0%,100%{opacity:.45}50%{opacity:1}}.br{animation:br 2.4s ease-in-out infinite}` +
785 `@keyframes or{to{transform:rotate(360deg)}}.or{animation:or 2.4s linear infinite}.wt{animation:or 9s linear infinite}</style>` +
786 `<defs>` +
787 `<linearGradient id="rd" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${hexOf(mix(EMERALD, WHITE, 0.25))}"/><stop offset="1" stop-color="#149a6c"/></linearGradient>` +
788 `<linearGradient id="ra" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#e6e1ff"/><stop offset="1" stop-color="${lavender}"/></linearGradient>` +
789 `<filter id="rg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.6"/></filter>` +
790 `</defs>` +
791 body +
792 `</svg>`,
793 }
794}
795hooks/views/guard.tsx 459 lines1import type { BoxProps, ButtonProps, ClientProps, ElementConstructor, RasterProps, RenderElement, SvgProps, TextProps } from 'claude-code'
2
3import { LIMIT_MAX, LIMIT_MIN, LIMIT_START, LIMIT_STEP, type Limits, type Pause } from '../guard'
4import { alertWaveCells, limitDialCells } from '../meter'
5import { AMBER, AMBER_DIM, hexOf, INDIGO, LAVENDER, MUTED } from '../palette'
6import { encodeRows } from '../raster'
7import { quotaColorOf, remainingTextOf, resetTextOf, type Quota } from '../usage'
8import { alertRingOf, alertWaveOf, confirmMarkOf } from './svg-alert'
9import { quotaBarOf } from './svg-bar'
10
11type Box = ElementConstructor<BoxProps>
12type Text = ElementConstructor<TextProps>
13type Button = ElementConstructor<ButtonProps>
14
15/** What the band needs of the usage guard: the limits, the editor, the pause, and what the buttons do. */
16export type GuardView = {
17 limits: Limits
18 /** The window whose limit picker is open, or null. */
19 editing: string | null
20 /** The limit the picker shows (null while the window has none), and the one its bar slides from after a move. */
21 draft: number | null
22 draftFrom: number | null
23 /** The picker just took the quota row's place: its dial opens out. */
24 isOpening: boolean
25 /** The window whose ✓ was just pressed: the quota row comes back with it lit. */
26 justSet: string | null
27 /** The dial's drag region runs on this surface; where it does not, the track takes clicks instead. */
28 isDragReady: boolean
29 /** Sets the limit where the track was clicked, and slides the bar there. */
30 onPick: (limit: number) => void
31 pause: Pause | null
32 /** Opens a window's picker in the quota row's place, or leaves it unchanged when it is the open one. */
33 onEdit: (kind: string) => void
34 /** Moves the picker's bar by steps of 5%. */
35 onNudge: (delta: number) => void
36 /** The ✓: keeps the limit the picker shows and brings the quota row back. */
37 onConfirm: () => void
38 /** Clears the picker's limit; the ✓ then lifts it. */
39 onClear: () => void
40 onSave: () => void
41 onResume: () => void
42}
43
44/** The Raster key of the terminal alert's wave: what the frame loop repaints. */
45export const ALERT_WAVE_KEY = 'alert-wave'
46
47/** The words of the alert card for a pause, as of `now`. */
48export function alertWordsOf(pause: Pause, now: number): { title: string; subtitle: string } {
49 const reset = pause.resetsAt === undefined ? '' : resetTextOf({ kind: pause.kind, label: pause.label, remaining: 0, resetsAt: pause.resetsAt }, now)
50
51 if (pause.phase === 'waiting') {
52 return {
53 title: `Saved · waiting for the ${pause.label} reset`,
54 subtitle: reset === '' ? 'Resume whenever you are ready' : `Claude picks up on its own when the window ${reset}`,
55 }
56 }
57
58 return {
59 title: `Paused · ${pause.label} limit reached`,
60 subtitle: [remainingTextOf(pause.remaining), `your limit ${pause.limit}%`, reset].filter(Boolean).join(' · '),
61 }
62}
63
64/** The share of the wait already gone, for the waiting ring's arc. */
65function waitedOf(pause: Pause, now: number): number {
66 if (pause.resetsAt === undefined || pause.resetsAt <= pause.since) {
67 return 0
68 }
69
70 return Math.max(0, Math.min(1, (now - pause.since) / (pause.resetsAt - pause.since)))
71}
72
73/**
74 * The desktop alert: the animated emblem, the title and the facts, the
75 * travelling LED wave, and the two ways on (`Save & wait`, `Resume`), or
76 * `Resume now` while waiting for the reset.
77 */
78export function desktopAlertCard(
79 ui: { Box: Box; Text: Text; Button: Button; Svg: ElementConstructor<SvgProps> },
80 guard: GuardView,
81 pause: Pause,
82 now: number,
83 wavePx: number,
84): RenderElement {
85 const { Box, Text, Button, Svg } = ui
86 const { title, subtitle } = alertWordsOf(pause, now)
87 const color = hexOf(pause.phase === 'alert' ? AMBER : INDIGO)
88
89 return (
90 <Box key="alert" flexDirection="row" alignItems="center" gap={1} marginBottom={1}>
91 <Svg source={alertRingOf(pause.phase, 46, waitedOf(pause, now))} alt={title} width={46} height={46} />
92 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
93 <Text bold color={color}>
94 {title}
95 </Text>
96 <Text color={hexOf(MUTED)}>{subtitle}</Text>
97 <Svg source={alertWaveOf(pause.phase, wavePx, 9)} alt="Paused" width={wavePx} height={9} />
98 </Box>
99 {pause.phase === 'alert' ? (
100 <Box flexDirection="row" gap={1}>
101 <Button key="pause:save" variant="primary" hotkey="s" onPress={guard.onSave}>
102 {'Save & wait'}
103 </Button>
104 <Button key="pause:resume" hotkey="r" onPress={guard.onResume}>
105 Resume
106 </Button>
107 </Box>
108 ) : (
109 <Button key="pause:resume" variant="primary" hotkey="r" onPress={guard.onResume}>
110 Resume now
111 </Button>
112 )}
113 </Box>
114 )
115}
116
117/** The cells the terminal alert's wave shows at `now`. */
118export function alertWaveCellsOf(pause: Pause, width: number, now: number): string {
119 const color = pause.phase === 'alert' ? AMBER : INDIGO
120
121 return encodeRows([alertWaveCells(width, now, color, pause.phase === 'alert' ? AMBER_DIM : 0x2d3160)])
122}
123
124/** The terminal alert: a rounded amber (or indigo) frame, the title and facts, the travelling wave, the buttons. */
125export function terminalAlertCard(
126 ui: { Box: Box; Text: Text; Button: Button; Raster: ElementConstructor<RasterProps> },
127 guard: GuardView,
128 pause: Pause,
129 now: number,
130 waveCells: number,
131): RenderElement {
132 const { Box, Text, Button, Raster } = ui
133 const { title, subtitle } = alertWordsOf(pause, now)
134 const color = hexOf(pause.phase === 'alert' ? AMBER : INDIGO)
135
136 return (
137 <Box key="alert" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
138 <Box flexDirection="row" gap={1}>
139 <Text bold color={color}>
140 {pause.phase === 'alert' ? '⏸' : '◷'}
141 </Text>
142 <Text bold color={color}>
143 {title}
144 </Text>
145 <Text dimColor wrap="truncate-end">
146 {subtitle}
147 </Text>
148 </Box>
149 <Raster key={ALERT_WAVE_KEY} columns={waveCells} rows={1} cells={alertWaveCellsOf(pause, waveCells, now)} />
150 <Box flexDirection="row" gap={2}>
151 {pause.phase === 'alert' ? (
152 <Box flexDirection="row" gap={2}>
153 <Button key="pause:save" variant="primary" hotkey="s" onPress={guard.onSave}>
154 {'Save & wait'}
155 </Button>
156 <Button key="pause:resume" hotkey="r" onPress={guard.onResume}>
157 Resume
158 </Button>
159 </Box>
160 ) : (
161 <Button key="pause:resume" variant="primary" hotkey="r" onPress={guard.onResume}>
162 Resume now
163 </Button>
164 )}
165 </Box>
166 </Box>
167 )
168}
169
170/** The label that opens a window's limit editor: `5h`, lit under the pointer. */
171export function limitLabelButton(Button: Button, guard: GuardView, quota: Quota): RenderElement {
172 return (
173 <Button key={`limit:${quota.kind}`} plain hover={{ scope: `quota:${quota.kind}`, color: hexOf(LAVENDER) }} onPress={() => guard.onEdit(quota.kind)}>
174 {quota.label}
175 </Button>
176 )
177}
178
179/** The flag after a window's figures, `⚑ 20%`, while a limit is set; nothing otherwise. */
180export function limitFlagOf(ui: { Box: Box; Text: Text }, guard: GuardView, quota: Quota): RenderElement | null {
181 const { Box, Text } = ui
182 const limit = guard.limits[quota.kind]
183
184 return limit === undefined ? null : (
185 <Box key={`flag:${quota.kind}`} flexShrink={0}>
186 <Text color={hexOf(AMBER)}>{`⚑ ${limit}%`}</Text>
187 </Box>
188 )
189}
190
191/**
192 * The blank label of the button over a quota capsule, wider than the capsule,
193 * and the room it is given, so it is never cut with an ellipsis.
194 */
195const CAPSULE_LABEL = '\u2800'.repeat(14)
196const CAPSULE_ROOM = 24
197
198/**
199 * The invisible button over a quota capsule on the desktop: a click on the
200 * bar opens its limit picker, and the pointer on it lights the capsule and
201 * the window's label. The button is wider and taller than the bar and
202 * clipped to it, so the ground the desktop gives a button under the pointer
203 * covers the bar exactly, never a patch of it, nor past its edges.
204 */
205export function capsuleButtonOf(ui: { Box: Box; Button: Button }, guard: GuardView, quota: Quota): RenderElement {
206 const { Box, Button } = ui
207
208 return (
209 <Box
210 key={`quota-hit:${quota.kind}`}
211 position="absolute"
212 top={0}
213 left={0}
214 right={0}
215 bottom={0}
216 overflow="hidden"
217 flexDirection="row"
218 alignItems="center"
219 >
220 <Box width={CAPSULE_ROOM} flexShrink={0} flexDirection="row">
221 <Button key={`quota-open:${quota.kind}`} plain hover={{ scope: `quota:${quota.kind}`, color: hexOf(LAVENDER) }} onPress={() => guard.onEdit(quota.kind)}>
222 {CAPSULE_LABEL}
223 </Button>
224 </Box>
225 </Box>
226 )
227}
228
229/**
230 * The readout beside the dial: one place where the percent under the
231 * pointer shows, `→ 40%`; each value is its own hidden line, lit by the
232 * hover group of its click target on the track.
233 */
234function hoverReadoutOf(ui: { Box: Box; Text: Text }, quota: Quota): RenderElement {
235 const { Box, Text } = ui
236
237 return (
238 <Box key={`dial-readout:${quota.kind}`} position="relative" width={7} flexShrink={0}>
239 <Text> </Text>
240 {limitSteps().map(value => (
241 <Box
242 key={`dial-readout:${quota.kind}:${value}`}
243 position="absolute"
244 top={0}
245 left={0}
246 display="none"
247 hover={{ scope: stepScopeOf(quota.kind, value), display: 'flex' }}
248 >
249 <Text bold color={hexOf(AMBER)}>{`→ ${value}%`}</Text>
250 </Box>
251 ))}
252 </Box>
253 )
254}
255
256/** The desktop limit dial's width, in CSS pixels. */
257const DIAL_PX = 300
258
259/** A blank label, invisible but not whitespace, so no surface trims it away. */
260const BLANK_LABEL = '\u2800\u2800'
261
262/** The limits the track offers, one per click target: 5% to 90%. */
263function limitSteps(): number[] {
264 const steps: number[] = []
265
266 for (let value = LIMIT_MIN; value <= LIMIT_MAX; value += LIMIT_STEP) {
267 steps.push(value)
268 }
269
270 return steps
271}
272
273/** The hover group tying a click target on the track to its percent in the readout. */
274function stepScopeOf(kind: string, value: number): string {
275 return `dial:${kind}:${value}`
276}
277
278/**
279 * The dial's click layer: one invisible target per 5% laid across the
280 * track, each centred on its value (the spacers before and after keep the
281 * track's own proportions). Nothing it reveals sits on the track: the
282 * percent under the pointer shows in the readout beside the dial, so a fast
283 * sweep never leaves a trail of bubbles behind. A click sets the limit there
284 * and the bar slides to it.
285 */
286function clickLayerOf(ui: { Box: Box; Button: Button }, guard: GuardView, quota: Quota): RenderElement {
287 const { Box, Button } = ui
288
289 return (
290 <Box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="row" alignItems="stretch">
291 <Box key={`dial-margin:${quota.kind}:start`} width={0} flexGrow={LIMIT_MIN - LIMIT_STEP / 2} />
292 {limitSteps().map(value => (
293 <Box
294 key={`dial-slot:${quota.kind}:${value}`}
295 width={0}
296 flexGrow={LIMIT_STEP}
297 flexDirection="column"
298 justifyContent="flex-end"
299 alignItems="center"
300 >
301 <Button
302 key={`dial-step:${quota.kind}:${value}`}
303 plain
304 hover={{ scope: stepScopeOf(quota.kind, value), color: hexOf(AMBER) }}
305 onPress={() => guard.onPick(value)}
306 >
307 {BLANK_LABEL}
308 </Button>
309 </Box>
310 ))}
311 <Box key={`dial-margin:${quota.kind}:end`} width={0} flexGrow={100 - LIMIT_MAX - LIMIT_STEP / 2} />
312 </Box>
313 )
314}
315
316/** The props the dial's drag region gets: the range, the grid, the value, the track's margins. */
317function dragPropsOf(guard: GuardView, inset: number) {
318 return { min: LIMIT_MIN, max: LIMIT_MAX, step: LIMIT_STEP, value: guard.draft ?? LIMIT_START, inset }
319}
320
321/** The drag region over a dial: an absolute layer spanning it, where the pointer carries the knob. */
322function dragLayerOf(ui: { Box: Box; Client: ElementConstructor<ClientProps> }, guard: GuardView, quota: Quota, inset: number): RenderElement {
323 const { Box, Client } = ui
324
325 return (
326 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
327 <Client key={`dial-drag:${quota.kind}`} module="./dial-drag.tsx" props={dragPropsOf(guard, inset)} width="100%" height="100%" />
328 </Box>
329 )
330}
331
332/** The ✓ disc's size, in CSS pixels. */
333const CONFIRM_PX = 24
334/** The picker's capsule: the quota capsule drawn wide, and the air above and below it for the click layer. */
335const DIAL_TRACK_PX = 12
336const DIAL_PAD_PX = 3
337/** Cells on each side of the picker's capsule, the same both sides, so the capsule stands in the middle. */
338const DIAL_SIDE = 26
339
340/**
341 * A window's limit picker on the desktop, in the quota row's place: the
342 * window's capsule drawn wide and centred, the window on its left and, on
343 * its right, the limit it shows, the percent under the pointer, `Remove`
344 * while a limit shows, and the ✓ that keeps it and brings the two windows
345 * back. A click on the capsule puts the notch there; another moves it.
346 * Opening, the capsule grows out of its middle, a gleam runs along it and
347 * the notch drops in; the ✓ pops in after.
348 */
349export function desktopLimitPicker(
350 ui: { Box: Box; Text: Text; Button: Button; Svg: ElementConstructor<SvgProps> },
351 guard: GuardView,
352 quota: Quota,
353 marginTop: number,
354): RenderElement {
355 const { Box, Text, Button, Svg } = ui
356 const value = guard.draft === null ? 'no limit' : `${guard.draft}%`
357
358 return (
359 <Box key={`editor:${quota.kind}`} flexDirection="row" gap={1} alignItems="center" marginTop={marginTop}>
360 <Box width={DIAL_SIDE} flexShrink={0} flexDirection="row" justifyContent="flex-end">
361 <Text bold color={hexOf(AMBER)}>
362 {`⚑ ${quota.label}`}
363 </Text>
364 </Box>
365 <Box key={`dial:${quota.kind}`} position="relative" flexShrink={0}>
366 <Svg
367 source={quotaBarOf(
368 {
369 kind: quota.kind,
370 remaining: quota.remaining,
371 color: quotaColorOf(quota.remaining),
372 ...(guard.draft === null ? {} : { limit: guard.draft }),
373 limitFrom: guard.draftFrom,
374 isGrowing: guard.isOpening,
375 isLanding: guard.isOpening,
376 padY: DIAL_PAD_PX,
377 },
378 DIAL_PX,
379 DIAL_TRACK_PX,
380 )}
381 alt={`${quota.label}: ${remainingTextOf(quota.remaining)}, ${guard.draft === null ? 'no limit' : `pause at ${guard.draft}% left`}`}
382 width={DIAL_PX}
383 height={DIAL_TRACK_PX + DIAL_PAD_PX * 2}
384 />
385 {clickLayerOf(ui, guard, quota)}
386 </Box>
387 <Box width={DIAL_SIDE} flexShrink={0} flexDirection="row" gap={1} alignItems="center">
388 <Box key={`dial-value:${quota.kind}`} flexShrink={0}>
389 <Text bold color={hexOf(AMBER)}>
390 {value}
391 </Text>
392 </Box>
393 {hoverReadoutOf(ui, quota)}
394 {guard.draft === null ? null : (
395 <Button key={`limit-off:${quota.kind}`} plain dimColor onPress={guard.onClear}>
396 Remove
397 </Button>
398 )}
399 <Box key={`confirm:${quota.kind}`} position="relative" flexShrink={0}>
400 <Svg source={confirmMarkOf(CONFIRM_PX, guard.isOpening)} alt="Validate the limit" width={CONFIRM_PX} height={CONFIRM_PX} />
401 <Box position="absolute" top={0} left={0} right={0} bottom={0} justifyContent="center" alignItems="center">
402 <Button key={`limit-confirm:${quota.kind}`} plain autoFocus onPress={guard.onConfirm}>
403 {BLANK_LABEL}
404 </Button>
405 </Box>
406 </Box>
407 </Box>
408 </Box>
409 )
410}
411
412/** The terminal dial's cells. */
413const DIAL_CELLS = 32
414
415/**
416 * A window's limit picker on the terminal, in the quota row's place: `◀`,
417 * a cell dial with its amber zone and knob (the mouse drags it where the
418 * terminal reports one), `▶`, the value, `Remove`, and the ✓ that keeps it.
419 */
420export function terminalLimitPicker(
421 ui: { Box: Box; Text: Text; Button: Button; Raster: ElementConstructor<RasterProps>; Client: ElementConstructor<ClientProps> },
422 guard: GuardView,
423 quota: Quota,
424): RenderElement {
425 const { Box, Text, Button, Raster } = ui
426
427 return (
428 <Box key={`editor:${quota.kind}`} flexDirection="row" gap={1}>
429 <Text bold color={hexOf(AMBER)}>
430 {`⚑ ${quota.label}`}
431 </Text>
432 <Button key={`nudge:${quota.kind}:down`} plain hotkey="h" onPress={() => guard.onNudge(-1)}>
433 ◀
434 </Button>
435 <Box key={`dial-box:${quota.kind}`} position="relative" flexShrink={0}>
436 <Raster
437 key={`dial:${quota.kind}`}
438 columns={DIAL_CELLS}
439 rows={1}
440 cells={encodeRows([limitDialCells(quota.remaining, guard.draft ?? LIMIT_START, quotaColorOf(quota.remaining), DIAL_CELLS, quota.kind)])}
441 />
442 {dragLayerOf(ui, guard, quota, 1)}
443 </Box>
444 <Button key={`nudge:${quota.kind}:up`} plain hotkey="l" onPress={() => guard.onNudge(1)}>
445 ▶
446 </Button>
447 <Text color={hexOf(AMBER)}>{guard.draft === null ? 'no limit' : `pause at ${guard.draft}%`}</Text>
448 {guard.draft === null ? null : (
449 <Button key={`limit-off:${quota.kind}`} plain dimColor onPress={guard.onClear}>
450 Remove
451 </Button>
452 )}
453 <Button key={`limit-confirm:${quota.kind}`} variant="primary" hotkey="s" autoFocus onPress={guard.onConfirm}>
454 ✓
455 </Button>
456 </Box>
457 )
458}
459hooks/palette.ts 88 lines1/**
2 * The band's colors. Raster cells take `0x00RRGGBB` numbers; Text and Svg
3 * take the same colors as `#rrggbb` strings.
4 */
5
6/** A color as a Raster cell takes it: `0x00RRGGBB`. */
7export type Rgb = number
8
9/** The terminal's own color, as a Raster cell spells it. */
10export const DEFAULT_COLOR: Rgb = 0x01000000
11
12/** The lavender of a running plan: its chip, its dot, its brightest pixels. */
13export const LAVENDER: Rgb = 0xa99cf6
14/** The chip's ground on the terminal, a shade deeper so white text reads on it. */
15export const LAVENDER_DEEP: Rgb = 0x8473ea
16/** The faintest pixels of the dither, far from the head. */
17export const LAVENDER_DIM: Rgb = 0x6a6390
18
19/** The amber of a pause at a limit: the alert, and the plans it holds. */
20export const AMBER: Rgb = 0xf5a524
21export const AMBER_BRIGHT: Rgb = 0xffdc9a
22export const AMBER_DEEP: Rgb = 0xc77d0a
23export const AMBER_DIM: Rgb = 0x5c4520
24
25/** The indigo of a pause that waits for the window to reset. */
26export const INDIGO: Rgb = 0x818cf8
27export const INDIGO_BRIGHT: Rgb = 0xd4dafe
28
29/** The emerald of a finished plan. */
30export const EMERALD: Rgb = 0x34d399
31export const EMERALD_DEEP: Rgb = 0x149a6c
32export const EMERALD_DIM: Rgb = 0x2c6b56
33
34/** The track the bar runs in, on the terminal. */
35export const TRACK: Rgb = 0x2c2c33
36/** The percent, the ✕ and the other quiet text. */
37export const MUTED: Rgb = 0x9a9aa3
38
39/** The header's rule, from its bright end to its faded one. */
40export const RULE_FROM: Rgb = 0x7c6cf0
41export const RULE_TO: Rgb = 0x26263a
42
43export const WHITE: Rgb = 0xffffff
44/** What the ✕ turns to under the pointer. */
45export const DANGER: Rgb = 0xf87171
46
47/** `#rrggbb`, as Text and Svg take a color. */
48export function hexOf(color: Rgb): string {
49 return `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
50}
51
52/** The color `t` (0 to 1) of the way from `a` to `b`, channel by channel. */
53export function mix(a: Rgb, b: Rgb, t: number): Rgb {
54 const k = Math.max(0, Math.min(1, t))
55 const channel = (shift: number) => {
56 const from = (a >> shift) & 0xff
57 const to = (b >> shift) & 0xff
58
59 return Math.round(from + (to - from) * k) & 0xff
60 }
61
62 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
63}
64
65/** A stable pseudo-random number in [0, 1) for three integers: the dither's coin. */
66export function noise(a: number, b: number, c: number): number {
67 let h = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x632be59b, 0xc2b2ae35) ^ Math.imul(c + 0x27d4eb2f, 0x165667b1)
68
69 h ^= h >>> 15
70 h = Math.imul(h, 0x2c1b3c6d)
71 h ^= h >>> 12
72 h = Math.imul(h, 0x297a2d39)
73 h ^= h >>> 15
74
75 return (h >>> 0) / 4294967296
76}
77
78/** A small integer seed from a plan's id, so each bar has its own grain. */
79export function seedOf(id: string): number {
80 let h = 0x811c9dc5
81
82 for (const char of id) {
83 h = Math.imul(h ^ (char.codePointAt(0) ?? 0), 0x01000193)
84 }
85
86 return h >>> 0
87}
88hooks/raster.ts 116 lines1import { DEFAULT_COLOR, type Rgb } from './palette'
2
3/** One Raster cell: a width-1 glyph, its color and its ground. */
4export type Cell = {
5 glyph: string
6 fg: Rgb
7 bg: Rgb
8}
9
10/** A cell with the terminal's own ground. */
11export function cellOf(glyph: string, fg: Rgb, bg: Rgb = DEFAULT_COLOR): Cell {
12 return { glyph, fg, bg }
13}
14
15/** A blank cell: a space on the terminal's own ground. */
16export const BLANK: Cell = cellOf(' ', DEFAULT_COLOR)
17
18/** `text` as cells, one per character, all in one color and ground. */
19export function textCells(text: string, fg: Rgb, bg: Rgb = DEFAULT_COLOR): Cell[] {
20 return [...text].map(glyph => cellOf(glyph, fg, bg))
21}
22
23/** `cells` cut or padded with blanks to exactly `width`. */
24export function fitted(cells: readonly Cell[], width: number): Cell[] {
25 const out = cells.slice(0, width)
26
27 while (out.length < width) {
28 out.push(BLANK)
29 }
30
31 return out
32}
33
34const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
35
36/** Standard padded base64 of `bytes`. */
37export function base64Of(bytes: Uint8Array): string {
38 let out = ''
39
40 for (let i = 0; i < bytes.length; i += 3) {
41 const a = bytes[i] ?? 0
42 const b = bytes[i + 1] ?? 0
43 const c = bytes[i + 2] ?? 0
44 const n = (a << 16) | (b << 8) | c
45 const left = bytes.length - i
46
47 out += ALPHABET[(n >> 18) & 63]
48 out += ALPHABET[(n >> 12) & 63]
49 out += left > 1 ? ALPHABET[(n >> 6) & 63] : '='
50 out += left > 2 ? ALPHABET[n & 63] : '='
51 }
52
53 return out
54}
55
56/**
57 * The `cells` prop of a Raster for `rows` (row-major, each row as wide as
58 * the first): little-endian u32 triplets `[codePoint, fg, bg]`, base64.
59 */
60export function encodeRows(rows: readonly (readonly Cell[])[]): string {
61 const count = rows.reduce((sum, row) => sum + row.length, 0)
62 const view = new DataView(new ArrayBuffer(count * 12))
63 let offset = 0
64
65 for (const row of rows) {
66 for (const cell of row) {
67 view.setUint32(offset, cell.glyph.codePointAt(0) ?? 0x20, true)
68 view.setUint32(offset + 4, cell.fg >>> 0, true)
69 view.setUint32(offset + 8, cell.bg >>> 0, true)
70 offset += 12
71 }
72 }
73
74 return base64Of(new Uint8Array(view.buffer))
75}
76
77/** The text a Raster's `cells` draw, row by row: what a test reads back. */
78export function decodeText(cells: string, columns: number): string[] {
79 const binary = atobBytes(cells)
80 const view = new DataView(binary.buffer)
81 const rows: string[] = []
82 let row = ''
83
84 for (let offset = 0; offset + 12 <= binary.length; offset += 12) {
85 row += String.fromCodePoint(view.getUint32(offset, true))
86
87 if ([...row].length === columns) {
88 rows.push(row)
89 row = ''
90 }
91 }
92
93 return rows
94}
95
96function atobBytes(text: string): Uint8Array {
97 const clean = text.replace(/=+$/, '')
98 const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
99 let bits = 0
100 let value = 0
101 let index = 0
102
103 for (const char of clean) {
104 value = (value << 6) | ALPHABET.indexOf(char)
105 bits += 6
106
107 if (bits >= 8) {
108 bits -= 8
109 bytes[index] = (value >> bits) & 0xff
110 index += 1
111 }
112 }
113
114 return bytes
115}
116hooks/views/svg-alert.ts 154 lines1import { AMBER, AMBER_BRIGHT, AMBER_DIM, EMERALD, EMERALD_DEEP, hexOf, INDIGO, INDIGO_BRIGHT, mix, noise, WHITE, type Rgb } from '../palette'
2
3/** Which pause is drawn: at the limit, waiting for a choice; or saved, waiting for the reset. */
4export type AlertPhase = 'alert' | 'waiting'
5
6/** The two looks of the alert. */
7type Look = { color: Rgb; bright: Rgb; dim: Rgb }
8
9const LOOKS: Record<AlertPhase, Look> = {
10 alert: { color: AMBER, bright: AMBER_BRIGHT, dim: AMBER_DIM },
11 waiting: { color: INDIGO, bright: INDIGO_BRIGHT, dim: 0x2d3160 },
12}
13
14/**
15 * The alert's emblem: a breathing glow, three sonar rings rippling out, a
16 * comet arc circling a dark core, and in the core a pause sign (at the
17 * limit) or a clock whose hand sweeps (waiting for the reset, its arc
18 * then the share of the wait already gone).
19 */
20export function alertRingOf(phase: AlertPhase, size: number, waited: number): string {
21 const look = LOOKS[phase]
22 const c = size / 2
23 const color = hexOf(look.color)
24 const bright = hexOf(look.bright)
25 const track = size * 0.33
26 const circumference = 2 * Math.PI * track
27 const pulses = [0, 0.8, 1.6]
28 .map(
29 begin =>
30 `<circle cx="${c}" cy="${c}" r="${size * 0.2}" fill="none" stroke="${color}" stroke-width="1.5">` +
31 `<animate attributeName="r" values="${size * 0.2};${size * 0.49}" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>` +
32 `<animate attributeName="stroke-opacity" values="0.8;0" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>` +
33 `</circle>`,
34 )
35 .join('')
36 const arc =
37 phase === 'waiting'
38 ? `<circle cx="${c}" cy="${c}" r="${track}" fill="none" stroke="url(#ag)" stroke-width="2.6" stroke-linecap="round" ` +
39 `stroke-dasharray="${(circumference * Math.max(0.02, Math.min(1, waited))).toFixed(1)} ${circumference.toFixed(1)}" transform="rotate(-90 ${c} ${c})"/>`
40 : `<g class="sp"><circle cx="${c}" cy="${c}" r="${track}" fill="none" stroke="url(#ag)" stroke-width="2.6" stroke-linecap="round" ` +
41 `stroke-dasharray="${(circumference * 0.28).toFixed(1)} ${circumference.toFixed(1)}"/></g>`
42 const core =
43 phase === 'waiting'
44 ? `<line x1="${c}" y1="${c}" x2="${c}" y2="${c - size * 0.13}" stroke="#fff" stroke-width="2" stroke-linecap="round">` +
45 `<animateTransform attributeName="transform" type="rotate" from="0 ${c} ${c}" to="360 ${c} ${c}" dur="6s" repeatCount="indefinite"/></line>` +
46 `<line x1="${c}" y1="${c}" x2="${c + size * 0.09}" y2="${c}" stroke="#fff" stroke-opacity="0.75" stroke-width="2" stroke-linecap="round"/>` +
47 `<circle cx="${c}" cy="${c}" r="1.6" fill="#fff"/>`
48 : `<rect x="${c - size * 0.1}" y="${c - size * 0.12}" width="${size * 0.07}" height="${size * 0.24}" rx="${size * 0.03}" fill="#fff"/>` +
49 `<rect x="${c + size * 0.03}" y="${c - size * 0.12}" width="${size * 0.07}" height="${size * 0.24}" rx="${size * 0.03}" fill="#fff"/>`
50
51 return (
52 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
53 `<style>@keyframes sp{to{transform:rotate(360deg)}}.sp{transform-origin:${c}px ${c}px;animation:sp 2.6s linear infinite}` +
54 `@keyframes gl{0%,100%{opacity:.45}50%{opacity:1}}.gl{animation:gl 2.4s ease-in-out infinite}</style>` +
55 `<defs>` +
56 `<radialGradient id="ah"><stop offset="0" stop-color="${color}" stop-opacity="0.55"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></radialGradient>` +
57 `<linearGradient id="ag" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="${bright}"/><stop offset="1" stop-color="${color}"/></linearGradient>` +
58 `<radialGradient id="ac" cx="0.4" cy="0.35"><stop offset="0" stop-color="${hexOf(mix(look.dim, look.color, 0.45))}"/><stop offset="1" stop-color="${hexOf(look.dim)}"/></radialGradient>` +
59 `</defs>` +
60 `<circle class="gl" cx="${c}" cy="${c}" r="${c}" fill="url(#ah)"/>` +
61 pulses +
62 `<circle cx="${c}" cy="${c}" r="${track}" fill="none" stroke="#fff" stroke-opacity="0.12" stroke-width="2.6"/>` +
63 arc +
64 `<circle cx="${c}" cy="${c}" r="${size * 0.24}" fill="url(#ac)" stroke="#fff" stroke-opacity="0.2"/>` +
65 core +
66 `</svg>`
67 )
68}
69
70/**
71 * The alert's wave: a strip of LEDs where a crest of light travels left to
72 * right without end, each column a beat later than the one before, each
73 * LED its own brightness, over a faint wash.
74 */
75export function alertWaveOf(phase: AlertPhase, width: number, height: number): string {
76 const look = LOOKS[phase]
77 const led = 2
78 const pitch = 3
79 const rows = Math.max(1, Math.floor((height - 1) / pitch))
80 const top = Math.round((height - rows * pitch + (pitch - led)) / 2)
81 const period = phase === 'alert' ? 1.9 : 3.2
82 let columns = ''
83
84 for (let x = 1, column = 0; x + led <= width - 1; x += pitch, column += 1) {
85 let d = ''
86
87 for (let row = 0; row < rows; row += 1) {
88 if (noise(97, column * 13 + row, 1) < 0.78) {
89 d += `M${x} ${top + row * pitch}h${led}v${led}h-${led}z`
90 }
91 }
92
93 if (d === '') {
94 continue
95 }
96
97 const delay = (-(1 - x / width) * period * 2).toFixed(2)
98 const shade = hexOf(mix(look.dim, look.bright, 0.35 + 0.65 * noise(97, column, 2)))
99
100 columns += `<path d="${d}" fill="${shade}" class="w" style="animation-delay:${delay}s"/>`
101 }
102
103 return (
104 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
105 `<style>@keyframes wv{0%,100%{opacity:.12}50%{opacity:1}}.w{animation:wv ${period * 2}s ease-in-out infinite}</style>` +
106 `<defs><linearGradient id="aw" x1="0" x2="1"><stop offset="0" stop-color="${hexOf(look.color)}" stop-opacity="0"/>` +
107 `<stop offset="0.5" stop-color="${hexOf(look.color)}" stop-opacity="0.16"/><stop offset="1" stop-color="${hexOf(look.color)}" stop-opacity="0"/></linearGradient></defs>` +
108 `<rect x="0" y="0" width="${width}" height="${height}" rx="${height / 2}" fill="url(#aw)"/>` +
109 columns +
110 `</svg>`
111 )
112}
113
114/** An animation that plays once, `begin` seconds in, and holds its end. */
115function once(attribute: string, values: string, begin: number, dur: number, extra = ''): string {
116 return `<animate attributeName="${attribute}" values="${values}" begin="${begin.toFixed(2)}s" dur="${dur.toFixed(2)}s" fill="freeze"${extra}/>`
117}
118
119/**
120 * The picker's validate mark: an emerald disc with a soft breathing halo, a
121 * glass highlight and a white check. Opening, the disc pops in after the
122 * dial and the check draws itself stroke by stroke.
123 */
124export function confirmMarkOf(size: number, isOpening: boolean): string {
125 const c = size / 2
126 const r = size * 0.36
127 const check = `M${(c - r * 0.45).toFixed(1)} ${(c + r * 0.02).toFixed(1)} L${(c - r * 0.1).toFixed(1)} ${(c + r * 0.36).toFixed(1)} L${(c + r * 0.5).toFixed(1)} ${(c - r * 0.34).toFixed(1)}`
128 const length = (r * 1.45).toFixed(1)
129
130 return (
131 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
132 `<style>@keyframes hb{0%,100%{opacity:.35}50%{opacity:.8}}.hb{animation:hb 2.4s ease-in-out infinite}</style>` +
133 `<defs>` +
134 `<radialGradient id="ch"><stop offset="0" stop-color="${hexOf(EMERALD)}" stop-opacity="0.6"/><stop offset="1" stop-color="${hexOf(EMERALD)}" stop-opacity="0"/></radialGradient>` +
135 `<linearGradient id="cd" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${hexOf(mix(EMERALD, WHITE, 0.25))}"/><stop offset="1" stop-color="${hexOf(EMERALD_DEEP)}"/></linearGradient>` +
136 `</defs>` +
137 `<g transform="translate(${c} ${c})"><g${isOpening ? ' transform="scale(0)"' : ''}>` +
138 (isOpening
139 ? `<animateTransform attributeName="transform" type="scale" values="0;1.18;1" keyTimes="0;0.6;1" begin="0.62s" dur="0.42s" fill="freeze"/>`
140 : '') +
141 `<g transform="translate(${-c} ${-c})">` +
142 `<circle class="hb" cx="${c}" cy="${c}" r="${c}" fill="url(#ch)"/>` +
143 `<circle cx="${c}" cy="${c}" r="${r}" fill="url(#cd)"/>` +
144 `<ellipse cx="${c}" cy="${(c - r * 0.45).toFixed(1)}" rx="${(r * 0.62).toFixed(1)}" ry="${(r * 0.32).toFixed(1)}" fill="#fff" fill-opacity="0.22"/>` +
145 `<circle cx="${c}" cy="${c}" r="${(r - 0.5).toFixed(1)}" fill="none" stroke="#fff" stroke-opacity="0.3"/>` +
146 `<path d="${check}" fill="none" stroke="#fff" stroke-width="${(size * 0.09).toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"` +
147 (isOpening
148 ? ` stroke-dasharray="${length}" stroke-dashoffset="${length}">` + once('stroke-dashoffset', `${length};0`, 0.9, 0.32, ' calcMode="spline" keyTimes="0;1" keySplines="0.4 0 0.2 1"') + `</path>`
149 : '/>') +
150 `</g></g></g>` +
151 `</svg>`
152 )
153}
154