SLOPSHOPPER

cockpit

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.

panebandspinnerguardcommandtoast
v2.1.0MITupdated 2026-10-18ilyasalmd/controlcentre_id
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ Session │ ┃ ━━━━━━━━━━────────────────────────────── 24% ⏺ Read(src/auth.ts) │ ┃ 97k of 400k compact point · 200k window · ┃ ⎿ Read 6 lines │ ┃ marks auto-compact ⏺ Update(src/auth.ts) │ ┃ A forecast appears after a few turns. ⎿ Added 2 lines, removed 1 line │ ┃ Cache warm · 1h left ⏺ Bash(bun test) │ ┃ Worth $0.42 at API prices · not a bill on ⎿ 3 pass, 1 fail │ ┃ your plan │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Limits │ ┃ 5-hour ━━━━━━━━━━━━──────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Fresh window, plenty of room. › /cockpit │ ┃ Trend ▃▃ 5-hour use this session ⎿ cockpit: Control Center closed. /cockpit brings it back. │ ┃ The thin line under each bar is time passed. │ ┃ Use ahead of time means you are spending │ ┃ fast. │ ┃ │ ┃ Activity │ ┃ Idle. The last turn took 0s and 9 tool │ ┃ calls. │ ┃ No subagents yet this session. │ ┃ │ ┃ Actions │ ┃ [ Shrink chat (97k) ] [ Quick handoff ] [ Fu ● Chat 24% · Cache warm 1h · 5h 31% fresh[ Compact ] [ Handoff ] [ ⌘ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Chat 24% · Cache warm 1h · 5h 31% fresh[ Compact ] [ Handoff ] [ ⌘ ]
Pane · cockpit
Session ━━━━━━━━━━────────────────────────────── 24% 97k of 400k compact point · 200k window · ┃ marks auto-compact A forecast appears after a few turns. Cache warm · 1h left Worth $0.42 at API prices · not a bill on your plan Limits 5-hour ━━━━━━━━━━━━──────────────────────────── 31% 0% of time Fresh window, plenty of room. Trend ▃▃ 5-hour use this session The thin line under each bar is time passed. Use ahead of time means you are spending fast. Activity Idle. The last turn took 0s and 9 tool calls. No subagents yet this session. Actions [ Shrink chat (97k) ] [ Quick handoff ] [ Full handoff ] [ K Quick writes a note to paste into a fresh chat. Full also checks git and writes HANDOFF.md. Launcher Find: search 1 skills and commands Type to search. Press ☆ on a result to pin it here.
README

Cockpit: a Control Center for Claude Code

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.

The Control Center: the Context glass drains while Claude compacts, then settles on the new size

The Control Center in dark mode

The Control Center in light mode

What's in it

  • Context: a glass of liquid that fills toward the auto-compact line, with a guess at how many turns are left. While Claude compacts, the glass drains.
  • 5-hour limit: a fuel gauge whose needle falls from F to E. A tick on the dial marks where the needle will be when the window refills at your current pace, and the line under it says how much will be left. If you're on track to run out first, it says when.
  • Cache: a thermometer that cools as the prompt cache gets close to expiring, and turns to ice once it has gone cold.
  • Week limit: a fuel tank showing what is left of the week.
  • Round buttons:
  • Compact summarises the chat to free room.
  • Handoff writes a HANDOFF.md with next steps so you can start a fresh chat.
  • Keep warm sends a tiny ping before the cache expires, so coming back to a chat stays cheap.
  • Customize opens the colour and opacity picker for the glass.
  • Agents: a crab for each subagent at work, showing its model, effort, task and what it is doing right now.

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.

Install

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.

Commands

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

Settings

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

What it does on your behalf

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 runs /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."
  • Handoff sends a prompt to Claude as if you had typed it. The full handoff asks Claude to check 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.
  • The launcher runs the slash command you tap or pin, and nothing else.
  • Keep warm, while it is on, sends one small model request ("Reply with the single word: warm") on a copy of the chat just before the prompt cache would expire, so the cache stays warm. Each ping counts toward your usage. It stops by itself after keepWarmHours, or as soon as you turn it off.
  • The cold-cache guard holds back one message, once, when the cache has gone cold on a big chat, so you can compact first. Sending the same message again lets it through. Set 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:

  • On tool calls it counts the tools in the current turn, and for a subagent it notes the tool for that agent's card.
  • When an agent starts it adds that agent's card. It never changes the agent's prompt, model or permissions.
  • On slash commands you type, it remembers the command name for the launcher's recent list.
  • On session start (clear, resume or fork) and on compaction, it resets or carries over the cache clock and drains the Context glass.
  • On a pane closing, it redraws.

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.

Developing

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

Licence

MIT. See LICENSE.

Source 12 files
hooks/register.tsx 770 lines
1// 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}
770
hooks/art/glass.ts 183 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
183
hooks/band.tsx 259 lines
1// 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}
259
hooks/glass.tsx 300 lines
1// 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}
300
hooks/model.ts 435 lines
1// 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}
435
hooks/pane.tsx 265 lines
1// 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}
265
hooks/view.ts 65 lines
1// 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>
65
hooks/pad.tsx 35 lines
1// 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
35
hooks/theme.ts 46 lines
1// 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}
46
hooks/art/tiles.ts 374 lines
1// 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}
374
hooks/art/crab.ts 168 lines
1// 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}` : ''}`)
168
types/index.d.ts 87 lines
1// 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