A Liquid Glass Control Center for Claude Code: context, cache, usage limits and agents at a glance, with one-tap Compact, Handoff and Keep warm.

A Liquid Glass control centre for the Claude Code desktop app, in the spirit of the one on iPhone and Mac. It shows how full the chat is, how warm the cache is and how much of your plan is left, and gives you round buttons for the things you reach for most.



HANDOFF.md with next steps so you can start a fresh chat.To open or close the Control Center, press the ⚌ button at the right of the chat box's footer; it turns into ✕ while the Control Center is open. You can also type /cockpit. It opens by itself when a chat starts unless you turn that off.
In a terminal session the same readings show as text in a band above the prompt.
At the prompt of a terminal session:
/plugin install cockpit --marketplace ilyasalmd/controlcentre_id
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install cockpit --marketplace ilyasalmd/controlcentre_id
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ilyasalmd/controlcentre_id, then Add plugin.
Cockpit is a mod: it uses Claude Code's mod system to draw its pane, so it needs a Claude Code version with mods. An organisation's settings can stop mods installed by users from running.
| Command | What it does | | :- | :- | | /cockpit | Opens or closes the Control Center | | /cockpit color <name\|#hex\|none> | Tints the glass | | /cockpit opacity <0-100\|clear\|tinted> | Sets how see-through the glass is | | /cockpit keepwarm on\|off | Turns keep-warm on or off | | /cockpit pin <command> | Pins a command in the terminal launcher |
Set these when you enable the plugin, or later in its options.
| Setting | Default | What it does | | :- | :- | :- | | compactAt | 0 | Where Claude compacts the chat by itself. 0 reads your autoCompactWindow setting, or 400,000 tokens. | | cacheLife | auto | auto is 1 hour on a subscription and 5 minutes on an API key. | | density | standard | How much the terminal band shows: calm, standard or full. | | keepWarm | off | Starts each session with keep-warm on. Each ping is a small model request. | | keepWarmHours | 3 | How long keep-warm runs once turned on. | | guardTokens | 100000 | Holds the first message after the cache goes cold, once, when the chat is at least this big, so you can compact first. Send it again to go ahead. 0 turns this off. | | openAtStart | on | Opens the Control Center beside each new chat. |
Cockpit only acts when you press one of its buttons or turn a setting on. These are the only things it does for you:
/compact with this focus: "Keep: the current goal, decisions made and why, key file paths, what is done, what is left, and the exact next step."git status and the last few commits, then write HANDOFF.md in the project root without committing it. The quick handoff asks for a short note in the chat and writes no files.keepWarmHours, or as soon as you turn it off.guardTokens to 0 to turn this off.Its hooks watch the session so the tiles stay current, and they pass every event on unchanged, with the guard above as the one exception:
It never makes a permission decision.
The tests/ folder runs only under claude plugin test. There it plays the engine's part, which includes starting a pretend agent and calling a pretend tool. None of it ships into a session.
It makes no network requests of its own, reads no credentials, and sends no telemetry. The word "token" in its code always means a count of the model's tokens. For example, fmtTokens turns 212000 into "212k". Your glass colour, opacity and launcher pins are stored on your machine in the plugin's own storage. The only web address in the code is the standard SVG namespace (http://www.w3.org/2000/svg), which every SVG file names.
Where an organization manages Claude Code, Claude Code doesn't pass the session-start and compaction events to plugins you install yourself. In that setup the Context glass doesn't drain while Claude compacts, and the cache clock doesn't reset on /clear or carry over on resume. Everything else works as usual.
npx tsc -p .
claude plugin validate .
claude plugin test .
The type-check needs the API types, which Claude Code writes into .claude-plugin/types/ the first time it loads the mod. To run your working copy, start Claude Code with --plugin-dir <this folder>.
MIT. See LICENSE.
hooks/register.tsx 770 lines1// Cockpit: the wiring and the state. Each event hands its facts to the functions here, and the band and the pane
2// redraw from the atoms. Every call on `$` lives in this file, beside the hooks that make it; the drawings (band,
3// pane, glass) take a set of plain actions instead. Redraws are event-driven (session.measure, turns, tools);
4// `pace` runs a timer only while a countdown shows.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
8
9import type { AgentCard, Launcher, Look, Notice, Pressed, Sample, Squeeze, Tone, TurnInfo, Usage, Warmth } from '../types'
10import { DEFAULT_LOOK, GLASS_COLORS, parseColor, parseOpacity } from './art/glass'
11import { drawBand } from './band'
12import { drawLaunch, padSpots, presses } from './glass'
13import {
14 FIVE_HOURS, MINUTE, WEEK,
15 actNeeded, cacheTtl, cacheView, chatView, doingOf, fmtDuration, fmtTokens, guardDecision, holdLimit, isCold, isWarmPing, paceView, pingDelay,
16 pushRecent, readSettings, startFreshReason, toUsage, togglePin,
17} from './model'
18import type { Settings } from './model'
19import { drawPane } from './pane'
20import { DRAIN_MS, PANE, PANE_OPEN, SETTLE_MS, drainLevel } from './view'
21import type { Actions, View } from './view'
22
23const NOTICE_MS = 20_000
24
25const EMPTY_USAGE: Usage = { tokens: 0, window: 0, five: null, week: null, costUsd: null, isPlan: false }
26const IDLE: TurnInfo = { startedAt: 0, tools: 0, isWorking: false, lastSeconds: null }
27const COLD_START: Warmth = { lastRequestAt: 0, isCompacted: false, keepWarmUntil: 0, lastPing: null, stopped: null, ackedAt: 0 }
28
29const usage = atom({ plugin: 'cockpit', key: 'usage' } as const, EMPTY_USAGE)
30const turn = atom({ plugin: 'cockpit', key: 'turn' } as const, IDLE)
31const agents = atom({ plugin: 'cockpit', key: 'agents' } as const, [] as AgentCard[])
32const warmth = atom({ plugin: 'cockpit', key: 'warmth' } as const, COLD_START)
33const notice = atom({ plugin: 'cockpit', key: 'notice' } as const, null as Notice | null)
34const samples = atom({ plugin: 'cockpit', key: 'samples' } as const, [] as Sample[])
35const turnTokens = atom({ plugin: 'cockpit', key: 'turnTokens' } as const, [] as number[])
36const filter = atom({ plugin: 'cockpit', key: 'filter' } as const, '')
37const expanded = atom({ plugin: 'cockpit', key: 'expanded' } as const, null as string | null)
38const launcher = atom({ plugin: 'cockpit', key: 'launcher' } as const, { pinned: [], recent: [] } as Launcher)
39const tick = atom({ plugin: 'cockpit', key: 'tick' } as const, 0)
40const pressed = atom({ plugin: 'cockpit', key: 'pressed' } as const, null as Pressed | null)
41const look = atom({ plugin: 'cockpit', key: 'look' } as const, DEFAULT_LOOK as Look)
42const customizing = atom({ plugin: 'cockpit', key: 'customizing' } as const, false)
43const squeeze = atom({ plugin: 'cockpit', key: 'squeeze' } as const, null as Squeeze | null)
44
45/**
46 * Set once per load: the person's options and the compact point from their settings. `isPlan` decides the
47 * auto cache life; it is remembered across sessions and assumed until a reply shows no plan limits.
48 */
49const config: { settings: Settings; isPlan: boolean } = { settings: readSettings({}, undefined), isPlan: true }
50
51async function loadPlan($: EngineInterface) {
52 const saved = await $.store.get('isPlan')
53 if (typeof saved === 'boolean') config.isPlan = saved
54}
55
56async function learnPlan($: EngineInterface, isPlan: boolean) {
57 if (config.isPlan === isPlan) return
58 config.isPlan = isPlan
59 await $.store.set('isPlan', isPlan)
60}
61
62const ttlNow = () => cacheTtl(config.settings, config.isPlan)
63
64// ── The snapshot every drawing reads ────────────────────────────────────────
65
66async function snapshot($: EngineInterface): Promise<View> {
67 await read($, tick)
68 const now = await $.clock.now()
69 const u = await read($, usage)
70 const w = await read($, warmth)
71 const n = await read($, notice)
72 const settings = config.settings
73 const ttl = ttlNow()
74 const chat = chatView(u.tokens, settings.compactAt, u.window)
75 const cache = cacheView(w.lastRequestAt, w.isCompacted, ttl, now, u.tokens, settings.guardTokens)
76 const five = paceView(u.five, FIVE_HOURS, now)
77 const week = paceView(u.week, WEEK, now)
78 const tones = [chat.tone, cache.tone, five?.tone ?? 'fine']
79 return {
80 now, usage: u, settings, ttl, chat, cache, five, week,
81 turn: await read($, turn),
82 agents: await read($, agents),
83 warmth: w,
84 notice: n && now - n.at < NOTICE_MS ? n : null,
85 act: actNeeded(chat, cache, five, u.tokens),
86 freshReason: startFreshReason(chat, cache),
87 isKeepingWarm: w.keepWarmUntil > now,
88 pressed: await read($, pressed),
89 look: await read($, look),
90 isCustomizing: await read($, customizing),
91 squeeze: await read($, squeeze),
92 tone: tones.includes('act') ? 'act' : tones.includes('watch') ? 'watch' : 'fine',
93 }
94}
95
96// ── The Control Center pane ─────────────────────────────────────────────────
97
98/** Asks every drawing to redraw, so the band notices the pane opening or closing. */
99async function redraw($: EngineInterface) {
100 await update($, tick, n => n + 1)
101}
102
103async function isPaneOpen($: EngineInterface) {
104 const panes = await $.ui.panes()
105 return panes.some(p => p.id === PANE)
106}
107
108/** Whether the pane is open, for a drawing: it reads the redraw counter, so it is drawn again when that changes. */
109async function isPaneShown($: EngineInterface) {
110 await read($, tick)
111 return isPaneOpen($)
112}
113
114async function openPane($: EngineInterface) {
115 await $.ui.open(PANE_OPEN)
116 await redraw($)
117}
118
119/** Opens the Control Center, or closes it when it is open. */
120async function togglePane($: EngineInterface) {
121 if (await isPaneOpen($)) await $.ui.close({ id: PANE })
122 else await openPane($)
123 await redraw($)
124}
125
126// ── Readings ────────────────────────────────────────────────────────────────
127
128/** Keeps the latest usage figures and a sample for the sparkline. */
129async function measure($: EngineInterface, raw: Parameters<typeof toUsage>[0]) {
130 const fresh = toUsage(raw)
131 const at = await $.clock.now()
132 const prev = await read($, usage)
133 // Limits seen earlier in this chat still mean a plan, so a reading without them (or a reload) keeps the 1-hour cache.
134 if (fresh.isPlan || prev.five !== null || prev.week !== null) await learnPlan($, true)
135 const u = { ...fresh, five: holdLimit(prev.five, fresh.five, at), week: holdLimit(prev.week, fresh.week, at) }
136 await update($, usage, () => u)
137 await update($, samples, list => [...list, { at, five: u.five?.percentUsed ?? null, tokens: u.tokens }].slice(-48))
138}
139
140// ── What the events change ──────────────────────────────────────────────────
141
142async function loadLauncher($: EngineInterface, saved: Launcher) {
143 await update($, launcher, () => saved)
144}
145
146const isLook = (x: unknown): x is Look =>
147 typeof x === 'object' && x !== null && typeof (x as Look).opacity === 'number' && ((x as Look).color === null || typeof (x as Look).color === 'string')
148
149/** The look chosen in an earlier chat, kept across sessions like the launcher. */
150async function loadLook($: EngineInterface) {
151 const saved = await $.store.get('look')
152 if (isLook(saved)) await update($, look, () => saved)
153}
154
155/** Changes the glass's colour or opacity, here and in every chat after. */
156async function changeLook($: EngineInterface, change: Partial<Look>) {
157 const saved = await update($, look, l => ({ ...l, ...change }))
158 await $.store.set('look', saved)
159 return saved
160}
161
162/** Opens or closes the Appearance panel under the Control Center's grid. */
163async function toggleCustomize($: EngineInterface) {
164 await update($, customizing, x => !x)
165}
166
167async function isKeepWarmOn($: EngineInterface) {
168 return (await read($, warmth)).keepWarmUntil > 0
169}
170
171/** /clear starts a new conversation: nothing measured before it still applies. */
172async function resetForClear($: EngineInterface) {
173 generation++
174 await update($, warmth, w => ({ ...w, lastRequestAt: 0, isCompacted: false, ackedAt: 0 }))
175 await update($, turnTokens, () => [])
176 await update($, agents, () => [])
177}
178
179/** A resumed chat brings its cache clock with it. */
180async function resumeCacheClock($: EngineInterface, secondsSince: number) {
181 generation++
182 const now = await $.clock.now()
183 await update($, warmth, w => ({ ...w, lastRequestAt: now - secondsSince * 1000, isCompacted: false }))
184}
185
186async function startTurn($: EngineInterface) {
187 const startedAt = await $.clock.now()
188 await update($, turn, () => ({ startedAt, tools: 0, isWorking: true, lastSeconds: null }))
189}
190
191async function countTool($: EngineInterface) {
192 await update($, turn, t => ({ ...t, tools: t.tools + 1 }))
193}
194
195/** A main-thread model request just went out: the cache clock restarts. */
196async function stampRequest($: EngineInterface) {
197 const at = await $.clock.now()
198 await update($, warmth, w => ({ ...w, lastRequestAt: at, isCompacted: false }))
199}
200
201/** A compaction began: the Context glass starts draining from where it stands. */
202async function startSqueeze($: EngineInterface) {
203 const at = await $.clock.now()
204 const u = await read($, usage)
205 const fromPct = chatView(u.tokens, config.settings.compactAt, u.window).pct
206 await update($, squeeze, () => ({ startedAt: at, fromPct, doneAt: null, doneLevel: null }))
207}
208
209/** The compaction ended: settle on the new reading, or, when it did nothing, put the glass back. */
210async function endSqueeze($: EngineInterface, didCompact: boolean) {
211 const at = await $.clock.now()
212 await update($, squeeze, s => (s && didCompact ? { ...s, doneAt: at, doneLevel: drainLevel(s.fromPct, at - s.startedAt) } : null))
213}
214
215async function isSqueezing($: EngineInterface) {
216 const s = await read($, squeeze)
217 return s !== null && s.doneAt === null
218}
219
220async function agentStep($: EngineInterface, id: string, model: string, spent: number, effort?: string | number) {
221 await update($, agents, list =>
222 list.map(a => (a.id === id ? { ...a, model, steps: a.steps + 1, tokens: a.tokens + spent, effort: effort ?? a.effort ?? null } : a)),
223 )
224}
225
226/** What a subagent is doing now, from the tool it just called. */
227async function agentTool($: EngineInterface, id: string, tool: string, input: Readonly<Record<string, unknown>>) {
228 const doing = doingOf(tool, input)
229 await update($, agents, list => (list.some(a => a.id === id) ? list.map(a => (a.id === id ? { ...a, tool, doing } : a)) : list))
230}
231
232async function agentDone($: EngineInterface, id: string, isAnswered: boolean) {
233 const now = await $.clock.now()
234 await update($, agents, list =>
235 list.map(a => (a.id === id ? { ...a, status: isAnswered ? 'done' : 'failed', endedAt: now } : a)),
236 )
237}
238
239async function agentStarted($: EngineInterface, card: Omit<AgentCard, 'startedAt'>) {
240 const startedAt = await $.clock.now()
241 await update($, agents, list => [...list.filter(a => a.id !== card.id), { ...card, startedAt }].slice(-20))
242}
243
244/** The main turn ended: the timer stops, the cache clock settles, the forecast gets a reading. */
245async function finishTurn($: EngineInterface, ended: { durationMs: number; isAborted: boolean; hasUsage: boolean }) {
246 const now = await $.clock.now()
247 const t = await read($, turn)
248 const took = t.startedAt > 0 ? Math.round((now - t.startedAt) / 1000) : null
249 await update($, turn, x => ({ ...x, isWorking: false, lastSeconds: took }))
250 // Only a turn the API answered touched the cache; an aborted or failed one (no usage) did not.
251 await update($, warmth, w => ({
252 ...w,
253 lastRequestAt: ended.hasUsage && now - w.lastRequestAt > ended.durationMs ? now : w.lastRequestAt,
254 isCompacted: false,
255 ackedAt: 0,
256 }))
257 const raw = await $.session.usage()
258 const after = toUsage(raw)
259 const tokens = after.tokens
260 // A real reply that reported no plan limits, in a chat that never showed any, means an API key: the cache lives 5 minutes.
261 const held = await read($, usage)
262 if (ended.hasUsage && !after.isPlan && held.five === null && held.week === null) await learnPlan($, false)
263 if (tokens > 0) await update($, turnTokens, list => [...list, tokens].slice(-16))
264 if (took !== null && took >= 60) $.ui.toast(`Done in ${fmtDuration(took * 1000)} · ${t.tools} tool call${t.tools === 1 ? '' : 's'}`)
265}
266
267/** The chat was compacted: the context reading drops at once, to core's count when it gives one. */
268async function markCompacted($: EngineInterface, tokensAfter?: number) {
269 generation++
270 await update($, warmth, w => ({ ...w, isCompacted: true, ackedAt: 0 }))
271 await update($, turnTokens, () => [])
272 await measure($, await $.session.usage())
273 if (tokensAfter !== undefined) await update($, usage, u => ({ ...u, tokens: tokensAfter }))
274}
275
276/** The cold-send guard's verdict on one prompt, and the words for it. */
277async function judgePrompt($: EngineInterface, originKind: string, text: string) {
278 const w = await read($, warmth)
279 const u = await read($, usage)
280 const now = await $.clock.now()
281 const ttl = ttlNow()
282 const decision = guardDecision({
283 isCold: isCold(w.lastRequestAt, w.isCompacted, ttl, now),
284 tokens: u.tokens,
285 guardTokens: config.settings.guardTokens,
286 originKind,
287 text,
288 ackedAt: w.ackedAt,
289 lastRequestAt: w.lastRequestAt,
290 })
291 // Held once: the same message sent again goes through.
292 if (decision === 'drop') await update($, warmth, x => ({ ...x, ackedAt: x.lastRequestAt }))
293 const why = `the cache went cold ${fmtDuration(now - w.lastRequestAt - ttl)} ago and this chat is ${fmtTokens(u.tokens)}, so this message re-reads all of it`
294 return { decision, why }
295}
296
297async function toggleExpanded($: EngineInterface, id: string) {
298 await update($, expanded, cur => (cur === id ? null : id))
299}
300
301async function setFilter($: EngineInterface, text: string) {
302 await update($, filter, () => text)
303}
304
305/** What the pane draws beyond the shared snapshot. */
306async function paneExtras($: EngineInterface) {
307 return {
308 turnTokens: await read($, turnTokens),
309 samples: await read($, samples),
310 query: await read($, filter),
311 launcher: await read($, launcher),
312 open: await read($, expanded),
313 }
314}
315
316async function readTurnTokens($: EngineInterface) {
317 return read($, turnTokens)
318}
319
320// ── Notices ─────────────────────────────────────────────────────────────────
321
322async function say($: EngineInterface, text: string, tone: Tone) {
323 const at = await $.clock.now()
324 await update($, notice, () => ({ text, tone, at }))
325 // The desktop Control Center draws no text lines: there the notice is the app's own toast.
326 try {
327 const surfaces = await $.session.surfaces()
328 if (surfaces.some(x => x !== 'terminal')) $.ui.toast(text)
329 } catch {}
330 $.clock.after(NOTICE_MS, () => {
331 void update($, notice, n => (n && n.at === at ? null : n))
332 })
333}
334
335const errText = (err: unknown) => String(err instanceof Error ? err.message : err).slice(0, 160)
336
337// ── Actions ─────────────────────────────────────────────────────────────────
338
339/** A second press this soon after the first is the same press: a double click acts once. */
340const PRESS_SETTLE_MS = 800
341
342/**
343 * Runs a Control Center action once per press: lights its tile at once, ignores the same key again while it
344 * runs or within the settle time, and marks it done when its work resolves.
345 */
346async function pressOnce($: EngineInterface, key: string, work: () => Promise<unknown>) {
347 const now = await $.clock.now()
348 const last = await read($, pressed)
349 if (last && last.key === key && (last.isBusy || now - last.at < PRESS_SETTLE_MS)) return
350 await update($, pressed, () => ({ key, at: now, isBusy: true }))
351 try {
352 await work()
353 } finally {
354 await update($, pressed, p => (p && p.key === key && p.at === now ? { ...p, isBusy: false } : p))
355 }
356}
357
358const KEEP =
359 'Keep: the current goal, decisions made and why, key file paths, what is done, what is left, and the exact next step.'
360
361const QUICK_HANDOFF =
362 'Write a short handoff note right here in the chat, no files: the goal, where things stand, and the exact next step, as one block I can paste as the first message of a fresh chat. Keep it under 12 lines.'
363
364const FULL_HANDOFF =
365 'Prepare a full handoff. First check git status and the last few commits. Then write HANDOFF.md in the project root (do not commit it) with: the goal, what is done, what is left, key files, decisions made and why, the git state, and three numbered next steps. Finish with one short line I can paste as the first message of a fresh chat.'
366
367async function shrink($: EngineInterface) {
368 const before = (await read($, usage)).tokens
369 await say($, 'Shrinking the chat. This can take up to a minute.', 'watch')
370 try {
371 await $.command.run({ command: 'compact', args: KEEP })
372 const raw = await $.session.usage()
373 const after = toUsage(raw).tokens
374 await say($, before && after ? `Chat shrunk from ${fmtTokens(before)} to ${fmtTokens(after)}.` : 'Chat shrunk. The summary is in the chat above.', 'fine')
375 } catch (err) {
376 await say($, `Shrink failed: ${errText(err)}`, 'act')
377 }
378}
379
380async function handoff($: EngineInterface, kind: 'quick' | 'full') {
381 await say($, kind === 'quick' ? 'Writing a quick handoff note.' : 'Writing HANDOFF.md with the git state and next steps.', 'watch')
382 try {
383 await $.prompt.submit({ text: kind === 'quick' ? QUICK_HANDOFF : FULL_HANDOFF })
384 } catch (err) {
385 await say($, `Handoff failed: ${errText(err)}`, 'act')
386 }
387}
388
389async function remember($: EngineInterface, name: string) {
390 const saved = await update($, launcher, l => ({ ...l, recent: pushRecent(l.recent, name) }))
391 await $.store.set('launcher', saved)
392}
393
394async function launch($: EngineInterface, name: string) {
395 await remember($, name)
396 try {
397 await $.command.run({ command: name })
398 } catch (err) {
399 await say($, `/${name} failed: ${errText(err)}`, 'act')
400 }
401}
402
403async function pin($: EngineInterface, name: string) {
404 const saved = await update($, launcher, l => ({ ...l, pinned: togglePin(l.pinned, name) }))
405 await $.store.set('launcher', saved)
406}
407
408/** The person chose to pay the cold re-read: the guard lets the next message through. */
409async function sendAnyway($: EngineInterface) {
410 await update($, warmth, w => ({ ...w, ackedAt: w.lastRequestAt }))
411 await say($, 'Your next message goes through and re-reads the chat once.', 'watch')
412}
413
414// ── Keep-warm: a tiny ping over the cached chat before the cache would expire ──
415
416const PING_PROMPT = 'Reply with the single word: warm'
417const MAX_RETRIES = 2
418
419let pending: Timer | null = null
420let inFlight = false
421let generation = 0
422let retries = 0
423
424async function setKeepWarm($: EngineInterface, isOn: boolean) {
425 const now = await $.clock.now()
426 generation++
427 retries = 0
428 await update($, warmth, w => ({ ...w, keepWarmUntil: isOn ? now + config.settings.keepWarmMs : 0, stopped: null }))
429 if (isOn) await say($, `Keeping the cache warm for ${fmtDuration(config.settings.keepWarmMs)}. Each ping is a small model request.`, 'fine')
430 await armKeepWarm($)
431}
432
433async function stopKeepWarm($: EngineInterface, why: string) {
434 generation++
435 pending?.cancel()
436 pending = null
437 await update($, warmth, w => ({ ...w, keepWarmUntil: 0, stopped: why }))
438 await say($, `Keep-warm stopped: ${why}.`, 'watch')
439}
440
441/** Sets the one timer: a ping before the cache would cool, or the window's end. */
442async function armKeepWarm($: EngineInterface) {
443 pending?.cancel()
444 pending = null
445 const w = await read($, warmth)
446 const now = await $.clock.now()
447 if (!w.keepWarmUntil) return
448 if (now >= w.keepWarmUntil) {
449 await update($, warmth, x => ({ ...x, keepWarmUntil: 0 }))
450 return
451 }
452 const ttl = ttlNow()
453 // Nothing to keep warm yet (or any more): wait for the next turn, which arms again.
454 if (!w.lastRequestAt || w.isCompacted || isCold(w.lastRequestAt, w.isCompacted, ttl, now)) {
455 pending = $.clock.after(w.keepWarmUntil - now, () => void armKeepWarm($))
456 return
457 }
458 const delay = Math.min(w.keepWarmUntil - now, pingDelay(w.lastRequestAt, ttl, now))
459 pending = $.clock.after(delay, () => void ping($))
460}
461
462async function ping($: EngineInterface) {
463 pending = null
464 if (inFlight) return
465 const w = await read($, warmth)
466 const now = await $.clock.now()
467 const ttl = ttlNow()
468 if (!w.keepWarmUntil || now >= w.keepWarmUntil || !w.lastRequestAt || w.isCompacted || isCold(w.lastRequestAt, false, ttl, now)) {
469 return armKeepWarm($)
470 }
471 // A request since the timer was set moved the cache clock: schedule from it instead.
472 if (pingDelay(w.lastRequestAt, ttl, now) > 1000) return armKeepWarm($)
473 const gen = generation
474 const sentFor = w.lastRequestAt
475 inFlight = true
476 let reply: Awaited<ReturnType<EngineInterface['model']['fork']>> | { isAnswered: false; reason: 'error'; err: unknown }
477 try {
478 reply = await $.model.fork({ prompt: PING_PROMPT })
479 } catch (err) {
480 reply = { isAnswered: false, reason: 'error', err }
481 }
482 inFlight = false
483 const at = await $.clock.now()
484 if (gen !== generation || (await read($, warmth)).lastRequestAt !== sentFor) return armKeepWarm($)
485 if (!reply.isAnswered) {
486 if (reply.reason === 'nothing-to-fork') return
487 if (reply.reason === 'api-error' && (reply.status === null || reply.status === 429 || reply.status >= 500) && retries < MAX_RETRIES) {
488 retries++
489 pending = $.clock.after(MINUTE, () => void ping($))
490 return
491 }
492 return stopKeepWarm($, reply.reason === 'error' ? `the ping failed (${errText(reply.err)})` : `the ping came back with ${reply.reason}`)
493 }
494 retries = 0
495 const isWarm = isWarmPing(reply.usage)
496 await update($, warmth, x => ({
497 ...x,
498 lastPing: { at, read: reply.usage.cache_read_input_tokens, isWarm },
499 lastRequestAt: isWarm ? at : x.lastRequestAt,
500 }))
501 if (!isWarm) return stopKeepWarm($, 'the cache had already expired when it pinged')
502 await armKeepWarm($)
503}
504
505// ── The redraw timer ────────────────────────────────────────────────────────
506
507let ticker: Timer | null = null
508let tickEvery = 0
509
510/** Every 5s while a turn runs (its timer shows), every 30s while the cache countdown shows, otherwise never. */
511async function pace($: EngineInterface) {
512 const t = await read($, turn)
513 const w = await read($, warmth)
514 const now = await $.clock.now()
515 const isCounting = (w.lastRequestAt > 0 && !w.isCompacted && now - w.lastRequestAt < ttlNow()) || w.keepWarmUntil > now
516 const want = t.isWorking ? 5_000 : isCounting ? 30_000 : 0
517 if (want === tickEvery) return
518 ticker?.cancel()
519 ticker = null
520 tickEvery = want
521 if (want) ticker = $.clock.every(want, () => void onTick($))
522}
523
524async function onTick($: EngineInterface) {
525 await update($, tick, n => n + 1)
526 await pace($)
527}
528
529/** A /clear, a resume or compaction replaced what an in-flight ping was sent for. */
530function outdatePing() {
531 generation++
532}
533
534// ── What a press does, for the drawings ─────────────────────────────────────
535
536function actionsFor($: EngineInterface): Actions {
537 return {
538 openPane: () => openPane($),
539 shrink: () => shrink($),
540 handoff: kind => handoff($, kind),
541 setKeepWarm: isOn => setKeepWarm($, isOn),
542 sendAnyway: () => sendAnyway($),
543 toggleExpanded: id => toggleExpanded($, id),
544 launch: name => launch($, name),
545 pin: name => pin($, name),
546 setFilter: text => setFilter($, text),
547 changeLook: change => changeLook($, change),
548 toggleCustomize: () => toggleCustomize($),
549 pressOnce: (key, work) => pressOnce($, key, work),
550 }
551}
552
553// ── The hooks ───────────────────────────────────────────────────────────────
554
555const isLauncher = (x: unknown): x is Launcher =>
556 typeof x === 'object' && x !== null && Array.isArray((x as Launcher).pinned) && Array.isArray((x as Launcher).recent)
557
558/** A pad's press and release can both arrive for one click (after a redraw); the second within this is the same. */
559const PAD_SETTLE_MS = 400
560const lastPadPress = new Map<string, number>()
561
562export const register: Register = (on, options) => {
563 config.settings = readSettings(options, undefined)
564
565 on('session.start', async ($, e, next) => {
566 let settings: Record<string, unknown> = {}
567 try {
568 settings = await $.settings.read()
569 } catch {}
570 config.settings = readSettings(options, settings.autoCompactWindow)
571 await loadPlan($)
572 await loadLook($)
573 const saved = await $.store.get('launcher')
574 if (isLauncher(saved)) await loadLauncher($, saved)
575 await measure($, await $.session.usage())
576 await $.command.register({
577 name: 'cockpit',
578 description: 'Open the Control Center: chat, cache, limits, agents, handoff and the launcher',
579 argumentHint: '[color <name|#hex|none> | opacity <0-100|clear|tinted> | keepwarm on|off | pin <command>]',
580 })
581 if (config.settings.keepWarm && !(await isKeepWarmOn($))) await setKeepWarm($, true)
582 else await armKeepWarm($)
583 await pace($)
584 if (config.settings.openAtStart) await openPane($)
585 return next(e)
586 })
587
588 on('classic.SessionStart', async ($, e, next) => {
589 try {
590 return await next(e)
591 } finally {
592 if (e.source === 'clear') await resetForClear($)
593 else if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
594 await resumeCacheClock($, e.seconds_since_last_response)
595 }
596 await armKeepWarm($)
597 await pace($)
598 }
599 })
600
601 on('session.measure', async ($, e, next) => {
602 await measure($, e)
603 return next(e)
604 })
605
606 on('turn.start', async ($, e, next) => {
607 if (!e.agentId) {
608 await startTurn($)
609 await pace($)
610 }
611 return next(e)
612 })
613
614 on('tool.call', async ($, e, next) => {
615 if (!e.agentId) await countTool($)
616 else await agentTool($, e.agentId, e.tool, e as Readonly<Record<string, unknown>>)
617 return next(e)
618 })
619
620 // Each answered model request: the main thread's restarts the cache clock; a subagent's fills in its card.
621 // A request with no response (a login or network error) cached nothing, so it leaves the clock alone.
622 on('turn.step', async function* ($, e, next) {
623 const r = yield* next(e)
624 const u = r.usage
625 // The compaction's own request reads the old chat, whose cache it is about to make useless: no restart for it.
626 if (!e.agentId && u && !(await isSqueezing($))) await stampRequest($)
627 if (e.agentId && u) {
628 await agentStep($, e.agentId, u.model || e.model, u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, e.effort)
629 }
630 return r
631 })
632
633 on('turn.complete', async ($, e, next) => {
634 const r = await next(e)
635 if (e.agentId) {
636 await agentDone($, e.agentId, e.reason === 'answer')
637 return r
638 }
639 await finishTurn($, { durationMs: e.durationMs, isAborted: e.isAborted, hasUsage: e.usage !== undefined })
640 await armKeepWarm($)
641 await pace($)
642 return r
643 })
644
645 on('agent.spawn', async ($, e, next) => {
646 const started = await next(e)
647 if (started.deny !== undefined) return started
648 await agentStarted($, {
649 id: started.agentId ?? e.tool_use_id,
650 type: e.subagentType,
651 description: e.description,
652 model: started.model,
653 status: 'running',
654 endedAt: null,
655 steps: 0,
656 tokens: 0,
657 })
658 return started
659 })
660
661 on('session.compact', async ($, e, next) => {
662 const isMain = !e.agentId && e.trigger !== 'precompute'
663 if (isMain) await startSqueeze($)
664 let didCompact = false
665 try {
666 const r = await next(e)
667 if (isMain && r.skip === undefined) {
668 didCompact = true
669 await markCompacted($, r.tokensAfter)
670 await armKeepWarm($)
671 }
672 return r
673 } finally {
674 if (isMain) await endSqueeze($, didCompact)
675 }
676 })
677
678 // The cold-send guard: the first message after the cache went cold on a big chat is held once, with the reason.
679 on('prompt.submit', async ($, e, next) => {
680 const { decision, why } = await judgePrompt($, e.origin.kind, e.text)
681 if (decision === 'warn') $.ui.log(`Cockpit: ${why}. Sending anyway, since nobody can resend this ${e.origin.kind} message.`)
682 if (decision !== 'drop') return next(e)
683 return { drop: `Cockpit held this message: ${why}. Send it again to go ahead, or press Compact first in the band.` }
684 })
685
686 on('command.run', { command: 'cockpit' }, async ($, e) => {
687 const [verb, arg] = e.args.trim().split(/\s+/)
688 if (verb === 'keepwarm' && (arg === 'on' || arg === 'off')) {
689 await setKeepWarm($, arg === 'on')
690 return { text: arg === 'on' ? 'Keep-warm is on.' : 'Keep-warm is off.' }
691 }
692 if (verb === 'color' || verb === 'colour') {
693 const color = arg ? parseColor(arg) : undefined
694 if (color === undefined) return { text: `Colours: ${Object.keys(GLASS_COLORS).join(', ')}, a #rrggbb, or none.` }
695 await changeLook($, { color })
696 return { text: color ? `Control Center glass tinted ${arg}.` : 'Control Center glass back to neutral.' }
697 }
698 if (verb === 'opacity') {
699 const opacity = arg ? parseOpacity(arg) : undefined
700 if (opacity === undefined) return { text: 'Opacity: 0 (clear) to 100 (solid), or clear, tinted or default.' }
701 await changeLook($, { opacity })
702 return { text: `Control Center glass at ${opacity}% opacity.` }
703 }
704 if (verb === 'pin' && arg) {
705 const name = arg.replace(/^\//, '')
706 await pin($, name)
707 return { text: `Toggled the pin on /${name}.` }
708 }
709 await togglePane($)
710 return { text: (await isPaneOpen($)) ? 'Control Center opened.' : 'Control Center closed. /cockpit brings it back.' }
711 })
712
713 // Commands the person runs land in the launcher's Recent group.
714 on('command.run', async ($, e, next) => {
715 if (e.origin.kind === 'composer' && e.command !== 'cockpit') await remember($, e.command)
716 return next(e)
717 })
718
719 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
720 if (e.props.hasSurvey) return next(e)
721 // On the desktop the Control Center is the display, so the band stays empty; /cockpit opens and closes it.
722 if (e.surface !== 'terminal') return next(e)
723 const v = await snapshot($)
724 const turnTokens = await readTurnTokens($)
725 return drawBand(actionsFor($), $.ui.resolve(e), { bodyColumns: e.props.bodyColumns, isWorking: e.props.isWorking, isTerminal: e.surface === 'terminal' }, v, turnTokens)
726 })
727
728 // On the desktop, a button after the footer's mode labels opens the Control Center and, open, closes it.
729 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
730 if (e.surface !== 'desktop') return next(e)
731 const els = $.ui.resolve(e)
732 return drawLaunch(els, e.props.modes, await isPaneShown($), () => void togglePane($))
733 })
734
735 // A pad over a glass tile was pressed (./pad.tsx): run what that tile does.
736 on('ui.message', async ($, e, next) => {
737 const press = presses.get(e.element)
738 if (!press || (e.data as { press?: unknown } | null)?.press !== true) return next(e)
739 const now = await $.clock.now()
740 if (now - (lastPadPress.get(e.element) ?? 0) < PAD_SETTLE_MS) return {}
741 lastPadPress.set(e.element, now)
742 press()
743 return {}
744 })
745
746 on('ui.close', async ($, e, next) => {
747 const r = await next(e)
748 if (e.id === PANE) await redraw($)
749 return r
750 })
751
752 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
753 const v = await snapshot($)
754 const list = await $.command.list()
755 const commands = list.map(c => ({ name: c.name, description: c.description }))
756 const extras = await paneExtras($)
757 const data = { bodyColumns: e.props.bodyColumns, isTerminal: e.surface === 'terminal', commands, ...extras }
758 if (e.surface === 'desktop') {
759 // The desktop lays a pad over each glass tile, made here so the pad's module is plain to see.
760 const ui = $.ui.resolve(e)
761 const pads = new Map<string, RenderChildren>()
762 for (const spot of padSpots()) {
763 pads.set(spot.key, <ui.Client module="./pad.tsx" key={`pad-${spot.key}`} width={spot.width} height={spot.height} props={{ rows: spot.height }} />)
764 }
765 return drawPane(actionsFor($), ui, { ...data, pads }, v)
766 }
767 return drawPane(actionsFor($), $.ui.resolve(e), data, v)
768 })
769}
770hooks/art/glass.ts 183 lines1// Liquid Glass, drawn in SVG. Each tile is its own document: a lit backdrop of the tile's own colours, refracted
2// through a frosted pane with a bright rim and a top sheen. The desktop draws it in a script-less sandboxed frame,
3// so all motion is SMIL or CSS, and colours follow the system light or dark scheme by media query.
4
5import type { Look } from '../../types'
6
7/** Design tokens shared by every tile. */
8export const INK = {
9 coral: '#D97757',
10 coralHot: '#FF6A3D',
11 amber: '#FFB547',
12 red: '#FF5A4E',
13 mint: '#7FD1A8',
14 ice: '#8CCBFF',
15 cream: '#F4EFE6',
16} as const
17
18export const FONT = `ui-rounded, 'SF Pro Rounded', -apple-system, BlinkMacSystemFont, system-ui, sans-serif`
19
20export type Glow = { color: string; x: number; y: number; r: number; opacity?: number }
21
22/** Escapes text for SVG. */
23export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
24
25/** Rounds to two decimals so the markup stays short. */
26export const n2 = (x: number) => Math.round(x * 100) / 100
27
28/** Cuts a label to a length, with an ellipsis. */
29export const clip = (s: string, max: number) => ([...s].length > max ? `${[...s].slice(0, max - 1).join('')}…` : s)
30
31// ── The look: the glass's colour and opacity, as Apple lets you tint Control Center and pick clear or tinted ──
32
33export const DEFAULT_LOOK: Look = { color: null, opacity: 58 }
34
35/** Apple's system colours, by the names `/cockpit color` takes; a #rrggbb works too. */
36export const GLASS_COLORS: Record<string, string> = {
37 coral: '#D97757', red: '#FF453A', orange: '#FF9F0A', yellow: '#FFD60A', green: '#30D158', mint: '#63E6E2',
38 teal: '#40C8E0', blue: '#0A84FF', indigo: '#5E5CE6', purple: '#BF5AF2', pink: '#FF375F', brown: '#AC8E68', graphite: '#8E8E93',
39}
40
41/** A colour name, a #rrggbb, or none/clear for neutral; undefined when it is neither. */
42export function parseColor(word: string): string | null | undefined {
43 const w = word.trim().toLowerCase()
44 if (w === 'none' || w === 'neutral' || w === 'default') return null
45 if (GLASS_COLORS[w]) return GLASS_COLORS[w]
46 if (/^#[0-9a-f]{6}$/.test(w)) return w.toUpperCase()
47 return undefined
48}
49
50/** A percentage 0 to 100 (a trailing % allowed), or clear (20) and tinted (80) as Apple names them. */
51export function parseOpacity(word: string): number | undefined {
52 const w = word.trim().toLowerCase()
53 if (w === 'clear') return 20
54 if (w === 'tinted') return 80
55 if (w === 'default') return DEFAULT_LOOK.opacity
56 const n = Number(w.replace(/%$/, ''))
57 return Number.isFinite(n) && w !== '' ? Math.min(100, Math.max(0, Math.round(n))) : undefined
58}
59
60let look: Look = DEFAULT_LOOK
61
62/** Sets the look every tile drawn after it wears, until the next call. */
63export function setLook(next: Look) {
64 look = next
65}
66
67const rgba = (hex: string, a: number) => {
68 const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
69 return `rgba(${r},${g},${b},${n2(a)})`
70}
71
72/** The frost for each scheme: the colour mixed into the smoke (dark) or milk (light), at the look's opacity. */
73function frost() {
74 const a = Math.min(100, Math.max(0, look.opacity)) / 100
75 const dark = look.color ? mix('#100F13', look.color, 0.34) : '#100F13'
76 const light = look.color ? mix('#FFFFFF', look.color, 0.24) : '#FFFFFF'
77 return { dark: rgba(dark, a), light: rgba(light, a * 0.83) }
78}
79
80const style = () => {
81 const f = frost()
82 return `
83.t{font-family:${FONT};fill:var(--fg);letter-spacing:-.01em}
84.d{fill:var(--dim)}
85.f{fill:var(--faint)}
86.tile{transition:transform .35s cubic-bezier(.2,.8,.2,1)}
87.tile:hover{transform:scale(1.015)}
88.tile:hover .sheen{opacity:1}
89.sheen{opacity:.75;transition:opacity .35s}
90svg{--fg:#FFFFFF;--dim:rgba(255,255,255,.64);--faint:rgba(255,255,255,.34);--tint:${f.dark};--track:rgba(255,255,255,.13);--rimA:rgba(255,255,255,.85);--rimB:rgba(255,255,255,.06);--edge:rgba(0,0,0,.5);--inner:rgba(255,255,255,.10)}
91@media (prefers-color-scheme: light){svg{--fg:#17161A;--dim:rgba(23,22,26,.62);--faint:rgba(23,22,26,.36);--tint:${f.light};--track:rgba(23,22,26,.10);--rimA:rgba(255,255,255,.95);--rimB:rgba(255,255,255,.3);--edge:rgba(0,0,0,.14);--inner:rgba(255,255,255,.5)}}
92`
93}
94
95/** One glass tile document. `body` is drawn inside the pane, above the frost and below the sheen. */
96export function tile(o: {
97 w: number
98 h: number
99 r?: number
100 glows: Glow[]
101 body: string
102 title: string
103 extraDefs?: string
104 isCircle?: boolean
105 drift?: boolean
106}): string {
107 const { w, h, glows, body, title } = o
108 const pad = 6
109 const W = w + pad * 2
110 const H = h + pad * 2
111 const r = o.isCircle ? Math.min(w, h) / 2 : (o.r ?? 26)
112 const shape = `<rect x="${pad}" y="${pad}" width="${w}" height="${h}" rx="${r}" ry="${r}"/>`
113 const drift = o.drift ?? true
114 const lights = glows
115 .map((g, i) => {
116 const dx = n2(w * 0.08 * (i % 2 ? -1 : 1))
117 const dy = n2(h * 0.06 * (i % 2 ? 1 : -1))
118 const anim = drift
119 ? `<animateTransform attributeName="transform" type="translate" values="0 0;${dx} ${dy};0 0" dur="${9 + i * 3}s" repeatCount="indefinite" calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1"/>`
120 : ''
121 return `<circle cx="${n2(pad + g.x * w)}" cy="${n2(pad + g.y * h)}" r="${n2(g.r * Math.max(w, h))}" fill="${g.color}" opacity="${g.opacity ?? 0.9}">${anim}</circle>`
122 })
123 .join('')
124 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><title>${esc(title)}</title><style>${style()}</style><defs>
125<clipPath id="c">${shape}</clipPath>
126<filter id="lens" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.011 0.017" numOctaves="2" seed="4" result="n"/><feGaussianBlur in="SourceGraphic" stdDeviation="${n2(Math.min(w, h) * 0.13)}" result="b"/><feDisplacementMap in="b" in2="n" scale="${n2(Math.min(w, h) * 0.32)}" xChannelSelector="R" yChannelSelector="G"/></filter>
127<filter id="edgeLens" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.03" numOctaves="1" seed="9" result="n"/><feGaussianBlur in="SourceGraphic" stdDeviation="${n2(Math.min(w, h) * 0.05)}" result="b"/><feDisplacementMap in="b" in2="n" scale="${n2(Math.min(w, h) * 0.18)}" xChannelSelector="R" yChannelSelector="G" result="d"/><feColorMatrix in="d" type="saturate" values="1.7" result="s"/><feComponentTransfer in="s"><feFuncR type="linear" slope="1.35"/><feFuncG type="linear" slope="1.35"/><feFuncB type="linear" slope="1.35"/></feComponentTransfer></filter>
128<filter id="soft"><feGaussianBlur stdDeviation="${n2(Math.max(2, Math.min(w, h) * 0.035))}"/></filter>
129<mask id="band" maskUnits="userSpaceOnUse"><g filter="url(#soft)" fill="none" stroke="#fff" stroke-width="${n2(Math.max(5, Math.min(w, h) * 0.13))}">${shape}</g></mask>
130<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1"><stop offset=".6" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity=".07"/></linearGradient>
131<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="3" stdDeviation="3.5" flood-color="#000" flood-opacity=".28"/></filter>
132<linearGradient id="rim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--rimA)"/><stop offset=".38" stop-color="var(--rimB)"/><stop offset=".62" stop-color="var(--rimB)"/><stop offset="1" stop-color="var(--rimA)" stop-opacity=".55"/></linearGradient>
133<radialGradient id="sheenG" cx=".28" cy="-.05" r=".9"><stop offset="0" stop-color="#fff" stop-opacity=".30"/><stop offset=".55" stop-color="#fff" stop-opacity=".04"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>
134${o.extraDefs ?? ''}</defs>
135<g class="tile" style="transform-origin:${W / 2}px ${H / 2}px">
136<g filter="url(#shadow)" fill="var(--tint)">${shape}</g>
137<g clip-path="url(#c)"><g filter="url(#lens)">${lights}</g><g fill="var(--tint)">${shape}</g>
138<g mask="url(#band)" opacity=".9"><g filter="url(#edgeLens)">${lights}</g></g><g fill="url(#floor)">${shape}</g>
139<g transform="translate(${pad} ${pad})">${body}</g>
140<g class="sheen" fill="url(#sheenG)">${shape}</g></g>
141<g fill="none" stroke="url(#rim)" stroke-width="1.3">${shape}</g>
142<g fill="none" stroke="var(--inner)" stroke-width="1" clip-path="url(#c)" transform="translate(0 1.4)">${shape}</g>
143<g fill="none" stroke="var(--edge)" stroke-width=".6" transform="translate(0 .6)" opacity=".7">${shape}</g>
144</g></svg>`
145}
146
147/** A hex colour mixed toward another, `t` from 0 to 1. */
148export function mix(a: string, b: string, t: number): string {
149 const p = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
150 const [x, y] = [p(a), p(b)]
151 const k = Math.min(1, Math.max(0, t))
152 return `#${x.map((v, i) => Math.round(v + ((y[i] ?? v) - v) * k).toString(16).padStart(2, '0')).join('')}`
153}
154
155/** The colour of a value along stops `[[at, hex], ...]`, sorted by `at`. */
156export function ramp(stops: readonly (readonly [number, string])[], t: number): string {
157 const first = stops[0]
158 if (!first) return '#ffffff'
159 if (t <= first[0]) return first[1]
160 for (let i = 1; i < stops.length; i++) {
161 const lo = stops[i - 1]
162 const hi = stops[i]
163 if (lo && hi && t <= hi[0]) return mix(lo[1], hi[1], (t - lo[0]) / (hi[0] - lo[0]))
164 }
165 return stops[stops.length - 1]?.[1] ?? first[1]
166}
167
168/**
169 * Every looping animation set to where the clock says it is: a `begin` of minus its place in the loop. A frame the
170 * desktop reloads (a new picture, or the pane's layout changing) then carries on mid-loop instead of jumping back.
171 */
172export function phased(source: string, nowS: number): string {
173 return source.replace(/<animate(?:Transform)?\b[^>]*repeatCount="indefinite"[^>]*>/g, tag => {
174 const dur = /\bdur="([\d.]+)s"/.exec(tag)
175 const begin = /\bbegin="(-?[\d.]+)s"/.exec(tag)
176 if (!dur || Number(dur[1]) <= 0 || (/\bbegin="/.test(tag) && !begin)) return tag
177 const d = Number(dur[1])
178 const at = (((nowS - Number(begin?.[1] ?? 0)) % d) + d) % d
179 const b = `begin="${-Math.round(at * 1000) / 1000}s"`
180 return begin ? tag.replace(begin[0], b) : tag.replace(/\bdur="/, `${b} dur="`)
181 })
182}
183hooks/band.tsx 259 lines1// The band above the prompt: one calm line of three facts, a second line only when it is time to act.
2
3import type { Elements, RenderSurface } from 'claude-code'
4
5import { barCells, fmtDuration, fmtTokens, runs, turnsLeft, turnsWords } from './model'
6import { PANE_TITLE } from './view'
7import type { Actions, View } from './view'
8import { ACCENT, GLYPH, MUTED, TONE, spinner, tierOf } from './theme'
9
10type Els = Elements[RenderSurface]
11type Part = { text: string; color?: string; dim?: boolean }
12type Seg = { id: string; parts: Part[]; card: string[] }
13type Btn = { key: string; label: string; hotkey: string; isPrimary?: boolean; onPress: () => void }
14type Layout = { segs: Seg[]; sep: string; buttons: Btn[] }
15
16const width = (parts: Part[]) => parts.reduce((n, p) => n + [...p.text].length, 0)
17const btnWidth = (b: Btn) => [...b.label].length + 4
18const toned = (tone: 'fine' | 'watch' | 'act') => (tone === 'fine' ? undefined : TONE[tone])
19
20// ── Segments ────────────────────────────────────────────────────────────────
21
22function chatSeg(v: View, level: number, turnTokens: readonly number[]): Seg {
23 const { chat, usage, settings } = v
24 const value: Part = { text: `${chat.pct}%`, color: toned(chat.tone) }
25 const parts: Part[] = level === 0 ? [value] : [{ text: 'Chat ', dim: true }, value]
26 if (level >= 2) {
27 const bar = runs(barCells(chat.windowFrac, 12, chat.notchFrac)).map(r => ({
28 text: (r.kind === 'fill' ? GLYPH.fill : r.kind === 'notch' ? GLYPH.notch : GLYPH.empty).repeat(r.length),
29 color: r.kind === 'fill' ? (toned(chat.tone) ?? ACCENT) : r.kind === 'notch' ? 'text' : MUTED,
30 dim: r.kind === 'empty',
31 }))
32 parts.splice(1, 0, ...bar, { text: ' ' })
33 parts.push({ text: ` of ${fmtTokens(settings.compactAt)}`, dim: true })
34 }
35 return {
36 id: 'chat',
37 parts,
38 card: [
39 `Chat ${fmtTokens(usage.tokens)} of ${fmtTokens(settings.compactAt)} compact point · ${fmtTokens(usage.window)} window`,
40 turnsWords(turnsLeft(turnTokens, usage.tokens, settings.compactAt)),
41 ],
42 }
43}
44
45function cacheSeg(v: View, level: number): Seg {
46 const { cache, warmth, ttl, usage } = v
47 const life = ttl >= 3_600_000 ? '1-hour cache' : `${fmtDuration(ttl)} cache`
48 const label: Part[] = level === 0 ? [] : [{ text: 'Cache ', dim: true }]
49 const warmMark: Part[] = v.isKeepingWarm ? [{ text: ` ${GLYPH.warm}`, color: ACCENT }] : []
50 const keep = v.isKeepingWarm ? `Keep-warm is on for ${fmtDuration(warmth.keepWarmUntil - v.now)} more.` : 'Keep-warm is off.'
51 if (cache.kind === 'warm') {
52 return {
53 id: 'cache',
54 parts: [...label, { text: 'warm ', color: toned(cache.tone) }, { text: fmtDuration(cache.leftMs) }, ...warmMark],
55 card: [`Cache warm for ${fmtDuration(cache.leftMs)} more (${life}). Replying now is cheap.`, keep],
56 }
57 }
58 if (cache.kind === 'cold') {
59 return {
60 id: 'cache',
61 parts: [...label, { text: 'cold', color: toned(cache.tone) }, ...warmMark],
62 card: [
63 `Cache cold for ${fmtDuration(v.now - warmth.lastRequestAt - ttl)}. The next message re-reads ${fmtTokens(usage.tokens)} tokens.`,
64 keep,
65 ],
66 }
67 }
68 return {
69 id: 'cache',
70 parts: [...(level === 0 ? [{ text: 'cache ', dim: true }] : label), { text: '–', dim: true }, ...warmMark],
71 card: [`Cache starts with the next reply (${life}).`, keep],
72 }
73}
74
75function limitSeg(v: View, level: number): Seg | null {
76 const { five, week } = v
77 if (!five && !week) return null
78 const parts: Part[] = []
79 const card: string[] = []
80 if (five) {
81 parts.push({ text: '5h ', dim: true }, { text: `${five.pct}%`, color: toned(five.tone) })
82 if (level >= 1) parts.push({ text: ` ${five.short}`, color: toned(five.tone), dim: five.tone === 'fine' })
83 card.push(`5-hour ${five.pct}% used. ${five.words}`)
84 }
85 if (week && (level >= 2 || !five)) {
86 if (parts.length) parts.push({ text: GLYPH.sepTight, dim: true })
87 parts.push({ text: 'Week ', dim: true }, { text: `${week.pct}%`, color: toned(week.tone) })
88 }
89 if (week) card.push(`Week ${week.pct}% used. ${week.words}`)
90 return { id: 'limits', parts, card }
91}
92
93function workingSeg(v: View, level: number): Seg {
94 const t = v.turn
95 const elapsed = t.startedAt > 0 ? fmtDuration(v.now - t.startedAt) : '0s'
96 const running = v.agents.filter(a => a.status === 'running').length
97 const frame = spinner(v.now)
98 const parts: Part[] = [{ text: frame, color: ACCENT }, { text: level === 0 ? ` ${elapsed}` : ` Working ${elapsed}` }]
99 parts.push({ text: ` · ${t.tools} tool${t.tools === 1 ? '' : 's'}`, dim: true })
100 if (running > 0 && level >= 1) parts.push({ text: ` · ${running} agent${running === 1 ? '' : 's'}`, dim: true })
101 return {
102 id: 'limits',
103 parts,
104 card: [`Turn running for ${elapsed} · ${t.tools} tool calls · ${running} agent${running === 1 ? '' : 's'} running`, 'Details live in the Control Center.'],
105 }
106}
107
108// ── Layouts, richest first ──────────────────────────────────────────────────
109
110function layouts(act: Actions, v: View, isWorking: boolean, turnTokens: readonly number[], hasAct: boolean, isTerminal: boolean): Layout[] {
111 const open = () => void act.openPane()
112 const fresh: Btn = { key: 'handoff', label: 'Start fresh', hotkey: 'h', isPrimary: true, onPress: () => void act.handoff('quick') }
113 const quiet: Btn = { key: 'handoff', label: 'Handoff', hotkey: 'h', onPress: () => void act.handoff('full') }
114 const shrinkBtn: Btn = { key: 'shrink', label: 'Compact', hotkey: 's', onPress: () => void act.shrink() }
115 const warmBtn: Btn = {
116 key: 'warm',
117 label: v.isKeepingWarm ? `Warm ${GLYPH.warm}` : 'Keep warm',
118 hotkey: 'w',
119 onPress: () => void act.setKeepWarm(!v.isKeepingWarm),
120 }
121 const launcher = (label: string): Btn => ({ key: 'open', label, hotkey: 'k', onPress: open })
122 // The app has room for words; the terminal keeps the glyph until it is wide.
123 const roomy = isTerminal ? GLYPH.launcher : PANE_TITLE
124 const handoffBtn = v.freshReason ? fresh : quiet
125 // On the act line the same actions sit beside the reason, so line one keeps only the launcher.
126 const actions = (list: Btn[]) => (hasAct ? [] : list)
127
128 const segsAt = (level: number) =>
129 [chatSeg(v, level, turnTokens), cacheSeg(v, level), isWorking ? workingSeg(v, level) : limitSeg(v, level)].filter(
130 (s): s is Seg => s !== null,
131 )
132
133 return [
134 { segs: segsAt(2), sep: GLYPH.sep, buttons: [...actions([shrinkBtn, handoffBtn, warmBtn]), launcher(`${GLYPH.launcher} ${PANE_TITLE}`)] },
135 { segs: segsAt(1), sep: GLYPH.sepTight, buttons: [...actions([shrinkBtn, handoffBtn]), launcher(roomy)] },
136 { segs: segsAt(1), sep: GLYPH.sepTight, buttons: [...actions(v.freshReason ? [fresh] : []), launcher(roomy)] },
137 { segs: segsAt(0), sep: GLYPH.sepTight, buttons: [...actions(v.freshReason ? [fresh] : []), launcher(GLYPH.launcher)] },
138 { segs: segsAt(0), sep: GLYPH.sepTight, buttons: [launcher(GLYPH.launcher)] },
139 ]
140}
141
142const START = { compact: 3, regular: 1, wide: 0 } as const
143
144function choose(all: Layout[], columns: number, density: string): Layout {
145 const lineWidth = (l: Layout) =>
146 2 + l.segs.reduce((n, s) => n + width(s.parts), 0) + [...l.sep].length * (l.segs.length - 1) + 1 +
147 l.buttons.reduce((n, b) => n + btnWidth(b) + 1, 0)
148 const from = START[tierOf(columns, density)]
149 return all.slice(from).find(l => lineWidth(l) <= columns) ?? (all[all.length - 1] as Layout)
150}
151
152// ── Drawing ─────────────────────────────────────────────────────────────────
153
154export type BandProps = { bodyColumns: number; isWorking: boolean; isTerminal: boolean }
155
156export function drawBand(actions: Actions, els: Els, p: BandProps, v: View, turnTokens: readonly number[]) {
157 const { Box, Text, Button } = els
158 const columns = Math.max(20, p.bodyColumns)
159 const isWorking = p.isWorking || v.turn.isWorking
160 const act = v.act
161 const layout = choose(layouts(actions, v, isWorking, turnTokens, act !== null, p.isTerminal), columns, v.settings.density)
162
163 const button = (b: Btn) => (
164 <Button
165 key={b.key}
166 label={b.label}
167 hotkey={b.hotkey}
168 variant={b.isPrimary ? 'primary' : undefined}
169 dimColor={b.isPrimary ? undefined : true}
170 onPress={b.onPress}
171 />
172 )
173
174 // Each segment is a hover scope with a card that opens over the rows above it.
175 let offset = 2
176 const segments = layout.segs.map((s, i) => {
177 const at = offset
178 offset += width(s.parts) + (i < layout.segs.length - 1 ? [...layout.sep].length : 0)
179 const cardWidth = Math.min(columns, Math.max(...s.card.map(l => [...l].length)) + 4)
180 const inner = cardWidth - 4
181 return (
182 <Box key={`seg-${s.id}`} flexShrink={0}>
183 <Text hover={{ underline: true }}>
184 {s.parts.map(part => (
185 <Text color={part.color} dimColor={part.dim}>
186 {part.text}
187 </Text>
188 ))}
189 {i < layout.segs.length - 1 ? <Text dimColor>{layout.sep}</Text> : null}
190 </Text>
191 <Box
192 position="absolute"
193 top={-(s.card.length + 2)}
194 left={Math.min(0, columns - at - cardWidth)}
195 width={cardWidth}
196 display="none"
197 hover={{ display: 'flex' }}
198 flexDirection="column"
199 borderStyle="round"
200 borderColor={MUTED}
201 paddingX={1}
202 >
203 {s.card.map(line => (
204 <Text wrap="truncate">{line.padEnd(inner)}</Text>
205 ))}
206 </Box>
207 </Box>
208 )
209 })
210
211 const actButtons: Btn[] =
212 act === null
213 ? []
214 : act.kind === 'cold'
215 ? [
216 { key: 'act-shrink', label: 'Compact first', hotkey: 's', isPrimary: true, onPress: () => void actions.shrink() },
217 { key: 'act-fresh', label: 'Start fresh', hotkey: 'h', onPress: () => void actions.handoff('quick') },
218 { key: 'act-send', label: 'Send anyway', hotkey: 'a', onPress: () => void actions.sendAnyway() },
219 ]
220 : act.kind === 'full'
221 ? [
222 { key: 'act-shrink', label: 'Compact now', hotkey: 's', isPrimary: true, onPress: () => void actions.shrink() },
223 { key: 'act-fresh', label: 'Start fresh', hotkey: 'h', onPress: () => void actions.handoff('quick') },
224 ]
225 : [{ key: 'act-details', label: 'Details', hotkey: 'd', onPress: () => void actions.openPane() }]
226
227 const actRoom = columns - actButtons.reduce((n, b) => n + btnWidth(b) + 1, 0) - 3
228 const actFits = act !== null && [...act.text].length <= actRoom
229
230 return (
231 <Box flexDirection="column">
232 <Box flexDirection="row">
233 <Text color={TONE[v.tone]}>{`${GLYPH.dot} `}</Text>
234 {segments}
235 <Box flexGrow={1} />
236 <Box flexDirection="row" flexShrink={0} gap={1}>
237 {layout.buttons.map(button)}
238 </Box>
239 </Box>
240 {act ? (
241 <Box flexDirection="row">
242 <Box flexShrink={1} flexGrow={1}>
243 <Text color={TONE.watch} wrap="truncate">{`${GLYPH.act} ${actFits ? act.text : act.short}`}</Text>
244 </Box>
245 <Box flexDirection="row" flexShrink={0} gap={1}>
246 {actButtons.map(button)}
247 </Box>
248 </Box>
249 ) : null}
250 {v.notice ? (
251 <Text wrap="truncate">
252 <Text color={TONE[v.notice.tone]}>{'› '}</Text>
253 <Text dimColor>{v.notice.text}</Text>
254 </Text>
255 ) : null}
256 </Box>
257 )
258}
259hooks/glass.tsx 300 lines1// The Liquid Glass Control Center, for surfaces that draw `Svg` (the desktop app). The terminal keeps the text pane.
2// A sandboxed SVG takes no presses, so each pressable tile has a pad laid over it: a Client that draws nothing
3// and posts on a press (./pad.tsx), so no hover band shows. A surface without Client gets a small Button instead.
4
5import type { Elements } from 'claude-code'
6
7import type { AgentCard } from '../types'
8import type { AgentArt, CacheArt, ContextArt, GaugeArt, RoundArt, TankArt } from './art/tiles'
9import { GLASS_COLORS, phased, setLook } from './art/glass'
10import { FULL, GAP, agentsTile, appearanceTile, cacheTile, contextTile, gaugeTile, opacityTile, roundTile, swatchTile, tankTile } from './art/tiles'
11import { MINUTE, fmtDuration, fmtTokens, turnsLeft, turnsWords } from './model'
12import type { PaceView } from './model'
13import { DRAIN_MS, SETTLE_MS, drainLevel } from './view'
14import type { Actions, Pads, View } from './view'
15
16// ── From Cockpit's snapshot to the tiles' figures ───────────────────────────
17
18const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
19
20export function contextArt(v: View, turnTokens: readonly number[]): ContextArt {
21 return {
22 pct: v.chat.pct,
23 tokens: fmtTokens(v.usage.tokens),
24 compactAt: fmtTokens(v.settings.compactAt),
25 forecast: turnsWords(turnsLeft(turnTokens, v.usage.tokens, v.settings.compactAt)),
26 tone: v.chat.tone,
27 isWorking: v.turn.isWorking,
28 ...squeezeArt(v),
29 }
30}
31
32/** While compacting the glass drains toward empty; once done it settles on the new level, then stands still. */
33function squeezeArt(v: View): Pick<ContextArt, 'drain' | 'isCompacting'> {
34 const s = v.squeeze
35 if (!s) return {}
36 if (s.doneAt === null) {
37 return { isCompacting: true, drain: { fromPct: s.fromPct, toPct: drainLevel(s.fromPct, DRAIN_MS), durS: DRAIN_MS / 1000, sinceS: (v.now - s.startedAt) / 1000 } }
38 }
39 const since = v.now - s.doneAt
40 if (since >= SETTLE_MS || s.doneLevel === null) return {}
41 return { drain: { fromPct: s.doneLevel, toPct: v.chat.pct, durS: SETTLE_MS / 1000, sinceS: since / 1000 } }
42}
43
44export function fiveArt(p: PaceView | null): GaugeArt {
45 if (!p) return { label: '5-hour', left: null, status: '', detail: 'Figures come with the first reply', tone: 'fine' }
46 const refill = `refills in ${fmtDuration(p.resetsInMs)}`
47 const leftAtRefill = p.projected === null || p.runsOutInMs !== null ? null : Math.max(0, 100 - p.projected)
48 if (leftAtRefill !== null) {
49 return { label: '5-hour', left: Math.max(0, 100 - p.pct), status: `≈${leftAtRefill}% left at refill`, detail: `in ${fmtDuration(p.resetsInMs)}`, tone: p.tone, leftAtRefill }
50 }
51 const status = p.pct >= 100 ? 'Empty' : p.runsOutInMs ? `Empty in ${fmtDuration(p.runsOutInMs)}` : cap(p.short)
52 return { label: '5-hour', left: Math.max(0, 100 - p.pct), status, detail: refill, tone: p.tone }
53}
54
55export function weekArt(p: PaceView | null): TankArt {
56 if (!p) return { label: 'Week', left: null, caption: 'no figures', tone: 'fine' }
57 return { label: 'Week', left: Math.max(0, 100 - p.pct), caption: `left · ${fmtDuration(p.resetsInMs).split(' ')[0]}`, tone: p.tone }
58}
59
60const coldFor = (ms: number) => (ms < MINUTE ? '<1m' : fmtDuration(Math.floor(ms / MINUTE) * MINUTE))
61
62export function cacheArt(v: View): CacheArt {
63 const { cache, warmth, ttl, now } = v
64 // Whole minutes, so the thermometer's picture changes once a minute and its frame is not reloaded on every tick.
65 const leftMin = Math.ceil(cache.leftMs / MINUTE)
66 if (cache.kind === 'warm') return { kind: 'warm', leftFrac: (leftMin * MINUTE) / ttl, value: fmtDuration(leftMin * MINUTE), caption: 'warm', isKeptWarm: v.isKeepingWarm, tone: cache.tone }
67 if (cache.kind === 'cold') return { kind: 'cold', leftFrac: 0, value: coldFor(now - warmth.lastRequestAt - ttl), caption: 'cold', isKeptWarm: v.isKeepingWarm, tone: cache.tone }
68 return { kind: 'none', leftFrac: 0, value: '—', caption: warmth.isCompacted ? 'compacted' : 'idle', isKeptWarm: v.isKeepingWarm, tone: 'fine' }
69}
70
71/** Whole minutes only, so a working agent's card changes once a minute rather than on every tick. */
72const minutes = (ms: number) => (ms < 60_000 ? '<1m' : fmtDuration(Math.floor(ms / 60_000) * 60_000))
73
74export function agentArt(a: AgentCard, now: number): AgentArt {
75 const took = minutes((a.endedAt ?? now) - a.startedAt)
76 const state = a.status === 'running' ? 'running' : a.status === 'done' ? 'done' : 'failed'
77 const doing = state === 'running' ? (a.doing ?? 'Starting') : state === 'done' ? `Done in ${took}` : 'Stopped'
78 const stats = `${fmtTokens(a.tokens)} tokens · ${a.steps} step${a.steps === 1 ? '' : 's'}`
79 return { id: a.id, type: a.type || 'agent', model: a.model, effort: a.effort ?? null, state, tool: a.tool, doing, elapsed: took, task: a.description, stats }
80}
81
82/** The agents at work, oldest first; a finished one leaves the stage. */
83export function crew(v: View): AgentArt[] {
84 return v.agents.filter(a => a.status === 'running').map(a => agentArt(a, v.now))
85}
86
87export function crewSummary(v: View): string {
88 const running = v.agents.filter(a => a.status === 'running').length
89 const done = v.agents.length - running
90 if (!v.agents.length) return 'Idle'
91 return [running ? `${running} working` : '', done ? `${done} done` : ''].filter(Boolean).join(' · ')
92}
93
94// ── Drawing ─────────────────────────────────────────────────────────────────
95
96/** What the glass drawings need of a surface: the elements every Svg surface carries. */
97type SvgEls = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
98
99const size = (source: string) => {
100 const m = /width="([\d.]+)" height="([\d.]+)"/.exec(source)
101 return { width: Number(m?.[1] ?? 0) || undefined, height: Number(m?.[2] ?? 0) || undefined }
102}
103
104/**
105 * Each frame's picture and the moment its loops were set. An unchanged picture in an unchanged layout keeps its
106 * source, so the frame isn't reloaded; a changed one, or any frame after the layout changed, is set afresh.
107 */
108const frames = new Map<string, { art: string; source: string }>()
109let drawNow = 0
110let isNewLayout = false
111let lastLayout = ''
112
113/** One glass tile: its own sandboxed, animated SVG at its own pixel size. */
114export function glassEl(Svg: SvgEls['Svg'], art: string, alt: string, key?: string) {
115 let source = art
116 if (key) {
117 const was = frames.get(key)
118 source = was && was.art === art && !isNewLayout ? was.source : phased(art, drawNow / 1000)
119 frames.set(key, { art, source })
120 }
121 return <Svg key={key} source={source} alt={alt} isInteractive {...size(art)} />
122}
123
124/** U+2800, a blank that is not whitespace: it keeps its width, so a run of them is a clickable span. */
125const BLANK = '\u2800'
126const CLEAR = { color: 'transparent', backgroundColor: 'transparent', inverse: false } as const
127
128/** Where a fallback Button sits: over a round button's icon; over a swatch's disc. */
129const ON_ICON = { left: 3, top: 2 }
130const ON_SWATCH = { left: 1, top: 1 }
131
132/** A pad's reach in cells (about 8 px wide, 17 px tall): a round button (76 px); a swatch (42 px). */
133const ROUND_PAD = { width: 9, height: 4 }
134const SWATCH_PAD = { width: 5, height: 2 }
135
136type Hit = { key: string; onPress: () => void; pad?: { width: number; height: number }; at?: { left: number; top: number }; cells?: number }
137
138/** What each pad on show does, by its key; refilled on every draw, answered at `ui.message`. */
139export const presses = new Map<string, () => void>()
140
141/**
142 * A pressable tile: the glass with a pad over all of it. The pad is a Client drawing nothing, so the desktop
143 * draws no hover; the tile's `alt` names it. Without Client, a plain Button of transparent blanks over the icon.
144 */
145function pressable(els: SvgEls, tileSvg: string, alt: string, hit: Hit, pads?: Pads) {
146 const { Box, Button, Svg, Text } = els
147 const padKey = `pad-${hit.key}`
148 const reach = hit.pad ?? ROUND_PAD
149 const pad = pads?.get(hit.key)
150 presses.set(padKey, hit.onPress)
151 return (
152 <Box key={`tile-${hit.key}`} position="relative" flexDirection="column">
153 {glassEl(Svg, tileSvg, alt, `svg-${hit.key}`)}
154 {pad ? (
155 <Box position="absolute" left={0} top={0} width={reach.width} height={reach.height}>
156 {pad}
157 </Box>
158 ) : (
159 <Box position="absolute" {...(hit.at ?? ON_ICON)}>
160 <Button key={hit.key} plain hover={CLEAR} onPress={hit.onPress}>
161 <Text color="transparent" hover={CLEAR}>{BLANK.repeat(hit.cells ?? 4)}</Text>
162 </Button>
163 </Box>
164 )}
165 </Box>
166 )
167}
168
169/**
170 * The footer's mode labels with the Control Center's button after them, at the right of the prompt: control
171 * two switches (⚌) while it is closed, a × while it is open, both white. The footer draws text and buttons only, no Svg.
172 */
173export function drawLaunch(els: Pick<SvgEls, 'Box' | 'Button' | 'Text'>, modes: readonly string[], isOpen: boolean, toggle: () => void) {
174 const { Box, Button, Text } = els
175 return (
176 <Box key="launch-row" flexDirection="row" alignItems="center" gap={1}>
177 {modes.length ? <Text dimColor>{modes.join(' & ')}</Text> : null}
178 <Button key="launch" plain onPress={toggle}>
179 <Text color="#FFFFFF" size="heading" weight="semibold">{isOpen ? '✕' : '⚌'}</Text>
180 </Button>
181 </Box>
182 )
183}
184
185/** Every pad the glass can show, by its tile's key, with its reach: the round buttons, then the Appearance swatches. */
186export function padSpots() {
187 return [
188 ...['shrink', 'handoff', 'warm', 'look'].map(key => ({ key, ...ROUND_PAD })),
189 ...['none', ...Object.keys(GLASS_COLORS)].map(name => ({ key: `color-${name}`, ...SWATCH_PAD })),
190 ...OPACITY_STEPS.map(opacity => ({ key: `opacity-${opacity}`, ...SWATCH_PAD })),
191 ]
192}
193
194const isBusy = (v: View, key: string) => v.pressed?.key === key && v.pressed.isBusy
195
196/** The steps the opacity row offers: Apple's clear and tinted, and between. */
197const OPACITY_STEPS = [0, 20, 40, 58, 80, 100]
198
199/** The Appearance panel: every colour, then the opacity steps, each a tap; the chosen ones ringed. */
200function drawAppearance(act: Actions, els: SvgEls, v: View, pads?: Pads) {
201 const { Box, Svg } = els
202 const colors: [string, string | null][] = [['none', null], ...Object.entries(GLASS_COLORS)]
203 const swatch = ([name, color]: [string, string | null]) =>
204 pressable(els, swatchTile({ color, name, isSelected: v.look.color === color }), `Glass colour ${name}`, {
205 key: `color-${name}`, pad: SWATCH_PAD, at: ON_SWATCH, cells: 3, onPress: () => void act.changeLook({ color }),
206 }, pads)
207 const step = (opacity: number) =>
208 pressable(els, opacityTile({ opacity, color: v.look.color, isSelected: v.look.opacity === opacity }), `Glass opacity ${opacity}%`, {
209 key: `opacity-${opacity}`, pad: SWATCH_PAD, at: ON_SWATCH, cells: 3, onPress: () => void act.changeLook({ opacity }),
210 }, pads)
211 return (
212 <Box key="appearance" position="relative" flexDirection="column">
213 {glassEl(Svg, appearanceTile(), 'Appearance', 'svg-appearance')}
214 <Box position="absolute" left={1} top={2} flexDirection="column">
215 <Box flexDirection="row">{colors.slice(0, 7).map(swatch)}</Box>
216 <Box flexDirection="row">{colors.slice(7).map(swatch)}</Box>
217 <Box flexDirection="row">{OPACITY_STEPS.map(step)}</Box>
218 </Box>
219 </Box>
220 )
221}
222
223/** The agents column's width: beside the grid when the pane has room for it, else below at the grid's width. */
224export function agentsWidth(bodyColumns: number): { width: number; isBeside: boolean } {
225 const spare = Math.round(bodyColumns * CELL_PX) - FULL - GAP
226 return spare >= 240 ? { width: Math.min(spare, 380), isBeside: true } : { width: FULL, isBeside: false }
227}
228
229/** About how many CSS pixels a desktop cell is. */
230const CELL_PX = 8
231
232export function drawGlassTiles(act: Actions, els: SvgEls, v: View, turnTokens: readonly number[], bodyColumns = 0, pads?: Pads) {
233 const { Box, Svg } = els
234 setLook(v.look)
235 presses.clear()
236 const chatBig = v.chat.pct >= 60 || v.act?.kind === 'full' || v.act?.kind === 'cold'
237 const ctx = contextArt(v, turnTokens)
238 const five = fiveArt(v.five)
239 const week = weekArt(v.week)
240 const cache = cacheArt(v)
241 const warmLeft = v.warmth.keepWarmUntil - v.now
242 const compactArt: RoundArt = {
243 kind: 'compact', isBusy: isBusy(v, 'shrink'), isSuggested: chatBig,
244 title: isBusy(v, 'shrink') ? 'Compacting…' : 'Compact: summarise this chat to free room',
245 }
246 const handoffArt: RoundArt = {
247 kind: 'handoff', isBusy: isBusy(v, 'handoff'), isSuggested: v.freshReason !== null,
248 title: 'Handoff: checks git, writes HANDOFF.md with next steps, and gives you a line to start a fresh chat',
249 }
250 const warmArt: RoundArt = {
251 kind: 'warm', isOn: v.isKeepingWarm,
252 title: v.isKeepingWarm
253 ? `Keep warm is on for ${fmtDuration(warmLeft)}: a tiny ping before the cache expires keeps this chat cheap to come back to.`
254 : 'Keep warm: a tiny ping before the cache expires, so coming back to this chat stays cheap. Each ping is a small request.',
255 }
256 const lookArt: RoundArt = { kind: 'look', isOn: v.isCustomizing, title: v.isCustomizing ? 'Done customizing' : 'Customize the glass: colour and opacity' }
257 const side = agentsWidth(bodyColumns)
258 // A change of layout can reload every frame, so each is set to the clock afresh.
259 const layout = `${v.isCustomizing}|${bodyColumns}`
260 drawNow = v.now
261 isNewLayout = layout !== lastLayout
262 lastLayout = layout
263 return (
264 <Box key="glass" flexDirection="row" flexWrap="wrap" alignItems="flex-start">
265 <Box key="grid" flexDirection="column" alignItems="flex-start">
266 <Box key="row-1" flexDirection="row">
267 {glassEl(Svg, contextTile(ctx), `Context ${ctx.pct}% of the way to auto-compact, ${ctx.tokens} of ${ctx.compactAt}. ${ctx.forecast}`, 'svg-context')}
268 {glassEl(Svg, gaugeTile(five), `5-hour limit ${five.left === null ? 'unknown' : `${five.left}% left`}: ${five.status ? `${five.status}, ` : ''}${five.detail}`, 'svg-five')}
269 </Box>
270 <Box key="row-2" flexDirection="row" alignItems="flex-start">
271 {glassEl(Svg, cacheTile(cache), `Cache ${cache.caption}: ${cache.value}`, 'svg-cache')}
272 {glassEl(Svg, tankTile(week), `Weekly limit ${week.left === null ? 'unknown' : `${week.left}% left`}, refills in ${week.caption.replace('left · ', '')}`, 'svg-week')}
273 <Box flexDirection="column">
274 <Box flexDirection="row">
275 {pressable(els, roundTile(compactArt), compactArt.isBusy ? 'Compacting' : 'Compact', {
276 key: 'shrink', onPress: () => void act.pressOnce('shrink', () => act.shrink()),
277 }, pads)}
278 {pressable(els, roundTile(handoffArt), handoffArt.isBusy ? 'Writing the handoff' : 'Handoff', {
279 key: 'handoff', onPress: () => void act.pressOnce('handoff', () => act.handoff('full')),
280 }, pads)}
281 </Box>
282 <Box flexDirection="row">
283 {pressable(els, roundTile(warmArt), v.isKeepingWarm ? 'Keep-warm is on' : 'Keep-warm is off', {
284 key: 'warm', onPress: () => void act.pressOnce('warm', () => act.setKeepWarm(!v.isKeepingWarm)),
285 }, pads)}
286 {pressable(els, roundTile(lookArt), v.isCustomizing ? 'Done customizing' : 'Customize', {
287 key: 'look', onPress: () => void act.toggleCustomize(),
288 }, pads)}
289 </Box>
290 </Box>
291 </Box>
292 {v.isCustomizing ? drawAppearance(act, els, v, pads) : null}
293 </Box>
294 <Box key="agents" marginLeft={side.isBeside ? 1 : 0}>
295 {glassEl(Svg, agentsTile(crew(v), crewSummary(v), side.width), `Agents: ${crewSummary(v)}`, 'svg-agents')}
296 </Box>
297 </Box>
298 )
299}
300hooks/model.ts 435 lines1// Cockpit's pure maths: settings, formatting, chat fill, cache warmth, limit pace, forecasts,
2// the launcher's ranking and the cold-send guard. No `$` here, so every rule is unit-tested.
3
4import type { Launcher, Limit, Tone, Usage } from '../types'
5import { SPARK } from './theme'
6
7export const MINUTE = 60_000
8export const HOUR = 60 * MINUTE
9export const FIVE_HOURS = 5 * HOUR
10export const WEEK = 7 * 24 * HOUR
11export const DEFAULT_COMPACT_AT = 400_000
12
13// ── Settings ────────────────────────────────────────────────────────────────
14
15export type Settings = {
16 compactAt: number
17 cacheLife: 'auto' | '1h' | '5m'
18 density: 'calm' | 'standard' | 'full'
19 keepWarm: boolean
20 keepWarmMs: number
21 guardTokens: number
22 openAtStart: boolean
23}
24
25const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
26const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T =>
27 typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback
28
29/** The plugin's `userConfig` values, with the person's `autoCompactWindow` as the compact point when unset. */
30export function readSettings(options: Readonly<Record<string, unknown>>, autoCompactWindow: unknown): Settings {
31 const configured = num(options.compactAt, 0)
32 const fromSettings = num(autoCompactWindow, 0)
33 return {
34 compactAt: configured > 0 ? configured : fromSettings > 0 ? fromSettings : DEFAULT_COMPACT_AT,
35 cacheLife: pick(options.cacheLife, ['auto', '1h', '5m'] as const, 'auto'),
36 density: pick(options.density, ['calm', 'standard', 'full'] as const, 'standard'),
37 keepWarm: options.keepWarm === true,
38 keepWarmMs: Math.max(0.25, num(options.keepWarmHours, 3)) * HOUR,
39 guardTokens: Math.max(0, num(options.guardTokens, 100_000)),
40 openAtStart: options.openAtStart !== false,
41 }
42}
43
44/** How long the prompt cache lives: 1 hour on a subscription, 5 minutes on an API key. */
45export function cacheTtl(settings: Settings, isPlan: boolean): number {
46 if (settings.cacheLife === '1h') return HOUR
47 if (settings.cacheLife === '5m') return 5 * MINUTE
48 return isPlan ? HOUR : 5 * MINUTE
49}
50
51/** The engine's usage figures as Cockpit keeps them. */
52export function toUsage(u: {
53 context: { tokens?: number; window: number }
54 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
55 cost?: { usd: number }
56}): Usage {
57 const limit = (kind: string): Limit | null => {
58 const r = u.rateLimits.find(x => x.kind === kind)
59 if (!r) return null
60 const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
61 return { percentUsed: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
62 }
63 const five = limit('five_hour')
64 const week = limit('seven_day')
65 return {
66 tokens: u.context.tokens ?? 0,
67 window: u.context.window,
68 five,
69 week,
70 costUsd: u.cost ? u.cost.usd : null,
71 isPlan: five !== null || week !== null,
72 }
73}
74
75/**
76 * A limit only climbs within its window. A reading with no figure (some carry none, such as the one after a
77 * compact) keeps the last one, and a lower figure for the same window keeps the higher; only a window that has
78 * reset, its old reset time passed or a new one given, may start lower.
79 */
80export function holdLimit(prev: Limit | null, next: Limit | null, now: number): Limit | null {
81 if (!prev) return next
82 const hasReset = prev.resetsAt !== null && now >= prev.resetsAt
83 if (!next) return hasReset ? null : prev
84 const isNewWindow = hasReset || (prev.resetsAt !== null && next.resetsAt !== null && Math.abs(next.resetsAt - prev.resetsAt) > 30 * MINUTE)
85 if (isNewWindow || next.percentUsed >= prev.percentUsed) return next
86 return { ...next, percentUsed: prev.percentUsed }
87}
88
89// ── Formatting ──────────────────────────────────────────────────────────────
90
91export function fmtTokens(n: number): string {
92 if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`
93 if (n >= 1000) return `${Math.round(n / 1000)}k`
94 return String(Math.max(0, Math.round(n)))
95}
96
97export function fmtDuration(ms: number): string {
98 const s = Math.max(0, Math.round(ms / 1000))
99 if (s < 60) return `${s}s`
100 const m = Math.floor(s / 60)
101 if (m < 60) return `${m}m`
102 const h = Math.floor(m / 60)
103 if (h < 48) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`
104 return h % 24 === 0 ? `${Math.floor(h / 24)}d` : `${Math.floor(h / 24)}d ${h % 24}h`
105}
106
107export function fmtUsd(usd: number): string {
108 return `$${usd >= 100 ? usd.toFixed(0) : usd.toFixed(2)}`
109}
110
111/** `claude-sonnet-5-5-20261001` reads as `Sonnet 5.5`. */
112export function shortModel(model: string): string {
113 const m = /(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(model)
114 const [, family = '', major = '', minor] = m ?? []
115 if (!family) return model || 'model'
116 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
117 return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
118}
119
120// ── Chat fill ───────────────────────────────────────────────────────────────
121
122export type ChatView = { pct: number; notchFrac: number; windowFrac: number; tone: Tone }
123
124/** Fill measured against the compact point; the bar spans the window with a notch where compaction starts. */
125export function chatView(tokens: number, compactAt: number, window: number): ChatView {
126 const pct = Math.round((tokens / Math.max(1, compactAt)) * 100)
127 const span = Math.max(window, compactAt, 1)
128 return {
129 pct,
130 notchFrac: Math.min(1, compactAt / span),
131 windowFrac: Math.min(1, tokens / span),
132 tone: pct >= 85 ? 'act' : pct >= 60 ? 'watch' : 'fine',
133 }
134}
135
136export type Cell = 'fill' | 'empty' | 'notch'
137
138/** A bar of `cells` cells filled to `frac`, with a notch marking `notchFrac` when given. */
139export function barCells(frac: number, cells: number, notchFrac?: number): Cell[] {
140 const filled = Math.round(Math.min(1, Math.max(0, frac)) * cells)
141 const notch = notchFrac === undefined || notchFrac >= 1 ? -1 : Math.min(cells - 1, Math.round(notchFrac * cells))
142 return Array.from({ length: cells }, (_, i): Cell => (i === notch ? 'notch' : i < filled ? 'fill' : 'empty'))
143}
144
145/** Consecutive cells of one kind, so a bar draws as a few runs of text. */
146export function runs(cells: Cell[]): { kind: Cell; length: number }[] {
147 const out: { kind: Cell; length: number }[] = []
148 for (const c of cells) {
149 const last = out[out.length - 1]
150 if (last && last.kind === c) last.length++
151 else out.push({ kind: c, length: 1 })
152 }
153 return out
154}
155
156/** Turns left before auto-compact, from how much each recent turn grew the chat. */
157export function turnsLeft(turnTokens: readonly number[], tokens: number, compactAt: number): number | null {
158 if (tokens >= compactAt) return 0
159 const growth: number[] = []
160 turnTokens.forEach((t, i) => {
161 const d = i > 0 ? t - (turnTokens[i - 1] ?? t) : 0
162 if (d > 0) growth.push(d)
163 })
164 if (growth.length < 2) return null
165 const recent = growth.slice(-8)
166 const avg = recent.reduce((a, b) => a + b, 0) / recent.length
167 return avg > 0 ? Math.floor((compactAt - tokens) / avg) : null
168}
169
170export function turnsWords(n: number | null): string {
171 if (n === null) return 'A forecast appears after a few turns.'
172 if (n === 0) return 'Auto-compact is due on the next turn.'
173 if (n > 50) return 'Plenty of room: 50+ turns before auto-compact.'
174 return `About ${n} turn${n === 1 ? '' : 's'} before auto-compact.`
175}
176
177// ── Cache warmth ────────────────────────────────────────────────────────────
178
179export type CacheView = { kind: 'none' | 'warm' | 'cold'; leftMs: number; tone: Tone }
180
181export function cacheView(
182 lastRequestAt: number,
183 isCompacted: boolean,
184 ttl: number,
185 now: number,
186 tokens: number,
187 guardTokens: number,
188): CacheView {
189 if (!lastRequestAt || isCompacted) return { kind: 'none', leftMs: 0, tone: 'fine' }
190 const leftMs = lastRequestAt + ttl - now
191 if (leftMs > 0) return { kind: 'warm', leftMs, tone: leftMs < Math.min(10 * MINUTE, ttl * 0.2) ? 'watch' : 'fine' }
192 const big = guardTokens > 0 ? guardTokens : 100_000
193 return { kind: 'cold', leftMs: 0, tone: tokens >= big ? 'act' : tokens >= 20_000 ? 'watch' : 'fine' }
194}
195
196export function isCold(lastRequestAt: number, isCompacted: boolean, ttl: number, now: number): boolean {
197 return lastRequestAt > 0 && !isCompacted && now - lastRequestAt >= ttl
198}
199
200// ── Limit pace ──────────────────────────────────────────────────────────────
201
202export type PaceView = {
203 pct: number
204 elapsedFrac: number
205 resetsInMs: number
206 projected: number | null
207 runsOutInMs: number | null
208 tone: Tone
209 short: string
210 words: string
211}
212
213/** Where a rate-limit window is heading at the current pace, in a word and in a sentence. */
214export function paceView(limit: Limit | null, windowMs: number, now: number): PaceView | null {
215 if (!limit) return null
216 const pct = Math.round(limit.percentUsed)
217 const resetsInMs = Math.max(0, limit.resetsAt === null ? windowMs : limit.resetsAt - now)
218 const elapsedFrac = Math.min(1, Math.max(0, 1 - resetsInMs / windowMs))
219 const resets = `the reset in ${fmtDuration(resetsInMs)}`
220 if (pct >= 100) {
221 return { pct, elapsedFrac, resetsInMs, projected: null, runsOutInMs: 0, tone: 'act', short: 'used up', words: `Used up. It comes back at ${resets}.` }
222 }
223 if (elapsedFrac < 0.05 || limit.percentUsed < 1) {
224 return { pct, elapsedFrac, resetsInMs, projected: null, runsOutInMs: null, tone: 'fine', short: 'fresh', words: 'Fresh window, plenty of room.' }
225 }
226 const projected = Math.round(limit.percentUsed / elapsedFrac)
227 const perMs = limit.percentUsed / (elapsedFrac * windowMs)
228 const runsOutInMs = (100 - limit.percentUsed) / perMs
229 if (projected >= 100 && runsOutInMs < resetsInMs) {
230 return {
231 pct, elapsedFrac, resetsInMs, projected, runsOutInMs,
232 tone: pct >= 40 ? 'act' : 'watch',
233 short: 'burning fast',
234 words: `At this pace it runs out in ${fmtDuration(runsOutInMs)}, ${fmtDuration(resetsInMs - runsOutInMs)} before ${resets}.`,
235 }
236 }
237 const tone: Tone = pct >= 90 ? 'act' : projected >= 80 ? 'watch' : 'fine'
238 const words = projected >= 80 ? `Tight: about ${projected}% used by ${resets}.` : `Lasts until ${resets}, about ${projected}% used by then.`
239 return { pct, elapsedFrac, resetsInMs, projected, runsOutInMs: null, tone, short: projected >= 80 ? 'tight' : 'on pace', words }
240}
241
242// ── When to act ─────────────────────────────────────────────────────────────
243
244export type Act = { kind: 'cold' | 'limit' | 'full'; text: string; short: string }
245
246/** The one reason, if any, the band grows a second line. */
247export function actNeeded(chat: ChatView, cache: CacheView, five: PaceView | null, tokens: number): Act | null {
248 if (cache.kind === 'cold' && cache.tone === 'act') {
249 return {
250 kind: 'cold',
251 text: `Cache went cold and this chat is ${fmtTokens(tokens)}. Your next message re-reads all of it.`,
252 short: `Cache cold · next message re-reads ${fmtTokens(tokens)}`,
253 }
254 }
255 if (five && five.tone === 'act') {
256 const short = five.runsOutInMs ? `5h limit runs out in ${fmtDuration(five.runsOutInMs)}` : `5h limit ${five.short}`
257 return { kind: 'limit', text: `5-hour limit: ${five.words}`, short }
258 }
259 if (chat.pct >= 90) {
260 return { kind: 'full', text: `This chat is ${chat.pct}% of the way to auto-compact. Shrink it now, on your terms.`, short: `Chat ${chat.pct}% to auto-compact` }
261 }
262 return null
263}
264
265/** Why a fresh chat clearly pays off now, or null when it does not. */
266export function startFreshReason(chat: ChatView, cache: CacheView): string | null {
267 if (cache.kind === 'cold' && cache.tone === 'act') return 'The cache is cold, so a fresh chat costs less than re-reading this one.'
268 if (chat.pct >= 60) return 'This chat is big. A fresh one starts small and answers faster.'
269 return null
270}
271
272// ── Cold-send guard and keep-warm ───────────────────────────────────────────
273
274/** Prompts no person sent: refusing one would lose it, so they are only warned about. */
275const UNATTENDED = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'coordinator', 'observer', 'observer-activity'])
276
277export type GuardInput = {
278 isCold: boolean
279 tokens: number
280 guardTokens: number
281 originKind: string
282 text: string
283 ackedAt: number
284 lastRequestAt: number
285}
286
287export function guardDecision(g: GuardInput): 'pass' | 'warn' | 'drop' {
288 if (g.guardTokens <= 0 || g.originKind === 'plugin' || g.text.trimStart().startsWith('/')) return 'pass'
289 if (!g.isCold || g.tokens < g.guardTokens) return 'pass'
290 if (UNATTENDED.has(g.originKind)) return 'warn'
291 return g.ackedAt === g.lastRequestAt ? 'pass' : 'drop'
292}
293
294/** When the next keep-warm ping is due: a safe margin before the cache would go cold. */
295export function pingDelay(lastRequestAt: number, ttl: number, now: number): number {
296 const margin = Math.min(10 * MINUTE, ttl * 0.2)
297 return Math.max(1000, lastRequestAt + ttl - margin - now)
298}
299
300/** A warm ping reads the cached prefix and writes only its own short message. */
301export function isWarmPing(u: { cache_read_input_tokens: number; cache_creation_input_tokens: number }): boolean {
302 return u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
303}
304
305// ── Sparkline ───────────────────────────────────────────────────────────────
306
307/** Percent readings as block glyphs, newest at the right. */
308export function sparkline(values: readonly number[], width: number): string {
309 return values
310 .slice(-width)
311 .map(v => SPARK[Math.min(SPARK.length - 1, Math.max(0, Math.round((v / 100) * (SPARK.length - 1))))])
312 .join('')
313}
314
315const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
316
317function base64(bytes: Uint8Array): string {
318 let out = ''
319 for (let i = 0; i < bytes.length; i += 3) {
320 const a = bytes[i] ?? 0
321 const b = bytes[i + 1] ?? 0
322 const c = bytes[i + 2] ?? 0
323 out += B64.charAt(a >> 2) + B64.charAt(((a & 3) << 4) | (b >> 4))
324 out += i + 1 < bytes.length ? B64.charAt(((b & 15) << 2) | (c >> 6)) : '='
325 out += i + 2 < bytes.length ? B64.charAt(c & 63) : '='
326 }
327 return out
328}
329
330/** One row of `Raster` cells for `text`, each glyph in the terminal's default colours. */
331export function rasterRow(text: string): string {
332 const glyphs = [...text]
333 const words = new Uint32Array(glyphs.length * 3)
334 glyphs.forEach((g, i) => {
335 words[i * 3] = g.codePointAt(0) ?? 0x20
336 words[i * 3 + 1] = 0x01000000
337 words[i * 3 + 2] = 0x01000000
338 })
339 return base64(new Uint8Array(words.buffer))
340}
341
342// ── Launcher ────────────────────────────────────────────────────────────────
343
344export type Cmd = { name: string; description: string }
345export type Ranked = Cmd & { group: 'pinned' | 'recent' | 'match' }
346
347/** How well a command matches what was typed: name prefix, then name, then letters in order, then description. */
348export function score(query: string, cmd: Cmd): number {
349 const q = query.trim().toLowerCase()
350 if (!q) return 1
351 const name = cmd.name.toLowerCase()
352 if (name.startsWith(q)) return 100 - Math.min(50, name.length)
353 if (name.includes(q)) return 60
354 let at = 0
355 for (const ch of name) if (ch === q[at]) at++
356 if (at === q.length) return 30
357 return cmd.description.toLowerCase().includes(q) ? 10 : 0
358}
359
360/** Pinned, then recent, then the rest when nothing is typed; the best matches when something is. */
361export function rankCommands(commands: readonly Cmd[], query: string, launcher: Launcher, limit = 12): Ranked[] {
362 const byName = new Map(commands.map(c => [c.name, c]))
363 if (query.trim()) {
364 return commands
365 .map(c => ({ c, s: score(query, c) }))
366 .filter(x => x.s > 0)
367 .sort((a, b) => b.s - a.s || a.c.name.localeCompare(b.c.name))
368 .slice(0, limit)
369 .map(({ c }) => ({ ...c, group: launcher.pinned.includes(c.name) ? 'pinned' : launcher.recent.includes(c.name) ? 'recent' : 'match' }))
370 }
371 const seen = new Set<string>()
372 const out: Ranked[] = []
373 const take = (name: string, group: Ranked['group']) => {
374 const c = byName.get(name)
375 if (!c || seen.has(name) || out.length >= limit) return
376 seen.add(name)
377 out.push({ ...c, group })
378 }
379 launcher.pinned.forEach(n => take(n, 'pinned'))
380 launcher.recent.slice(0, 5).forEach(n => take(n, 'recent'))
381 return out
382}
383
384export function pushRecent(recent: readonly string[], name: string, max = 8): string[] {
385 return [name, ...recent.filter(n => n !== name)].slice(0, max)
386}
387
388export function togglePin(pinned: readonly string[], name: string): string[] {
389 return pinned.includes(name) ? pinned.filter(n => n !== name) : [...pinned, name].slice(0, 9)
390}
391
392// ── What an agent is doing ──────────────────────────────────────────────────
393
394const base = (path: unknown) => (typeof path === 'string' ? path.split('/').filter(Boolean).pop() ?? path : '')
395const quoted = (x: unknown, max = 18) => {
396 const s = typeof x === 'string' ? x.trim() : ''
397 return s.length > max ? `"${s.slice(0, max - 1)}…"` : `"${s}"`
398}
399
400/** A tool call in a few plain words: `Reading model.ts`, `Searching "agent.spawn"`, `Running npm test`. */
401export function doingOf(tool: string, input: Readonly<Record<string, unknown>>): string {
402 switch (tool) {
403 case 'Read':
404 return `Reading ${base(input.file_path) || 'a file'}`
405 case 'Edit':
406 case 'MultiEdit':
407 case 'NotebookEdit':
408 return `Editing ${base(input.file_path ?? input.notebook_path) || 'a file'}`
409 case 'Write':
410 return `Writing ${base(input.file_path) || 'a file'}`
411 case 'Grep':
412 return `Searching ${quoted(input.pattern)}`
413 case 'Glob':
414 return `Finding ${typeof input.pattern === 'string' ? input.pattern : 'files'}`
415 case 'Bash': {
416 if (typeof input.description === 'string' && input.description) return input.description
417 const cmd = typeof input.command === 'string' ? input.command.trim().split(/\s+/).slice(0, 2).join(' ') : ''
418 return cmd ? `Running ${cmd}` : 'Running a command'
419 }
420 case 'WebFetch': {
421 const m = typeof input.url === 'string' ? /^https?:\/\/([^/]+)/.exec(input.url) : null
422 return `Reading ${m?.[1] ?? 'a web page'}`
423 }
424 case 'WebSearch':
425 return `Searching the web`
426 case 'Agent':
427 case 'Task':
428 return 'Delegating'
429 default: {
430 const mcp = /^mcp__[^_]+(?:_[^_]+)*__(.+)$/.exec(tool)
431 return `Using ${(mcp?.[1] ?? tool).replace(/_/g, ' ')}`
432 }
433 }
434}
435hooks/pane.tsx 265 lines1// The Control Center pane: Session, Limits, Activity, Actions and the Launcher, top to bottom.
2
3import type { Elements, RenderSurface } from 'claude-code'
4
5import type { AgentCard, Launcher, Sample } from '../types'
6import {
7 barCells, fmtDuration, fmtTokens, fmtUsd, rankCommands, rasterRow, runs, shortModel, sparkline, turnsLeft, turnsWords,
8} from './model'
9import type { Cmd, PaceView } from './model'
10import { drawGlassTiles } from './glass'
11import type { Actions, Pads, View } from './view'
12import { ACCENT, GLYPH, MUTED, TONE, spinner } from './theme'
13
14type Els = Elements[RenderSurface]
15
16export type PaneData = {
17 /** The press pad for glass tiles, where the surface has one. */
18 pads?: Pads
19 bodyColumns: number
20 isTerminal: boolean
21 turnTokens: readonly number[]
22 samples: readonly Sample[]
23 commands: readonly Cmd[]
24 query: string
25 launcher: Launcher
26 open: string | null
27}
28
29const toned = (tone: 'fine' | 'watch' | 'act') => (tone === 'fine' ? undefined : TONE[tone])
30
31export function drawPane(act: Actions, els: Els, d: PaneData, v: View) {
32 const { Box, Text, Button } = els
33 const cols = Math.max(24, d.bodyColumns)
34 const barWidth = Math.max(10, Math.min(40, cols - 14))
35
36 const heading = (text: string) => (
37 <Text bold color="text">
38 {text}
39 </Text>
40 )
41
42 const bar = (frac: number, cells: number, color: string, notch?: number) => (
43 <Text>
44 {runs(barCells(frac, cells, notch)).map(r =>
45 r.kind === 'fill' ? (
46 <Text color={color}>{GLYPH.fill.repeat(r.length)}</Text>
47 ) : r.kind === 'notch' ? (
48 <Text color="text">{GLYPH.notch.repeat(r.length)}</Text>
49 ) : (
50 <Text color={MUTED} dimColor>
51 {GLYPH.empty.repeat(r.length)}
52 </Text>
53 ),
54 )}
55 </Text>
56 )
57
58 // ── 1. Session ──
59 const { chat, cache, usage, settings, warmth } = v
60 const cacheLine =
61 cache.kind === 'warm'
62 ? { text: `warm · ${fmtDuration(cache.leftMs)} left`, color: toned(cache.tone) }
63 : cache.kind === 'cold'
64 ? { text: `cold for ${fmtDuration(v.now - warmth.lastRequestAt - v.ttl)}. The next message re-reads the whole chat.`, color: toned(cache.tone) }
65 : { text: 'starts with the next reply', color: MUTED }
66 const keepLine = v.isKeepingWarm
67 ? `${GLYPH.warm} Keep-warm on for ${fmtDuration(warmth.keepWarmUntil - v.now)}${warmth.lastPing ? `, last ping read ${fmtTokens(warmth.lastPing.read)}` : ''}`
68 : warmth.stopped
69 ? `Keep-warm stopped: ${warmth.stopped}.`
70 : null
71
72 const session = (
73 <Box key="session" flexDirection="column">
74 {heading('Session')}
75 <Box flexDirection="row" gap={1}>
76 {bar(chat.windowFrac, barWidth, toned(chat.tone) ?? ACCENT, chat.notchFrac)}
77 <Text color={toned(chat.tone)}>{`${chat.pct}%`}</Text>
78 </Box>
79 <Text dimColor>
80 {`${fmtTokens(usage.tokens)} of ${fmtTokens(settings.compactAt)} compact point · ${fmtTokens(usage.window)} window · ${GLYPH.notch} marks auto-compact`}
81 </Text>
82 <Text dimColor>{turnsWords(turnsLeft(d.turnTokens, usage.tokens, settings.compactAt))}</Text>
83 <Text>
84 <Text dimColor>{'Cache '}</Text>
85 <Text color={cacheLine.color}>{cacheLine.text}</Text>
86 </Text>
87 {keepLine ? <Text color={v.isKeepingWarm ? ACCENT : MUTED}>{keepLine}</Text> : null}
88 {usage.costUsd !== null && usage.costUsd > 0 ? (
89 <Text dimColor>{`Worth ${fmtUsd(usage.costUsd)} at API prices${usage.isPlan ? ' · not a bill on your plan' : ''}`}</Text>
90 ) : null}
91 </Box>
92 )
93
94 // ── 2. Limits ──
95 const limit = (key: string, label: string, p: PaceView) => (
96 <Box key={key} flexDirection="column">
97 <Box flexDirection="row" gap={1}>
98 <Text dimColor>{label.padEnd(7)}</Text>
99 {bar(p.pct / 100, barWidth, toned(p.tone) ?? ACCENT)}
100 <Text color={toned(p.tone)}>{`${p.pct}%`}</Text>
101 </Box>
102 <Box flexDirection="row" gap={1}>
103 <Text>{' '.repeat(7)}</Text>
104 <Text color={MUTED} dimColor>
105 {GLYPH.elapsed.repeat(Math.round(p.elapsedFrac * barWidth)).padEnd(barWidth)}
106 </Text>
107 <Text dimColor>{`${Math.round(p.elapsedFrac * 100)}% of time`}</Text>
108 </Box>
109 <Text color={toned(p.tone)} dimColor={p.tone === 'fine'}>
110 {`${' '.repeat(8)}${p.words}`}
111 </Text>
112 </Box>
113 )
114 const trend = d.samples.filter(s => s.five !== null).map(s => s.five as number)
115 const spark = sparkline(trend, Math.min(48, cols - 10))
116 const limits =
117 v.five || v.week ? (
118 <Box key="limits" flexDirection="column">
119 {heading('Limits')}
120 {v.five ? limit('five', '5-hour', v.five) : null}
121 {v.week ? limit('week', 'Week', v.week) : null}
122 {spark.length >= 2 ? (
123 <Box flexDirection="row" gap={1}>
124 <Text dimColor>{'Trend '}</Text>
125 {'Raster' in els ? <els.Raster key="trend" columns={[...spark].length} rows={1} cells={rasterRow(spark)} /> : <Text color={ACCENT}>{spark}</Text>}
126 <Text dimColor>5-hour use this session</Text>
127 </Box>
128 ) : null}
129 <Text dimColor>The thin line under each bar is time passed. Use ahead of time means you are spending fast.</Text>
130 </Box>
131 ) : (
132 <Box key="limits" flexDirection="column">
133 {heading('Limits')}
134 <Text dimColor>No plan limits reported yet. They appear after the first reply on a subscription.</Text>
135 </Box>
136 )
137
138 // ── 3. Activity ──
139 const t = v.turn
140 const running = v.agents.filter(a => a.status === 'running')
141 const cards = [...running, ...v.agents.filter(a => a.status !== 'running').reverse()].slice(0, 6)
142 const card = (a: AgentCard, n: number) => {
143 const isOpen = d.open === a.id
144 const age = a.status === 'running' ? fmtDuration(v.now - a.startedAt) : `done ${fmtDuration(v.now - (a.endedAt ?? v.now))} ago`
145 const mark = a.status === 'running' ? spinner(v.now) : a.status === 'done' ? '✓' : '✕'
146 const markColor = a.status === 'running' ? ACCENT : a.status === 'done' ? TONE.fine : TONE.act
147 return (
148 <Box key={`agent-${a.id}`} flexDirection="column">
149 <Button key={`agent-row-${a.id}`} plain onPress={() => void act.toggleExpanded(a.id)}>
150 <Text dimColor>{`${n} `}</Text>
151 <Text color={markColor}>{mark}</Text>
152 {` ${a.type} `}
153 <Text dimColor>{`${shortModel(a.model)} · ${age}`}</Text>
154 </Button>
155 {isOpen ? (
156 <Box flexDirection="column" paddingLeft={5}>
157 <Text>{a.description || 'No description.'}</Text>
158 <Text dimColor>{`${a.steps} model request${a.steps === 1 ? '' : 's'} · ${fmtTokens(a.tokens)} tokens · ${shortModel(a.model)}`}</Text>
159 </Box>
160 ) : null}
161 </Box>
162 )
163 }
164 const activity = (
165 <Box key="activity" flexDirection="column">
166 {heading('Activity')}
167 {t.isWorking ? (
168 <Text>
169 <Text color={ACCENT}>{spinner(v.now)}</Text>
170 {` Working ${t.startedAt ? fmtDuration(v.now - t.startedAt) : ''}`}
171 <Text dimColor>{` · ${t.tools} tool call${t.tools === 1 ? '' : 's'} · ${running.length} agent${running.length === 1 ? '' : 's'} running`}</Text>
172 </Text>
173 ) : (
174 <Text dimColor>{t.lastSeconds !== null ? `Idle. The last turn took ${fmtDuration(t.lastSeconds * 1000)} and ${t.tools} tool calls.` : 'Idle.'}</Text>
175 )}
176 {cards.length ? cards.map((a, i) => card(a, i + 1)) : <Text dimColor>No subagents yet this session.</Text>}
177 </Box>
178 )
179
180 // ── 4. Actions ──
181 const actions = (
182 <Box key="actions" flexDirection="column">
183 {heading('Actions')}
184 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
185 <Button key="shrink" label={usage.tokens > 0 ? `Shrink chat (${fmtTokens(usage.tokens)})` : 'Shrink chat'} hotkey="s" variant={v.act?.kind === 'full' || v.act?.kind === 'cold' ? 'primary' : undefined} onPress={() => void act.shrink()} />
186 <Button key="quick" label="Quick handoff" hotkey="h" variant={v.freshReason ? 'primary' : undefined} onPress={() => void act.handoff('quick')} />
187 <Button key="full" label="Full handoff" hotkey="f" onPress={() => void act.handoff('full')} />
188 <Button key="warm" label={v.isKeepingWarm ? 'Keep warm: on' : 'Keep warm: off'} hotkey="w" onPress={() => void act.setKeepWarm(!v.isKeepingWarm)} />
189 </Box>
190 {v.freshReason ? <Text color={TONE.watch}>{v.freshReason}</Text> : null}
191 <Text dimColor>Quick writes a note to paste into a fresh chat. Full also checks git and writes HANDOFF.md.</Text>
192 </Box>
193 )
194
195 // ── 5. Launcher ──
196 const ranked = rankCommands(d.commands, d.query, d.launcher)
197 const groupTitle = { pinned: 'Pinned', recent: 'Recent', match: 'Matches' } as const
198 const rows: unknown[] = []
199 let lastGroup = ''
200 ranked.forEach((c, i) => {
201 const group = d.query ? 'match' : c.group
202 if (group !== lastGroup) {
203 rows.push(<Text dimColor>{groupTitle[group]}</Text>)
204 lastGroup = group
205 }
206 const isPinned = d.launcher.pinned.includes(c.name)
207 const room = Math.max(0, cols - c.name.length - 12)
208 rows.push(
209 <Box key={`cmd-${c.name}`} flexDirection="row">
210 <Box flexGrow={1} flexShrink={1}>
211 <Button key={`run-${c.name}`} plain hotkey={i < 9 ? String(i + 1) : undefined} onPress={() => void act.launch(c.name)}>
212 {`/${c.name} `}
213 <Text dimColor>{c.description.length > room ? `${c.description.slice(0, Math.max(0, room - 1))}…` : c.description}</Text>
214 </Button>
215 </Box>
216 <Button key={`pin-${c.name}`} plain dimColor={!isPinned} label={isPinned ? '★' : '☆'} onPress={() => void act.pin(c.name)} />
217 </Box>,
218 )
219 })
220 const Input = 'Input' in els ? els.Input : null
221 const launcherSection = (
222 <Box key="launcher" flexDirection="column">
223 {heading('Launcher')}
224 {Input ? (
225 <Input
226 key="filter"
227 label="Find"
228 placeholder={`search ${d.commands.length} skills and commands`}
229 value={d.query}
230 onInput={(x: string) => void act.setFilter(x)}
231 onSubmit={(x: string) => void act.setFilter(x)}
232 />
233 ) : null}
234 {rows.length ? (rows as never[]) : <Text dimColor>Type to search. Press ☆ on a result to pin it here.</Text>}
235 </Box>
236 )
237
238 const noticeLine = v.notice ? (
239 <Text>
240 <Text color={TONE[v.notice.tone]}>{'› '}</Text>
241 {v.notice.text}
242 </Text>
243 ) : null
244
245 // The desktop draws Svg: the Liquid Glass Control Center alone, an always-on display; /cockpit opens and
246 // closes it. Notices reach it as toasts; warnings show in the tiles themselves.
247 if (!d.isTerminal && 'Svg' in els) return drawGlassTiles(act, els, v, d.turnTokens, d.bodyColumns, d.pads)
248
249 return (
250 <Box flexDirection="column" gap={1}>
251 {v.notice ? (
252 <Text>
253 <Text color={TONE[v.notice.tone]}>{'› '}</Text>
254 {v.notice.text}
255 </Text>
256 ) : null}
257 {session}
258 {limits}
259 {activity}
260 {actions}
261 {launcherSection}
262 </Box>
263 )
264}
265hooks/view.ts 65 lines1// What the drawings share with the hooks: the snapshot they read, the pane's name, the drain's timing, and the
2// actions a press can take. No engine calls here: every call on `$` lives in register.tsx, beside the hooks.
3
4import type { RenderChildren } from 'claude-code'
5
6import type { AgentCard, Look, Notice, Pressed, Squeeze, Tone, TurnInfo, Usage, Warmth } from '../types'
7import type { Act, CacheView, ChatView, PaceView, Settings } from './model'
8
9export const PANE = 'cockpit'
10export const PANE_TITLE = 'Control Center'
11/** How every open asks for the pane: inline above the prompt it wants room for all five sections. */
12export const PANE_OPEN = { id: PANE, title: PANE_TITLE, rows: 34 } as const
13
14export type View = {
15 now: number
16 usage: Usage
17 settings: Settings
18 ttl: number
19 chat: ChatView
20 cache: CacheView
21 five: PaceView | null
22 week: PaceView | null
23 turn: TurnInfo
24 agents: AgentCard[]
25 warmth: Warmth
26 notice: Notice | null
27 act: Act | null
28 freshReason: string | null
29 isKeepingWarm: boolean
30 pressed: Pressed | null
31 look: Look
32 isCustomizing: boolean
33 squeeze: Squeeze | null
34 tone: Tone
35}
36
37/** How long the Context glass takes to drain while compacting, and to settle on the new level afterwards. */
38export const DRAIN_MS = 40_000
39export const SETTLE_MS = 1_200
40/** What the drain leaves while the compaction is still running: a thin pool, not an empty glass. */
41export const DRAIN_FLOOR = 3
42
43/** Where a drain that began at `fromPct` stands `ms` in: a steady fall to the floor. */
44export const drainLevel = (fromPct: number, ms: number) =>
45 fromPct - (fromPct - Math.min(fromPct, DRAIN_FLOOR)) * Math.min(1, Math.max(0, ms / DRAIN_MS))
46
47/** What the band's and the pane's presses do; register.tsx makes one set per drawing. */
48export type Actions = {
49 openPane: () => Promise<unknown>
50 shrink: () => Promise<unknown>
51 handoff: (kind: 'quick' | 'full') => Promise<unknown>
52 setKeepWarm: (isOn: boolean) => Promise<unknown>
53 sendAnyway: () => Promise<unknown>
54 toggleExpanded: (id: string) => Promise<unknown>
55 launch: (name: string) => Promise<unknown>
56 pin: (name: string) => Promise<unknown>
57 setFilter: (text: string) => Promise<unknown>
58 changeLook: (change: Partial<Look>) => Promise<unknown>
59 toggleCustomize: () => Promise<unknown>
60 pressOnce: (key: string, work: () => Promise<unknown>) => Promise<unknown>
61}
62
63/** The invisible press pad over each glass tile, by the tile's key, on a surface that has them (./pad.tsx). */
64export type Pads = ReadonlyMap<string, RenderChildren>
65hooks/pad.tsx 35 lines1// A press pad for the desktop: a region that draws nothing, laid over a glass tile. The desktop gives a Client
2// pointer events but draws no hover of its own, so the glass is all that shows. A left press posts once;
3// register.tsx answers it at `ui.message`, keyed by the pad's `key`. The first click into an unfocused pane
4// reaches the pad as a release with no press before it, so that release counts as the press. It draws one
5// empty row per row it covers, since an empty region shrinks to a single row on the desktop.
6
7import type { ClientModule } from 'claude-code'
8
9type PadProps = { rows: number }
10
11const Pad: ClientModule<PadProps, true> = (props, surface) => {
12 if (surface.state === undefined) {
13 let isDown = false
14 surface.onPointer(e => {
15 if ((e.button ?? 'left') !== 'left') return
16 if (e.type === 'down') {
17 isDown = true
18 surface.post({ press: true })
19 } else if (e.type === 'up') {
20 if (!isDown) surface.post({ press: true })
21 isDown = false
22 } else if (e.type === 'leave') isDown = false
23 })
24 surface.setState(true)
25 }
26 const { Box } = surface.elements
27 return (
28 <Box flexDirection="column" width="100%" height={props.rows}>
29 {Array.from({ length: props.rows }, (_, i) => <Box key={`r${i}`} width="100%" height={1} flexShrink={0} />)}
30 </Box>
31 )
32}
33
34export default Pad
35hooks/theme.ts 46 lines1// Cockpit's design tokens. Only theme keys, so the band and pane follow the person's theme and any skin.
2
3import type { Tone } from '../types'
4
5/** The one three-state colour rule used everywhere: fine, watch, act. */
6export const TONE = { fine: 'success', watch: 'warning', act: 'error' } as const satisfies Record<Tone, string>
7
8export const ACCENT = 'claude'
9export const MUTED = 'subtle'
10export const QUIET = 'inactive'
11
12export const GLYPH = {
13 dot: '●',
14 sep: ' · ',
15 sepTight: ' · ',
16 fill: '━',
17 empty: '─',
18 notch: '┃',
19 elapsed: '▔',
20 act: '▲',
21 warm: '↻',
22 launcher: '⌘',
23} as const
24
25export const SPARK = '▁▂▃▄▅▆▇█'
26
27/** Spinner frames for the working state, one per tick. */
28const WORKING = ['◐', '◓', '◑', '◒'] as const
29
30/** The working glyph for this moment: it turns once per 5-second redraw. */
31export const spinner = (now: number): string => WORKING[Math.floor(now / 5000) % WORKING.length] ?? WORKING[0]
32
33export type Tier = 'compact' | 'regular' | 'wide'
34
35/** Band layout from the columns it may use: compact under 80, regular to 140, wide above. */
36export function tierOf(columns: number, density: string): Tier {
37 if (density === 'calm') return 'compact'
38 const wideFrom = density === 'full' ? 110 : 140
39 return columns < 80 ? 'compact' : columns <= wideFrom ? 'regular' : 'wide'
40}
41
42/** The harsher of several tones. */
43export function worst(...tones: Tone[]): Tone {
44 return tones.includes('act') ? 'act' : tones.includes('watch') ? 'watch' : 'fine'
45}
46hooks/art/tiles.ts 374 lines1// The Control Center tiles, each a Liquid Glass SVG document drawn from plain figures.
2// Grid: a 76-pixel unit with 10-pixel gaps, so a 2×2 tile is 162 square and the full width is 334.
3
4import { crab, crabTitle, breedOf, effortLevel, pips, starburst } from './crab'
5import type { CrabState } from './crab'
6import { INK, clip, esc, mix, n2, ramp, tile } from './glass'
7
8export const U = 76
9export const GAP = 10
10export const SPAN2 = U * 2 + GAP
11export const FULL = U * 4 + GAP * 3
12
13export type Tone = 'fine' | 'watch' | 'act'
14const TONE_INK: Record<Tone, string> = { fine: INK.coral, watch: INK.amber, act: INK.red }
15
16// ── Icons: Lucide (lucide.dev, ISC licence), drawn on its 24-unit grid with round 2-unit strokes ──
17
18const LUCIDE = {
19 send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
20 flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
21 minimize: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
22 loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
23 fuel: '<line x1="3" x2="15" y1="22" y2="22"/><line x1="4" x2="14" y1="9" y2="9"/><path d="M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18"/><path d="M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2a2 2 0 0 0 2-2V9.83a2 2 0 0 0-.59-1.42L18 5"/>',
24 palette: '<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>',
25} as const
26
27/** A Lucide icon centred on (cx, cy), `size` pixels across; a spinning one turns about its centre. */
28function icon(name: keyof typeof LUCIDE, cx: number, cy: number, size: number, color: string, isSpinning = false): string {
29 const spin = isSpinning ? '<animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>' : ''
30 return `<g transform="translate(${n2(cx - size / 2)} ${n2(cy - size / 2)}) scale(${n2(size / 24)})"><g fill="none" stroke="${color}" color="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${LUCIDE[name]}${spin}</g></g>`
31}
32
33// ── Context: a glass of liquid, filled toward the auto-compact line ─────────
34
35/** A drain under way: the liquid falls from `fromPct` to `toPct` over `durS`, of which `sinceS` has passed already. */
36export type Drain = { fromPct: number; toPct: number; durS: number; sinceS: number }
37
38export type ContextArt = {
39 pct: number
40 tokens: string
41 compactAt: string
42 forecast: string
43 tone: Tone
44 isWorking: boolean
45 /** Set while compacting and for a moment after: the glass drains, starting where the clock says it has got to. */
46 drain?: Drain
47 isCompacting?: boolean
48}
49
50export function contextTile(a: ContextArt): string {
51 const w = SPAN2
52 const h = SPAN2
53 const top = 34
54 const bottom = h - 8
55 const surfaceAt = (pct: number) => n2(bottom - (bottom - top) * Math.min(1, Math.max(0, pct / 100)))
56 const level = Math.min(1, Math.max(0, (a.drain ? a.drain.fromPct : a.pct) / 100))
57 const surface = surfaceAt(a.drain ? a.drain.fromPct : a.pct)
58 // A negative begin starts the fall part-way through, so a frame that reloads mid-drain shows the same level.
59 const fall = a.drain
60 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${n2(surfaceAt(a.drain.toPct) - surface)}" dur="${n2(a.drain.durS)}s" begin="${n2(-a.drain.sinceS)}s" fill="freeze"/>`
61 : ''
62 const ink = TONE_INK[a.tone]
63 const deep = mix(ink, '#1A0604', 0.38)
64 const wave = (amp: number, len: number, y: number) => {
65 let d = `M${-len} ${y}`
66 for (let x = -len; x < w + len; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`
67 return `${d} V${h + 4} H${-len} Z`
68 }
69 const bubbles = a.isWorking
70 ? [0.22, 0.48, 0.71, 0.36]
71 .map(
72 (x, i) =>
73 `<circle cx="${n2(w * x)}" cy="${bottom}" r="${2 + (i % 2)}" fill="#fff" opacity="0"><animate attributeName="cy" values="${bottom};${surface + 6}" dur="${2.2 + i * 0.5}s" begin="${i * 0.7}s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;.55;0" dur="${2.2 + i * 0.5}s" begin="${i * 0.7}s" repeatCount="indefinite"/></circle>`,
74 )
75 .join('')
76 : ''
77 const liquid = level > 0
78 ? `<g>${fall}<path d="${wave(3.2, 54, surface + 2)}" fill="${deep}" opacity=".85"><animateTransform attributeName="transform" type="translate" values="0 0;54 0" dur="5.5s" repeatCount="indefinite"/></path><path d="${wave(4, 72, surface)}" fill="url(#liq)"><animateTransform attributeName="transform" type="translate" values="0 0;-72 0" dur="3.8s" repeatCount="indefinite"/></path>${bubbles}</g>`
79 : ''
80 const body = `${liquid}
81<path d="M10 ${top}H${w - 10}" stroke="var(--fg)" stroke-opacity=".55" stroke-width="1" stroke-dasharray="2 4"/>
82<text class="t d" x="${w - 12}" y="${top - 5}" font-size="9" text-anchor="end" font-weight="600">AUTO-COMPACT</text>
83<text class="t" x="14" y="24" font-size="13" font-weight="650">Context</text>
84${a.isCompacting
85 ? `<text class="t" x="14" y="${h / 2 + 8}" font-size="20" font-weight="700">Compacting<animate attributeName="opacity" values="1;.55;1" dur="1.8s" repeatCount="indefinite"/></text>
86<text class="t d" x="14" y="${h / 2 + 26}" font-size="11" font-weight="600">was ${esc(a.tokens)}</text>`
87 : `<text class="t" x="14" y="${h / 2 + 13}" font-size="44" font-weight="700" style="paint-order:stroke;stroke:rgba(0,0,0,.12);stroke-width:2">${a.pct}<tspan font-size="22" dx="1">%</tspan></text>
88<text class="t d" x="14" y="${h / 2 + 31}" font-size="11" font-weight="600">${esc(a.tokens)} of ${esc(a.compactAt)}</text>`}`
89 return tile({
90 w, h, body,
91 title: `Context ${a.pct}% of the way to auto-compact: ${a.tokens} of ${a.compactAt}. ${a.forecast}`,
92 glows: [{ color: ink, x: 0.3, y: 0.85, r: 0.55, opacity: 0.7 }, { color: '#5B6CFF', x: 0.95, y: 0.1, r: 0.4, opacity: 0.35 }],
93 extraDefs: `<linearGradient id="liq" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(ink, '#ffffff', 0.25)}" stop-opacity=".95"/><stop offset="1" stop-color="${deep}" stop-opacity=".95"/></linearGradient>`,
94 })
95}
96
97// ── 5-hour limit: a fuel gauge, the needle falling from F to E as the window is used ────────
98
99/** `leftAtRefill`: the fuel forecast to be left when the window refills, at the pace so far; marked on the dial. */
100export type GaugeArt = { label: string; left: number | null; status: string; detail: string; tone: Tone; leftAtRefill?: number | null }
101
102/** A point on a dial: `deg` counted anticlockwise from three o'clock, as on paper. */
103const onDial = (cx: number, cy: number, r: number, deg: number) => {
104 const t = (deg * Math.PI) / 180
105 return `${n2(cx + Math.cos(t) * r)} ${n2(cy - Math.sin(t) * r)}`
106}
107
108/** The arc of a dial from one angle clockwise to a lower one. */
109const dialArc = (cx: number, cy: number, r: number, from: number, to: number) =>
110 `M${onDial(cx, cy, r, from)}A${r} ${r} 0 ${from - to > 180 ? 1 : 0} 1 ${onDial(cx, cy, r, to)}`
111
112/** The needle is drawn where the reading is, never swept in from E, so a redraw shows the same gauge. */
113export function gaugeTile(a: GaugeArt): string {
114 const w = SPAN2
115 const h = SPAN2
116 const cx = w / 2
117 const cy = 100
118 const r = 54
119 const E = 200
120 const F = -20
121 const sweep = E - F
122 const left = a.left === null ? 0 : Math.min(1, Math.max(0, a.left / 100))
123 const at = E - sweep * left
124 const ink = TONE_INK[a.tone]
125 const lo = mix(ink, '#FFD27A', 0.35)
126 const ticks = Array.from({ length: 9 }, (_, i) => {
127 const deg = E - (sweep * i) / 8
128 const isMajor = i % 2 === 0
129 return `<path d="M${onDial(cx, cy, r - 13, deg)}L${onDial(cx, cy, r - (isMajor ? 21 : 17), deg)}" stroke="var(--fg)" stroke-opacity="${isMajor ? 0.55 : 0.3}" stroke-width="${isMajor ? 1.6 : 1}" stroke-linecap="round"/>`
130 }).join('')
131 const reserve = `<path d="${dialArc(cx, cy, r, E, E - sweep * 0.15)}" fill="none" stroke="${INK.red}" stroke-opacity=".35" stroke-width="9" stroke-linecap="round"/>`
132 const fuel = a.left === null || left <= 0
133 ? ''
134 : `<path d="${dialArc(cx, cy, r, E, at)}" fill="none" stroke="url(#fuelG)" stroke-width="9" stroke-linecap="round"/>`
135 // The forecast: the fuel still to burn before the refill shaded on the arc, and a tick where the needle will stop.
136 const willBurn = a.left !== null && a.leftAtRefill != null && a.leftAtRefill < a.left
137 const stopAt = E - sweep * Math.max(0, (a.leftAtRefill ?? 0) / 100)
138 const forecast = willBurn
139 ? `<path d="${dialArc(cx, cy, r, stopAt, at)}" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width="9" stroke-dasharray="1.5 2.5"/><path d="M${onDial(cx, cy, r - 7, stopAt)}L${onDial(cx, cy, r + 7, stopAt)}" stroke="#fff" stroke-width="2" stroke-linecap="round"/>`
140 : ''
141 const tip = onDial(cx, cy, r - 6, at)
142 const base1 = onDial(cx, cy, 3.2, at + 90)
143 const base2 = onDial(cx, cy, 3.2, at - 90)
144 const tail = onDial(cx, cy, 10, at + 180)
145 const needle = `<g opacity="${a.left === null ? 0.35 : 1}"><g><path d="M${tip}L${base1}L${tail}L${base2}Z" fill="${a.left === null ? 'var(--fg)' : '#fff'}" stroke="rgba(0,0,0,.28)" stroke-width=".8" stroke-linejoin="round"/><animateTransform attributeName="transform" type="rotate" values="0 ${cx} ${cy};-.8 ${cx} ${cy};.5 ${cx} ${cy};0 ${cx} ${cy}" dur="3.2s" repeatCount="indefinite" calcMode="spline" keySplines=".45 0 .55 1;.45 0 .55 1;.45 0 .55 1"/></g><circle cx="${cx}" cy="${cy}" r="5.5" fill="${ink}" stroke="#fff" stroke-opacity=".8" stroke-width="1.4"/></g>`
146 const mark = (deg: number, text: string, color: string) => {
147 const [x, y] = onDial(cx, cy, r - 30, deg).split(' ')
148 return `<text class="t" x="${x}" y="${n2(Number(y) + 4)}" font-size="11" font-weight="750" text-anchor="middle" fill="${color}">${text}</text>`
149 }
150 const body = `<path d="${dialArc(cx, cy, r, E, F)}" fill="none" stroke="var(--track)" stroke-width="9" stroke-linecap="round"/>${reserve}${fuel}${forecast}${ticks}
151${mark(E - 6, 'E', INK.red)}${mark(F + 6, 'F', 'var(--fg)')}${icon('fuel', cx, cy - 22, 15, a.left !== null && left < 0.15 ? INK.red : 'var(--fg)')}${needle}
152<text class="t" x="14" y="24" font-size="13" font-weight="650">${esc(a.label)}</text>
153<text class="t" x="${cx}" y="${h - 22}" font-size="21" font-weight="700" text-anchor="middle">${a.left === null ? '—' : `${a.left}<tspan font-size="12">% left</tspan>`}</text>
154<text class="t d" x="${cx}" y="${h - 8}" font-size="9.5" font-weight="550" text-anchor="middle">${esc(clip(a.status ? `${a.status} · ${a.detail}` : a.detail, 32))}</text>`
155 return tile({
156 w, h, body,
157 title: `${a.label} limit: ${a.left === null ? 'no figures yet' : `${a.left}% left`}. ${a.status}. ${a.detail}`,
158 glows: [{ color: ink, x: 0.5, y: 0.55, r: 0.45, opacity: 0.5 }, { color: '#7A5CFF', x: 0.05, y: 1, r: 0.35, opacity: 0.3 }],
159 extraDefs: `<linearGradient id="fuelG" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="${ink}"/><stop offset="1" stop-color="${lo}"/></linearGradient>`,
160 })
161}
162
163// ── Cache heat sensor: a thermometer that cools as the cache ages ───────────
164
165export type CacheArt = { kind: 'none' | 'warm' | 'cold'; leftFrac: number; value: string; caption: string; isKeptWarm: boolean; tone: Tone }
166
167/** Heat from fresh (1) to about to expire (0). */
168export const HEAT = [[0, '#9DB4C8'], [0.2, '#F7D488'], [0.55, '#FFA14A'], [1, '#FF5636']] as const
169
170export function cacheTile(a: CacheArt): string {
171 const w = U
172 const h = SPAN2
173 const tx = w / 2
174 const tTop = 30
175 const tBot = h - 50
176 const tw = 16
177 const isWarm = a.kind === 'warm'
178 const frac = isWarm ? Math.max(0.04, a.leftFrac) : 0
179 const color = isWarm ? ramp(HEAT, a.leftFrac) : a.kind === 'cold' ? INK.ice : '#8A8790'
180 const fillTop = n2(tBot - (tBot - tTop) * frac)
181 const shimmer = isWarm
182 ? [-5, 0, 5]
183 .map(
184 (dx, i) =>
185 `<path d="M${tx + dx} ${fillTop - 2}q3 -5 0 -10q-3 -5 0 -10" stroke="${color}" stroke-width="1.4" fill="none" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;.7;0" dur="2.4s" begin="${i * 0.6}s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 4;0 -8" dur="2.4s" begin="${i * 0.6}s" repeatCount="indefinite"/></path>`,
186 )
187 .join('')
188 : ''
189 const frost = a.kind === 'cold'
190 ? `<g stroke="${INK.ice}" stroke-width="1.4" stroke-linecap="round" transform="translate(${tx} ${(tTop + tBot) / 2})">${[0, 60, 120].map(d => `<path d="M0 -9V9M-3 -6l3 3 3 -3M-3 6l3 -3 3 3" transform="rotate(${d})"/>`).join('')}<animateTransform attributeName="transform" type="rotate" values="0;30;0" dur="8s" repeatCount="indefinite" additive="sum"/></g>`
191 : ''
192 const flame = a.isKeptWarm
193 ? `<g transform="translate(${w - 20} 10)"><path d="M6 0c1 4 5 5 5 9.5a5 5 0 0 1-10 0c0-2.5 1.6-3.6 2.3-5.6c.8 1.4 1.7 1.8 2.2 2.4C6 4.4 5.6 2 6 0Z" fill="${INK.coralHot}"><animate attributeName="opacity" values=".75;1;.75" dur="1.4s" repeatCount="indefinite"/></path><title>Keep-warm is on</title></g>`
194 : ''
195 const body = `<text class="t" x="12" y="22" font-size="12" font-weight="650">Cache</text>${flame}
196<rect x="${tx - tw / 2}" y="${tTop}" width="${tw}" height="${tBot - tTop}" rx="${tw / 2}" fill="var(--track)"/>
197${isWarm ? `<rect x="${tx - tw / 2}" y="${fillTop}" width="${tw}" height="${n2(tBot - fillTop)}" rx="${tw / 2}" fill="url(#heatG)"/><rect x="${tx - tw / 2 + 3}" y="${fillTop + 3}" width="3" height="${n2(Math.max(0, tBot - fillTop - 10))}" rx="1.5" fill="#fff" opacity=".35"/>` : ''}
198<circle cx="${tx}" cy="${tBot + 4}" r="${tw * 0.72}" fill="${a.kind === 'none' ? 'var(--track)' : color}"/>${isWarm ? `<circle cx="${tx}" cy="${tBot + 4}" r="${tw * 0.72 + 5}" fill="${color}" opacity=".25"><animate attributeName="r" values="${tw * 0.72 + 2};${tw * 0.72 + 7};${tw * 0.72 + 2}" dur="2s" repeatCount="indefinite"/></circle>` : ''}
199${shimmer}${frost}
200<text class="t" x="${tx}" y="${h - 18}" font-size="17" font-weight="700" text-anchor="middle">${esc(a.value)}</text>
201<text class="t d" x="${tx}" y="${h - 6}" font-size="9" font-weight="600" text-anchor="middle">${esc(a.caption.toUpperCase())}</text>`
202 return tile({
203 w, h, body, r: 24,
204 title: `Cache ${a.caption}: ${a.value}.${a.isKeptWarm ? ' Keep-warm is on.' : ''}`,
205 glows: [{ color, x: 0.5, y: 0.75, r: 0.55, opacity: isWarm ? 0.8 : 0.45 }, { color: isWarm ? '#FF2E63' : '#3A7BFF', x: 0.5, y: 0.1, r: 0.35, opacity: 0.25 }],
206 extraDefs: `<linearGradient id="heatG" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="${mix(color, '#ffffff', 0.15)}"/><stop offset="1" stop-color="${color}"/></linearGradient>`,
207 })
208}
209
210// ── Weekly limit: a fuel tank ───────────────────────────────────────────────
211
212export type TankArt = { label: string; left: number | null; caption: string; tone: Tone }
213
214/** The week as a fuel tank: the liquid is what is left, with F, half and E marked up its side. */
215export function tankTile(a: TankArt): string {
216 const w = U
217 const h = SPAN2
218 const frac = a.left === null ? 0 : Math.min(1, Math.max(0, a.left / 100))
219 // Liquid, as in the Context glass: a thin layer still shows as a pool with a moving surface, never a sliver.
220 const surface = n2(frac > 0 ? Math.min(h - 12, h - h * frac) : h + 6)
221 const ink = a.tone === 'fine' ? '#8E9BFF' : TONE_INK[a.tone]
222 const deep = mix(ink, '#0B0A1A', 0.45)
223 const wave = (amp: number, len: number, y: number) => {
224 let d = `M${-len} ${y}`
225 for (let x = -len; x < w + len; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`
226 return `${d} V${h + 4} H${-len} Z`
227 }
228 const liquid = frac > 0
229 ? `<path d="${wave(2.2, 38, surface + 2)}" fill="${deep}" opacity=".7"><animateTransform attributeName="transform" type="translate" values="0 0;38 0" dur="6s" repeatCount="indefinite"/></path><path d="${wave(2.6, 48, surface)}" fill="url(#wk)"><animateTransform attributeName="transform" type="translate" values="0 0;-48 0" dur="4.4s" repeatCount="indefinite"/></path>`
230 : ''
231 const scale = [['F', 0.12], ['½', 0.5], ['E', 0.88]] as const
232 const marks = scale
233 .map(([t, y]) => `<path d="M${w - 12} ${n2(h * y)}h6" stroke="var(--fg)" stroke-opacity=".5" stroke-width="1.4" stroke-linecap="round"/><text class="t d" x="${w - 15}" y="${n2(h * y + 3.5)}" font-size="9" font-weight="750" text-anchor="end"${t === 'E' ? ` fill="${INK.red}" fill-opacity=".85"` : ''}>${t}</text>`)
234 .join('')
235 const body = `${liquid}${marks}
236<text class="t" x="12" y="22" font-size="12" font-weight="650">${esc(a.label)}</text>
237<text class="t" x="12" y="${h - 26}" font-size="21" font-weight="700">${a.left === null ? '—' : `${a.left}<tspan font-size="12">%</tspan>`}</text>
238<text class="t d" x="12" y="${h - 11}" font-size="9" font-weight="650">${esc(clip(a.caption, 12).toUpperCase())}</text>`
239 return tile({
240 w, h, body, r: 24,
241 title: `${a.label} limit: ${a.left === null ? 'no figures yet' : `${a.left}% left`}, ${a.caption}.`,
242 glows: [{ color: '#6E7BFF', x: 0.5, y: 0.2, r: 0.5, opacity: 0.4 }, { color: ink, x: 0.5, y: 0.95, r: 0.45, opacity: 0.45 }],
243 extraDefs: `<linearGradient id="wk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(ink, '#ffffff', 0.3)}" stop-opacity=".78"/><stop offset="1" stop-color="${deep}" stop-opacity=".7"/></linearGradient>`,
244 })
245}
246
247// ── Round buttons: iPhone Control Center circles, the icon alone ──────────
248
249export type RoundKind = 'compact' | 'handoff' | 'warm' | 'look'
250export type RoundArt = { kind: RoundKind; isOn?: boolean; isBusy?: boolean; isSuggested?: boolean; title: string }
251
252const ROUND_ICON: Record<RoundKind, keyof typeof LUCIDE> = { compact: 'minimize', handoff: 'send', warm: 'flame', look: 'palette' }
253const ROUND_INK: Record<RoundKind, string> = { compact: INK.coral, handoff: INK.coral, warm: INK.coralHot, look: INK.coral }
254
255/** On or busy: a white disc with the icon in colour, as iOS draws a switched-on control; suggested: a coral pulse. */
256export function roundTile(a: RoundArt): string {
257 const s = U
258 const c = s / 2
259 const ink = ROUND_INK[a.kind]
260 const lit = a.isOn === true || a.isBusy === true
261 const disc = lit
262 ? `<circle cx="${c}" cy="${c}" r="${c}" fill="#fff" opacity=".94"/>`
263 : ''
264 const pulse = a.isSuggested && !lit
265 ? `<circle cx="${c}" cy="${c}" r="${c - 3}" fill="none" stroke="${INK.coral}" stroke-width="2.5"><animate attributeName="stroke-opacity" values=".15;.95;.15" dur="1.8s" repeatCount="indefinite"/></circle>`
266 : ''
267 const glyph = a.isBusy ? icon('loader', c, c, 26, ink, true) : icon(ROUND_ICON[a.kind], c, c, 26, lit ? ink : 'var(--fg)')
268 return tile({
269 w: s, h: s, isCircle: true, drift: false, title: a.title,
270 body: `${disc}${pulse}${glyph}`,
271 glows: [{ color: lit ? ink : '#8E8AA0', x: 0.5, y: 0.6, r: 0.6, opacity: lit ? 0.8 : 0.45 }, { color: '#5B6CFF', x: 0.8, y: 0.1, r: 0.35, opacity: 0.25 }],
272 })
273}
274
275// ── Appearance: the panel the Customize button opens, its colour swatches and opacity steps ──
276
277/** The glass panel the swatches sit on, as wide as the grid. */
278export function appearanceTile(): string {
279 return tile({
280 w: FULL, h: 164, r: 26, title: 'Appearance', drift: false,
281 body: `<text class="t" x="14" y="22" font-size="12" font-weight="650">Appearance</text>`,
282 glows: [{ color: '#BF5AF2', x: 0.15, y: 0.4, r: 0.35, opacity: 0.35 }, { color: '#0A84FF', x: 0.6, y: 0.8, r: 0.35, opacity: 0.3 }, { color: INK.coral, x: 0.95, y: 0.2, r: 0.3, opacity: 0.35 }],
283 })
284}
285
286/** A swatch's drawn size: a 30-pixel disc with 6 pixels round it for the selection ring. */
287export const SWATCH = 42
288
289const swatchDoc = (body: string, title: string) =>
290 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SWATCH} ${SWATCH}" width="${SWATCH}" height="${SWATCH}"><title>${esc(title)}</title>${body}</svg>`
291
292const ring = (isSelected: boolean) =>
293 isSelected ? `<circle cx="21" cy="21" r="19" fill="none" stroke="#fff" stroke-width="2"/>` : ''
294
295/** One colour: a glossy disc, or for none the neutral smoke struck through; ringed when chosen. */
296export function swatchTile(o: { color: string | null; name: string; isSelected: boolean }): string {
297 const disc = o.color
298 ? `<defs><radialGradient id="g" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="${mix(o.color, '#ffffff', 0.35)}"/><stop offset="1" stop-color="${o.color}"/></radialGradient></defs><circle cx="21" cy="21" r="15" fill="url(#g)"/>`
299 : `<circle cx="21" cy="21" r="15" fill="#3A3940" stroke="rgba(255,255,255,.35)"/><path d="M11 31L31 11" stroke="rgba(255,255,255,.7)" stroke-width="1.6" stroke-linecap="round"/>`
300 return swatchDoc(`${ring(o.isSelected)}${disc}<ellipse cx="17" cy="14" rx="7" ry="4" fill="#fff" opacity=".22"/>`, o.name)
301}
302
303/** One opacity step: coloured light behind a frost of that strength, its percentage on top; ringed when chosen. */
304export function opacityTile(o: { opacity: number; color: string | null; isSelected: boolean }): string {
305 const frost = o.color ? mix('#100F13', o.color, 0.34) : '#100F13'
306 const body = `${ring(o.isSelected)}<clipPath id="c"><circle cx="21" cy="21" r="15"/></clipPath><g clip-path="url(#c)"><circle cx="14" cy="15" r="11" fill="${INK.coral}"/><circle cx="29" cy="28" r="11" fill="#5B6CFF"/><circle cx="21" cy="21" r="15" fill="${frost}" fill-opacity="${n2(o.opacity / 100)}"/></g><circle cx="21" cy="21" r="15" fill="none" stroke="rgba(255,255,255,.35)"/><text x="21" y="25" font-family="ui-rounded, -apple-system, sans-serif" font-size="11" font-weight="700" fill="#fff" text-anchor="middle">${o.opacity}</text>`
307 return swatchDoc(body, `${o.opacity}% opacity`)
308}
309
310// ── Agents: the crab stage ──────────────────────────────────────────────────
311
312export type AgentArt = {
313 id: string; type: string; model: string; effort?: string | number | null; state: CrabState; tool?: string; doing: string; elapsed: string
314 /** What it was asked to do, and its spend so far ("12 steps · 48k tokens"). */
315 task?: string; stats?: string
316}
317
318/** The tallest the left-hand grid stands (two 162-pixel rows), so the agents column lines up with it. */
319export const GRID_H = SPAN2 * 2
320const AGENT_ROW = 84
321const MAX_AGENTS = 6
322
323/** The spend line, dropping its later parts ("48k tokens · 12 steps" to "48k tokens") until it fits. */
324const fitStats = (stats: string, max: number) => {
325 const parts = stats.split(' · ')
326 while (parts.length > 1 && parts.join(' · ').length > max) parts.pop()
327 return clip(parts.join(' · '), max)
328}
329
330/** Characters of 10-pixel text that fit across `px`. */
331const fits = (px: number, size = 10) => Math.max(8, Math.floor(px / (size * 0.56)))
332
333/**
334 * The agents column: one card per working agent, its crab beside who it is, what it was asked, what it is
335 * doing now, and its model, effort and spend. Finished agents leave; the summary counts them.
336 */
337export function agentsTile(list: readonly AgentArt[], summary: string, w = FULL): string {
338 const shown = list.slice(0, MAX_AGENTS)
339 const h = Math.max(GRID_H, 44 + Math.max(1, shown.length) * AGENT_ROW + (list.length > shown.length ? 14 : 0))
340 const working = list.some(a => a.state === 'running')
341 const x0 = 68
342 const room = w - x0 - 14
343 const cards = shown
344 .map((a, i) => {
345 const top = 44 + i * AGENT_ROW
346 const lvl = effortLevel(a.effort)
347 const b = breedOf(a.model)
348 const famW = b.family.length * 5.6 + 6
349 const rule = i > 0 ? `<path d="M${x0} ${top - 4}H${w - 12}" stroke="var(--track)"/>` : ''
350 return `<g>${rule}<title>${crabTitle(a.type, a.model, lvl, a.doing)}</title>
351<g transform="translate(36 ${top + 44})">${crab({ model: a.model, state: a.state, tool: a.tool, p: 2.6, phase: i * 0.13 })}</g>
352<text class="t" x="${x0}" y="${top + 14}" font-size="12" font-weight="650">${esc(clip(a.type, fits(room - 40, 12)))}</text>
353<text class="t d" x="${w - 14}" y="${top + 14}" font-size="10" font-weight="600" text-anchor="end">${esc(a.elapsed)}</text>
354<text class="t d" x="${x0}" y="${top + 30}" font-size="10" font-weight="500">${esc(clip(a.task || 'No description', fits(room)))}</text>
355<text class="t" x="${x0}" y="${top + 46}" font-size="10" font-weight="600" style="fill:${INK.coral}">${esc(clip(a.doing, fits(room)))}</text>
356<text class="t d" x="${x0}" y="${top + 62}" font-size="9.5" font-weight="600">${esc(b.family)}</text>${pips(lvl, b.body, x0 + famW, top + 61, 3.2)}
357<text class="t d" x="${w - 14}" y="${top + 62}" font-size="9.5" font-weight="500" text-anchor="end">${esc(fitStats(a.stats ?? '', fits(room - famW - 26, 9.5)))}</text></g>`
358 })
359 .join('')
360 const empty = shown.length === 0
361 ? `<g transform="translate(${w / 2} ${h / 2 - 6})">${crab({ model: 'opus', state: 'idle', p: 3.4 })}</g><text class="t d" x="${w / 2}" y="${h / 2 + 26}" font-size="10.5" font-weight="600" text-anchor="middle">No agents working</text>`
362 : ''
363 const more = list.length > shown.length ? `<text class="t d" x="${w - 14}" y="${h - 10}" font-size="9.5" text-anchor="end">+${list.length - shown.length} more</text>` : ''
364 const body = `${starburst(22, 21, 9, INK.coral, working)}
365<text class="t" x="38" y="25.5" font-size="13" font-weight="650">Agents</text>
366<text class="t d" x="${w - 14}" y="25.5" font-size="10.5" font-weight="600" text-anchor="end">${esc(summary)}</text>
367<path d="M12 36H${w - 12}" stroke="var(--track)"/>${cards}${empty}${more}`
368 return tile({
369 w, h, body,
370 title: `Agents: ${summary}`,
371 glows: [{ color: INK.coral, x: 0.15, y: 0.9, r: 0.4, opacity: 0.45 }, { color: '#4E7BFF', x: 0.85, y: 0.2, r: 0.4, opacity: 0.35 }, { color: '#9B6BFF', x: 0.55, y: 1.1, r: 0.35, opacity: 0.3 }],
372 })
373}
374hooks/art/crab.ts 168 lines1// The agent crabs: a pixel crab per subagent. Its colour and hat say the model, glowing pips say the effort,
2// a bubble says the tool it is using, and the way it moves says whether it is working, done or stuck.
3
4import { esc, n2 } from './glass'
5
6export type CrabState = 'running' | 'done' | 'failed' | 'idle'
7
8export type Breed = { family: string; body: string; shade: string; hat: string }
9
10/** Each model family is its own crab: Opus in Claude coral with a mortarboard, Sonnet in blue with a quill, etc. */
11export const BREEDS: Record<string, Breed> = {
12 opus: { family: 'Opus', body: '#D97757', shade: '#B85C3E', hat: 'cap' },
13 sonnet: { family: 'Sonnet', body: '#6FA3E0', shade: '#4E80BD', hat: 'quill' },
14 haiku: { family: 'Haiku', body: '#7CC49A', shade: '#57A076', hat: 'leaf' },
15 fable: { family: 'Fable', body: '#B08CF0', shade: '#8B68CF', hat: 'wizard' },
16 mythos: { family: 'Mythos', body: '#E3B75C', shade: '#BF9238', hat: 'halo' },
17 other: { family: 'Agent', body: '#E8DFD0', shade: '#C4B8A6', hat: 'none' },
18}
19
20export function breedOf(model: string): Breed {
21 const m = /(opus|sonnet|haiku|fable|mythos)/i.exec(model)
22 return BREEDS[m?.[1]?.toLowerCase() ?? 'other'] ?? BREEDS.other!
23}
24
25/** Effort as a level from 1 to 5, or 0 when unknown. */
26export function effortLevel(effort: string | number | undefined | null): number {
27 if (effort === undefined || effort === null || effort === '') return 0
28 if (typeof effort === 'number') return effort >= 32_000 ? 5 : effort >= 16_000 ? 4 : effort >= 8_000 ? 3 : effort >= 2_000 ? 2 : 1
29 return ({ low: 1, medium: 2, high: 3, xhigh: 4, max: 5 } as Record<string, number>)[effort.toLowerCase()] ?? 0
30}
31
32export const EFFORT_WORD = ['', 'low', 'medium', 'high', 'extra high', 'max'] as const
33
34// The sprite on a 14 × 9 grid of `p`-pixel squares: body, claws, eyes, two leg frames.
35const BODY: [number, number, number, number][] = [[2, 0, 10, 6], [0, 2, 2, 2], [12, 2, 2, 2]]
36const LEGS_A: [number, number][] = [[3, 6], [5, 6], [8, 6], [10, 6], [3, 7], [10, 7]]
37const LEGS_B: [number, number][] = [[3, 6], [5, 6], [8, 6], [10, 6], [5, 7], [8, 7]]
38
39const rects = (cells: [number, number][] | [number, number, number, number][], p: number) =>
40 cells.map(c => `<rect x="${c[0] * p}" y="${c[1] * p}" width="${(c[2] ?? 1) * p}" height="${(c[3] ?? 1) * p}"/>`).join('')
41
42function hat(kind: string, p: number, b: Breed): string {
43 const W = 14 * p
44 switch (kind) {
45 case 'cap':
46 return `<g><path d="M${W / 2 - 4.5 * p} ${-1.2 * p}L${W / 2} ${-2.6 * p}L${W / 2 + 4.5 * p} ${-1.2 * p}L${W / 2} ${0.2 * p}Z" fill="#26211F"/><rect x="${W / 2 - 2.2 * p}" y="${-1.1 * p}" width="${4.4 * p}" height="${1.2 * p}" fill="#26211F"/><path d="M${W / 2 + 3.6 * p} ${-1.4 * p}v${2.6 * p}" stroke="${b.body}" stroke-width="${p * 0.55}" stroke-linecap="round"/></g>`
47 case 'quill':
48 return `<path d="M${W / 2 + 2 * p} ${0.2 * p}C${W / 2 + 2.4 * p} ${-2.4 * p} ${W / 2 + 4.6 * p} ${-4 * p} ${W / 2 + 6 * p} ${-4.4 * p}C${W / 2 + 5.4 * p} ${-2.6 * p} ${W / 2 + 4 * p} ${-0.8 * p} ${W / 2 + 2 * p} ${0.2 * p}Z" fill="#F4EFE6" stroke="#9CB8DA" stroke-width="${p * 0.25}"/>`
49 case 'leaf':
50 return `<g><path d="M${W / 2} ${0.2 * p}v${-1.6 * p}" stroke="#4E8F64" stroke-width="${p * 0.45}"/><path d="M${W / 2} ${-1.4 * p}C${W / 2 - 2.6 * p} ${-1.6 * p} ${W / 2 - 3 * p} ${-3.6 * p} ${W / 2 - 2.4 * p} ${-3.8 * p}C${W / 2 - 0.6 * p} ${-3.6 * p} ${W / 2} ${-2.6 * p} ${W / 2} ${-1.4 * p}Z" fill="#9BE0A8"/></g>`
51 case 'wizard':
52 return `<g><path d="M${W / 2 - 3.6 * p} ${0.3 * p}L${W / 2 + 0.6 * p} ${-5 * p}L${W / 2 + 3.6 * p} ${0.3 * p}Z" fill="#4B3A8C"/><circle cx="${W / 2 + 0.4 * p}" cy="${-2.2 * p}" r="${p * 0.5}" fill="#FFE27A"/></g>`
53 case 'halo':
54 return `<ellipse cx="${W / 2}" cy="${-1.6 * p}" rx="${3.4 * p}" ry="${0.9 * p}" fill="none" stroke="#FFE27A" stroke-width="${p * 0.55}"/>`
55 default:
56 return ''
57 }
58}
59
60/** A small vector icon for the tool the agent is using. */
61export function toolIcon(tool: string, s: number, color: string): string {
62 const k = tool.toLowerCase()
63 const sw = `stroke="${color}" stroke-width="${n2(s * 0.12)}" fill="none" stroke-linecap="round" stroke-linejoin="round"`
64 if (/grep|glob|search|find/.test(k)) return `<g ${sw}><circle cx="${s * 0.42}" cy="${s * 0.42}" r="${s * 0.26}"/><path d="M${s * 0.62} ${s * 0.62}L${s * 0.86} ${s * 0.86}"/></g>`
65 if (/read|notebook/.test(k)) return `<g ${sw}><path d="M${s * 0.22} ${s * 0.12}h${s * 0.4}l${s * 0.18} ${s * 0.18}v${s * 0.58}h${-s * 0.58}Z"/><path d="M${s * 0.34} ${s * 0.48}h${s * 0.32}M${s * 0.34} ${s * 0.66}h${s * 0.24}"/></g>`
66 if (/edit|write|update/.test(k)) return `<g ${sw}><path d="M${s * 0.2} ${s * 0.8}l${s * 0.06} ${-s * 0.2}l${s * 0.46} ${-s * 0.46}l${s * 0.14} ${s * 0.14}l${-s * 0.46} ${s * 0.46}Z"/></g>`
67 if (/bash|shell|terminal|exec/.test(k)) return `<g ${sw}><path d="M${s * 0.18} ${s * 0.3}l${s * 0.22} ${s * 0.2}l${-s * 0.22} ${s * 0.2}M${s * 0.5} ${s * 0.74}h${s * 0.3}"/></g>`
68 if (/web|fetch|browser|http/.test(k)) return `<g ${sw}><circle cx="${s / 2}" cy="${s / 2}" r="${s * 0.34}"/><path d="M${s * 0.16} ${s / 2}h${s * 0.68}M${s / 2} ${s * 0.16}c${-s * 0.18} ${s * 0.2} ${-s * 0.18} ${s * 0.48} 0 ${s * 0.68}c${s * 0.18} ${-s * 0.2} ${s * 0.18} ${-s * 0.48} 0 ${-s * 0.68}"/></g>`
69 if (/agent|task/.test(k)) return `<g ${sw}><circle cx="${s * 0.36}" cy="${s * 0.4}" r="${s * 0.14}"/><circle cx="${s * 0.66}" cy="${s * 0.4}" r="${s * 0.14}"/><path d="M${s * 0.2} ${s * 0.8}c${s * 0.06} ${-s * 0.18} ${s * 0.26} ${-s * 0.18} ${s * 0.32} 0M${s * 0.5} ${s * 0.8}c${s * 0.06} ${-s * 0.18} ${s * 0.26} ${-s * 0.18} ${s * 0.32} 0"/></g>`
70 // Thinking: three dots that breathe.
71 return [0.28, 0.5, 0.72]
72 .map((x, i) => `<circle cx="${s * x}" cy="${s / 2}" r="${s * 0.08}" fill="${color}"><animate attributeName="opacity" values=".25;1;.25" dur="1.2s" begin="${i * 0.2}s" repeatCount="indefinite"/></circle>`)
73 .join('')
74}
75
76/**
77 * One crab, drawn with its feet at (0, 0) of its own group: about 14p wide and 9p tall, the hat and bubble above.
78 * `phase` staggers the animation so a row of crabs does not move in lockstep.
79 */
80export function crab(o: { model: string; state: CrabState; tool?: string; p?: number; phase?: number }): string {
81 const p = o.p ?? 3
82 const b = breedOf(o.model)
83 const W = 14 * p
84 const H = 9 * p
85 const ph = n2(o.phase ?? 0)
86 const isRun = o.state === 'running'
87 const legs = isRun
88 ? `<g fill="${b.shade}">${rects(LEGS_A, p)}<animate attributeName="opacity" values="1;0" dur=".36s" begin="${ph}s" calcMode="discrete" repeatCount="indefinite"/></g><g fill="${b.shade}" opacity="0">${rects(LEGS_B, p)}<animate attributeName="opacity" values="0;1" dur=".36s" begin="${ph}s" calcMode="discrete" repeatCount="indefinite"/></g>`
89 : o.state === 'idle'
90 ? ''
91 : `<g fill="${b.shade}">${rects(LEGS_A.slice(0, 4), p)}</g>`
92 const eyeColor = '#1E1A19'
93 const eyes =
94 o.state === 'done'
95 ? `<path d="M${3.6 * p} ${2.4 * p}l${0.7 * p} ${-0.8 * p}l${0.7 * p} ${0.8 * p}M${8.6 * p} ${2.4 * p}l${0.7 * p} ${-0.8 * p}l${0.7 * p} ${0.8 * p}" stroke="${eyeColor}" stroke-width="${p * 0.5}" fill="none" stroke-linecap="round"/>`
96 : o.state === 'failed'
97 ? `<path d="M${3.7 * p} ${1.2 * p}l${1.2 * p} ${1.2 * p}M${4.9 * p} ${1.2 * p}l${-1.2 * p} ${1.2 * p}M${8.7 * p} ${1.2 * p}l${1.2 * p} ${1.2 * p}M${9.9 * p} ${1.2 * p}l${-1.2 * p} ${1.2 * p}" stroke="${eyeColor}" stroke-width="${p * 0.45}" stroke-linecap="round"/>`
98 : o.state === 'idle'
99 ? `<path d="M${3.6 * p} ${2 * p}h${1.4 * p}M${8.6 * p} ${2 * p}h${1.4 * p}" stroke="${eyeColor}" stroke-width="${p * 0.45}" stroke-linecap="round"/>`
100 : `<g fill="${eyeColor}">${[4, 9]
101 .map(
102 x =>
103 `<rect x="${x * p}" y="${p}" width="${p}" height="${n2(1.6 * p)}"><animate attributeName="height" values="${n2(1.6 * p)};${n2(0.25 * p)};${n2(1.6 * p)};${n2(1.6 * p)}" dur="3.6s" keyTimes="0;.03;.06;1" begin="${n2(1 + (o.phase ?? 0) * 7)}s" repeatCount="indefinite"/></rect>`,
104 )
105 .join('')}</g>`
106 const claws = isRun
107 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-p * 0.8};0 0" dur=".72s" begin="${ph}s" repeatCount="indefinite"/>`
108 : ''
109 const body = `<g fill="${b.body}"><rect x="${2 * p}" y="0" width="${10 * p}" height="${6 * p}" rx="${p * 0.35}"/></g><g fill="${b.body}">${rects([BODY[1]!, BODY[2]!], p)}${claws}</g><rect x="${2 * p}" y="0" width="${10 * p}" height="${p * 0.9}" fill="#fff" opacity=".18"/>`
110 const bob = isRun
111 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 ${-p * 0.6};0 0" dur=".36s" begin="${ph}s" repeatCount="indefinite" additive="sum"/>`
112 : ''
113 const sit = o.state === 'idle' ? `translate(0 ${p * 2})` : o.state === 'done' ? `translate(0 ${p})` : ''
114 const shadow = `<ellipse cx="${W / 2}" cy="${H + p * 0.4}" rx="${W * 0.42}" ry="${p * 0.7}" fill="#000" opacity=".28"/>`
115 const sprite = `<g transform="${sit}"><g>${legs}${body}${eyes}${hat(b.hat, p, b)}${bob}</g></g>`
116 const zzz =
117 o.state === 'idle'
118 ? `<text class="t d" x="${W - p}" y="${-p}" font-size="${p * 3}" font-weight="600">z<animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 0;${p * 1.5} ${-p * 3}" dur="2.4s" repeatCount="indefinite"/></text>`
119 : ''
120 return `<g transform="translate(${-W / 2} ${-H})">${shadow}${sprite}${zzz}${badge(o.state, o.tool, W, p)}</g>`
121}
122
123/** The bubble over a crab: the tool while working, a tick when done, an exclamation when it failed. */
124function badge(state: CrabState, tool: string | undefined, W: number, p: number): string {
125 const r = p * 2.6
126 const cx = W + p * 0.6
127 const cy = -p * 2.4
128 if (state === 'running') {
129 const s = r * 1.3
130 return `<g><circle cx="${cx}" cy="${cy}" r="${r}" fill="rgba(255,255,255,.92)"/><circle cx="${cx - r * 0.9}" cy="${cy + r * 0.95}" r="${p * 0.55}" fill="rgba(255,255,255,.8)"/><g transform="translate(${cx - s / 2} ${cy - s / 2})">${toolIcon(tool ?? '', s, '#2A2522')}</g><animateTransform attributeName="transform" type="translate" values="0 0;0 ${-p * 0.7};0 0" dur="2.2s" repeatCount="indefinite"/></g>`
131 }
132 if (state === 'done') {
133 return `<g><circle cx="${cx}" cy="${cy}" r="${r * 0.8}" fill="#34C77B"/><path d="M${cx - r * 0.38} ${cy}l${r * 0.26} ${r * 0.28}l${r * 0.5} ${-r * 0.52}" stroke="#fff" stroke-width="${p * 0.6}" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g>`
134 }
135 if (state === 'failed') {
136 return `<g><circle cx="${cx}" cy="${cy}" r="${r * 0.8}" fill="#FF5A4E"/><path d="M${cx} ${cy - r * 0.42}v${r * 0.46}" stroke="#fff" stroke-width="${p * 0.65}" stroke-linecap="round"/><circle cx="${cx}" cy="${cy + r * 0.4}" r="${p * 0.38}" fill="#fff"/></g>`
137 }
138 return ''
139}
140
141/** Effort pips: `level` lit of five, the lit ones glowing in the crab's colour. */
142export function pips(level: number, color: string, x: number, y: number, size = 4): string {
143 return [0, 1, 2, 3, 4]
144 .map(i => {
145 const lit = i < level
146 return `<rect x="${n2(x + i * size * 1.6)}" y="${n2(y - (i + 1) * size * 0.35)}" width="${size}" height="${n2(size * 0.35 * (i + 1) + size * 0.6)}" rx="${n2(size * 0.4)}" fill="${lit ? color : 'var(--track)'}"${lit && level === 5 ? ` opacity=".95"><animate attributeName="opacity" values=".55;1;.55" dur="1.6s" begin="${i * 0.12}s" repeatCount="indefinite"/></rect>` : '/>'}`
147 })
148 .join('')
149}
150
151/** The Claude starburst: rounded rays of uneven length, turning while anything works. */
152export function starburst(cx: number, cy: number, r: number, color: string, isSpinning: boolean): string {
153 const rays = [1, 0.78, 0.94, 0.7, 0.98, 0.82, 0.9, 0.74, 1, 0.8, 0.92, 0.72]
154 const lines = rays
155 .map((k, i) => {
156 const a = (i / rays.length) * Math.PI * 2 + 0.13
157 const r0 = r * 0.18
158 const r1 = r * k
159 return `<path d="M${n2(cx + Math.cos(a) * r0)} ${n2(cy + Math.sin(a) * r0)}L${n2(cx + Math.cos(a) * r1)} ${n2(cy + Math.sin(a) * r1)}"/>`
160 })
161 .join('')
162 const spin = isSpinning ? `<animateTransform attributeName="transform" type="rotate" from="0 ${cx} ${cy}" to="360 ${cx} ${cy}" dur="6s" repeatCount="indefinite"/>` : ''
163 return `<g stroke="${color}" stroke-width="${n2(r * 0.22)}" stroke-linecap="round">${lines}${spin}</g>`
164}
165
166export const crabTitle = (type: string, model: string, effort: number, doing: string) =>
167 esc(`${type} · ${breedOf(model).family}${effort ? ` · ${EFFORT_WORD[effort]} effort` : ''}${doing ? ` · ${doing}` : ''}`)
168types/index.d.ts 87 lines1// Cockpit v2: the state contract. Every value the band and the pane draw from.
2
3export type Tone = 'fine' | 'watch' | 'act'
4
5export type Limit = { percentUsed: number; resetsAt: number | null }
6
7/** The figures `session.measure` pushes, kept so drawings redraw only when they move. */
8export type Usage = {
9 tokens: number
10 window: number
11 five: Limit | null
12 week: Limit | null
13 costUsd: number | null
14 isPlan: boolean
15}
16
17export type TurnInfo = { startedAt: number; tools: number; isWorking: boolean; lastSeconds: number | null }
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentCard = {
22 id: string
23 type: string
24 description: string
25 model: string
26 status: AgentStatus
27 startedAt: number
28 endedAt: number | null
29 steps: number
30 tokens: number
31 /** The effort its requests run at (`high`, or a token budget), once its first request is seen. */
32 effort?: string | number | null
33 /** The tool it called last, and that call in plain words. */
34 tool?: string
35 doing?: string
36}
37
38/** The prompt cache as Cockpit tracks it, plus the keep-warm window. */
39export type Warmth = {
40 lastRequestAt: number
41 isCompacted: boolean
42 keepWarmUntil: number
43 lastPing: { at: number; read: number; isWarm: boolean } | null
44 stopped: string | null
45 ackedAt: number
46}
47
48/**
49 * A compaction under way or just finished, so the Context glass can drain: from `fromPct` toward empty while it
50 * runs, then settle from `doneLevel` to the new reading. Times are clock times, so a frame that reloads picks up
51 * the drain where it was.
52 */
53export type Squeeze = { startedAt: number; fromPct: number; doneAt: number | null; doneLevel: number | null }
54
55export type Notice = { text: string; tone: Tone; at: number }
56/** The glass's look: `color` tints the frost (null: neutral smoke); `opacity` is how much frost covers the lights, 0 clear to 100. */
57export type Look = { color: string | null; opacity: number }
58/** The Control Center action pressed last: its tile lights while `isBusy`, and a repeat press is ignored. */
59export type Pressed = { key: string; at: number; isBusy: boolean }
60
61/** One reading for the sparkline. */
62export type Sample = { at: number; five: number | null; tokens: number }
63
64export type Launcher = { pinned: string[]; recent: string[] }
65
66declare module 'claude-code' {
67 interface PluginState {
68 cockpit: {
69 usage: Usage
70 turn: TurnInfo
71 agents: AgentCard[]
72 warmth: Warmth
73 notice: Notice | null
74 samples: Sample[]
75 turnTokens: number[]
76 filter: string
77 expanded: string | null
78 launcher: Launcher
79 tick: number
80 pressed: Pressed | null
81 look: Look
82 customizing: boolean
83 squeeze: Squeeze | null
84 }
85 }
86}
87