SLOPSHOPPER

Progress Band

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…

newbandguardcommandtoastprompt
v1.1.0MITupdated 2026-10-03Certicore/ClaudeMOD/progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /progress ⎿ progress: No plan in progress. Claude adds one by calling report_progress. ◷ 5h ▐⣻⢞⠿⡯⣩⠦⡿ ▌ 69% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ 5h ▐⣻⢞⠿⡯⣩⠦⡿ ▌ 69% ⟨Claude Code's own drawing⟩
README

progress

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.

Le bandeau dans l'app Desktop

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.

Le rendu

Dans l'app Desktop (Code tab), chaque barre est un SVG dessiné comme une image, sans fond propre, dans les deux thèmes :

  • La piste : une capsule légèrement bombée, un souffle de lumière en haut et une ombre en bas.
  • La matrice de LED : une grille régulière de pixels de 3 px. Ils sont rares et sombres au départ, denses et lumineux près de la tête, chacun avec sa propre luminosité. Une rampe de couleur va du violet sombre au lavande presque blanc. Ils sont regroupés en quelques path par niveau, pour que le document reste léger : 26 000 caractères au pire.
  • Le halo : une lueur continue, sans aucun bord net. Elle culmine au bord gauche de la pastille, passe dessous et s'estompe après elle.
  • La pastille : un dégradé lavande avec un liseré clair et une lueur floutée derrière elle. Elle affiche l'étape en cours, par exemple « Tests 3/5 » quand deux étapes sur cinq sont finies. Elle passe à « Done 4/4 » en émeraude à la fin.
  • Le mouvement : un nouveau rapport fait glisser la pastille avec un ralenti, et les pixels se dévoilent derrière elle. Les LED scintillent en permanence, plans terminés compris, et une lumière lente traverse la barre de temps en temps. Pendant que Claude travaille sur le plan, le scintillement s'accélère, la lumière passe plus souvent et la lueur de la pastille respire.

Barres Desktop, thème sombre Barres Desktop, thème clair

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

Les limites de 5 h et de 7 jours

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.

Les quotas sous les plans

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.

Une limite qui met le travail en pause

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.

Le sélecteur de limite

Quand une fenêtre descend à sa limite, le mod :

  • interrompt le tour en cours avec $.turn.abort ;
  • refuse tout appel d'outil, sauf report_progress, avec un message qui dit à Claude de s'arrêter ;
  • passe les barres des plans en ambre, marquées « Paused » ;
  • joue 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.

L'alerte

La carte propose deux suites :

  • Save & wait : Claude écrit un point d'étape en trois lignes, sans outils. La carte passe en indigo, avec une horloge et un arc qui suit l'attente. Vingt secondes après la remise à zéro de la fenêtre, le mod relance Claude depuis ce point d'étape. L'attente survit à un redémarrage.
  • Resume : le travail reprend tout de suite, et Claude est relancé si un tour avait été interrompu. Cette fenêtre ne se redéclenche plus avant sa remise à zéro.

Écrire soi-même un message à Claude pendant la pause compte aussi comme une reprise.

Un bandeau par dossier

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.

Le bandeau dans le terminal

Capturé dans une vraie session claude --plugin-dir ./progress (Claude Code 2.1.287), Claude appelant report_progress lui-même.

Replier la liste

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.

La ligne repliée en anneaux

Les messages en attente

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.

Des messages en attente sous un plan en cours

Le ✕ qui dissout

Le ✕ 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.

Ce que ça fait

HookCe qu'il fait
session.startEnregistre l'outil report_progress ($.tool.register) et la commande /progress ($.command.register, immediate), puis charge les plans depuis $.store.
tool.describe sur l'outilLe 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_progressLit 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 AbovePromptDessine 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.startRelit le store, pour afficher les plans signalés par une autre session du même dossier, et lance le spinner.
session.measureMet à jour les fenêtres de 5 h et de 7 jours quand le moteur les mesure.
turn.completeArrête le spinner et le reflet à la fin du tour principal, et efface les messages en attente de cette conversation.
prompt.submitAjoute 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.endEfface 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.composeAjoute 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.

L'outil report_progress

ChampTypeRôle
planstring, requisLe nom du plan, étiquette de la ligne ; le même nom met la ligne à jour.
stepinteger ≥ 0Étapes terminées.
totalinteger ≥ 1Nombre d'étapes ; requis à la première mention d'un plan.
notestringL'étape qui commence, en un à trois mots (« Tests », « Deploy ») : le texte de la pastille.
donebooleantrue quand le plan est fini : step devient total.
removebooleantrue pour retirer la ligne.
queuedbooleantrue 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.

Options

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.

Fichiers

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

Installer

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

Vérifier

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.

Limites

  • Un bandeau par session. Le mod garde ce que les mods en dessous y dessinent, mais un mod placé avant lui dans la chaîne peut le remplacer. Un sondage de Claude Code prend la place du bandeau tant qu'il est affiché.
  • Un même dossier partage son bandeau : deux conversations dans un même projet voient les mêmes plans, et ✕ dans l'une retire le plan dans l'autre.
  • Le terminal dépend de sa police pour les points braille : ils sont plus fins dans certaines polices.
  • Le son passe par le lecteur de la plateforme (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.
Source 13 files
hooks/register.ts 1279 lines
1import 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    return
hooks/guard.ts 171 lines
1import 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.'
171
hooks/meter.ts 385 lines
1import { 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
385
hooks/usage.ts 108 lines
1import 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}
108
hooks/motion.ts 85 lines
1/**
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}
85
hooks/plans.ts 307 lines
1/**
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}
307
hooks/views/band.tsx 721 lines
1import 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}
721
hooks/views/svg-bar.ts 795 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
795
hooks/views/guard.tsx 459 lines
1import 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}
459
hooks/palette.ts 88 lines
1/**
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}
88
hooks/raster.ts 116 lines
1import { 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}
116
hooks/views/svg-alert.ts 154 lines
1import { 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