SLOPSHOPPER

deck

A cockpit for Claude Code: plan bars and subagent strips, context, usage limits with reset countdowns and burn rates, spend, the model route and a pet. Six bar…

newpanebandspinnerguardcommand
★ 5v0.4.9MITupdated 2026-10-08nvr0x5/claude-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deck
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /deck ⎿ deck: Deck expanded. ▣ 0: deck ▾ · 2 rows 15:53:04 ✓ fix the failing aut… Done · 9 tools 0s ✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Deck ] ⟨Claude Code's own drawing⟩

Draws

Band
▣ 0: deck ▾ · 2 rows 15:53:04 ✓ fix the failing aut… Done · 9 tools 0s ✕
README

<img src="assets/banner.svg" alt="deck — a cockpit for Claude Code" width="100%">

<a href="https://github.com/nvr0x5/claude-deck/actions/workflows/test.yml"><img alt="tests" src="https://img.shields.io/github/actions/workflow/status/nvr0x5/claude-deck/test.yml?branch=main&style=flat-square&label=tests&labelColor=14121a"></a> <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-D97757?style=flat-square&labelColor=14121a"></a> <img alt="Claude Code 2.1.286+" src="https://img.shields.io/badge/Claude%20Code-2.1.286%2B-9C95EC?style=flat-square&labelColor=14121a"> <img alt="CLI and Desktop" src="https://img.shields.io/badge/runs%20in-CLI%20%2B%20Desktop-EBA83A?style=flat-square&labelColor=14121a"> <a href="#model-routing"><img alt="Pairs with jev-model-router" src="https://img.shields.io/badge/pairs%20with-jev--model--router-5DCAA5?style=flat-square&labelColor=14121a"></a>

<b>See what Claude is doing, what it costs you, and when your limits reset, without leaving the prompt.</b>

<a href="#install">Install</a> · <a href="#what-you-get">What you get</a> · <a href="#styles">Styles</a> · <a href="#the-pet">The pet</a> · <a href="#commands">Commands</a> · <a href="#model-routing">Model routing</a> · <a href="#credits">Credits</a>


Deck is a Claude Code mod. It draws a small, collapsible panel in the band above your prompt, in the terminal and in the Desktop app, and keeps it live while Claude works. It only watches and draws: it never blocks a tool call, changes a request, or adds tokens to your conversation.

It pairs with jev-model-router: when the router picks a model and effort for a prompt or a subagent, Deck shows the decision live, with its confidence, so routing stops being invisible.

  • Claude Code Desktop and CLI. The same mod runs in the Desktop app's Code tab and in the terminal, each drawn natively: animated SVG on Desktop, colored text and pictures in the terminal.
  • Your limits, always on screen. 5h and 7d usage with live reset countdowns and burn rates, no /usage needed.
  • Live progress. Plan bars, an activity bar for any task, and subagent strips with the model each one got.
  • Six bar styles, three collapsed looks. Pixel, segments, line, solid, dots and spark; chips, text and rings. Pick yours with /deck style; see them all in Styles.
  • A tiny pet that plays while Claude works and raises a ! when Claude needs you.

<img src="assets/demo-desktop.svg" alt="An illustrative session in the Claude Code desktop app. Deck shows the model route, a plan bar that fills stage by stage with two agents as strips under it, turns amber when a command needs approval and green when done, while the pet plays, alerts, reads and celebrates. Context and the 5h and 7d limits with countdowns sit below. At the end Deck collapses to one status line." width="860">

<img src="assets/demo-terminal.svg" alt="The same session in the terminal (Ghostty): solid bars for the route, the plan with agent rows, context and the 5h and 7d limits, the pet above, and the one-line collapsed view at the end." width="860">

Install

In Claude Code:

/plugin marketplace add nvr0x5/claude-deck
/plugin install deck@deck

Then run /deck demo to see it. In a fresh session the demo fills in a sample model route and sample limits until the real ones arrive.

VS Code: the Claude Code extension's chat panel runs /deck commands but doesn't draw a mod's band or panels yet (checked with extension 2.1.294). Turn on the extension's Claude Code: Use Terminal setting, or run claude in VS Code's terminal, and you get the full Deck; the pet needs a terminal that shows images.

Mods need Claude Code 2.1.286 or later. To try it from a clone without installing, run claude --plugin-dir ./claude-deck.

What you get

RowWhat it showsWhere it comes from
Plan barSteps, the current step, stage linesClaude's todo list, task tools, or a plan you approve in plan mode
Activity barWhat Claude is doing right now (Editing auth.ts · 14 tools · 1m 12s)Any turn without a todo list
Agent stripsEach subagent's current tool, its model, elapsed timeSubagents, under the bar that started them
ContextTokens used out of the windowYour session
5h and 7d limitsPercent used, a live countdown to each reset, and how fast you're burning it (+12%/h)Your plan's rate limits, no /usage needed
Spend (optional)What you've spent today and this monthYour session cost, added up across sessions. Turn on with /deck cost on; it matters on a pay-per-use API key
Model routeWhich model and effort a router picked, and how sure it wasjev-model-router, if installed
Clock and weatherLocal time and the weather where you areOpen-Meteo, no key

When something needs you (a permission prompt, a question, a reply ending in ?), its row turns amber, a collapsed Deck opens by itself, and a soft alert plays. Steps tick, finished bars chime, and the last one plays a little fanfare.

Collapsed, Deck is one line ordered by what matters: anything waiting on you, then your 5h and 7d limits, context, the current task, and the route. Pick its look:

  • /deck collapsed chips: chips with mini bars (the default)
  • /deck collapsed text: one plain status line, with the clock and weather at its end
  • /deck collapsed rings: a ring per limit with its reset time, plus spend: ◔ 14% 5h · resets 1h7m ◕ 83% 7d · resets 3h47m $ $0.10 today · $4.30 mo

Styles

Pick how the bars look with /deck style, a picker with live previews. Each app (Desktop and terminal) keeps its own choice.

<img src="assets/styles.svg" alt="Every Deck bar style (pixel, segments, line, solid, dots, spark) on Desktop and in the terminal, and the three collapsed looks: chips, text and rings." width="860">

StyleCLIDesktop
Pixelbraille dots and a colored pilltwinkling pixels and a gliding pill
Segments▰▰▰▰▱▱▱rounded blocks
Line━━━━╸────thin line with a pulsing head
Solidfilled bar with the text insidefilled bar with a light sweep
Dots●─●─◉┄○ one per stepstep dots
Sparkslim bar and a ▁▂▃▅▇ history of the last hourslim bar and a live sparkline, with the burn rate

The pet

A tiny Claude walks along Deck's header line (in its own strip above the panel with /deck size roomy, and always in the terminal). It wanders, runs, jumps and naps when you're idle, plays a console while Claude works, raises a ! when something needs you, and celebrates when a bar finishes.

On Desktop it's an animated SVG. In the terminal it's drawn as a picture, so it needs a terminal that shows images: Ghostty, Kitty, WezTerm or iTerm2. Elsewhere it simply stays hidden. /deck pet off sends it home.

Commands

CommandWhat it does
/deck, 0 in an empty prompt, or the Deck footer buttonCollapse or expand
/deck styleStyle picker with live previews (keys 1–6)
/deck style <name>segments, line, solid, dots, pixel, spark
`/deck <section> on\off`plans agents context limits route pet clock weather all
/deck city <name>Weather city
/deck panel [off]Open Deck in a panel of its own, always expanded
/deck versionShow which Deck version is running
`/deck size compact\roomy`Row spacing on Desktop (compact is the default)
`/deck collapsed chips\text\rings`Collapsed look: chips with mini bars, one plain line, or limit rings
`/deck cost on\off`Track spend today and this month
`/deck auto on\off`Open by itself when something needs you
`/deck sound on\off`Sounds
`/deck quiet on\off`Hide the router's own lines while Deck shows the route
/deck demoA sample plan with two agents (plus a sample route and limits until real ones arrive)
/deck clearRemove all bars
/deck statusEverything Deck shows, as text

Settings are saved and shared by every session on your machine.

Model routing

Deck works on its own, and it's better with jev-model-router by Daniel Ávila (MIT, part of claude-code-templates). The router uses Jev, TypeSafe's decision model, to pick each subagent's model and each prompt's reasoning effort. Deck shows what it decided.

WhereWhat Deck shows
Model route rowYour model → the one the router picked (haiku, sonnet or opus), the effort, the confidence, a risk flag, how long the decision took, and the last eight decisions as squares: filled when the router switched the model, outlined when Jev only suggested it. "↓ cheaper" or "↑ deeper" appears only when the model really changed
Agent stripsThe model each subagent was given, as a haiku, sonnet or opus tag
Collapsed line⇄ opus · medium, the current route at a glance

Deck only reads the router's output; routing is the router's job, and Deck never changes a request itself. Deck keeps the router's own status and log lines out of the transcript while it shows them as a row; /deck quiet off brings them back.

Add the router. Install it in a project with claude-code-templates:

npx claude-code-templates@latest --mod productivity/jev-model-router

Or load it in every session and keep it up to date on its own. Check out just the router's folder:

git clone --depth 1 --filter=blob:none --sparse https://github.com/davila7/claude-code-templates.git ~/mods/claude-code-templates
git -C ~/mods/claude-code-templates sparse-checkout set cli-tool/components/mods/productivity/jev-model-router

Then, in ~/.claude/settings.json, point CLAUDE_CODE_PLUGIN_DIRS at it and add a SessionStart hook that pulls updates in the background each time Claude Code starts (replace /Users/you with your home folder):

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/mods/claude-code-templates/cli-tool/components/mods/productivity/jev-model-router"
},
"hooks": {
  "SessionStart": [
    { "hooks": [{ "type": "command", "command": "(git -C ~/mods/claude-code-templates pull -q --ff-only >/dev/null 2>&1 &) ; exit 0" }] }
  ]
}

An update pulled at startup takes effect from the next session. Auto-updating runs new upstream code without you reviewing it; leave the hook out and run the git pull yourself if you'd rather check first.

With no key, the router uses Claude Code's built-in classifier and nothing leaves your machine. For a calibrated confidence, add a TypeSafe key in ~/.claude/settings.json. The entry's name depends on how the router is loaded: jev-model-router@skills-dir for the npx install, jev-model-router@inline for a folder in CLAUDE_CODE_PLUGIN_DIRS.

"pluginConfigs": {
  "jev-model-router@inline": { "options": { "provider": "typesafe", "typesafeApiKey": "YOUR_KEY" } }
}

With a key, prompt text goes to TypeSafe. Run /deck quiet off and look for ready on typesafe to confirm the key was picked up.

Main-model routing: by default the router only changes your subagents' models and your main chat's reasoning effort. To let it also switch your main conversation's model (cheaper for short chats, haiku for "what is X?"), add "routeMainModel": true to the router's options:

"jev-model-router@inline": { "options": { "provider": "typesafe", "typesafeApiKey": "YOUR_KEY", "routeMainModel": true } }

This is off by default because switching models throws away the prompt cache, and re-caching long conversations can cost more than the cheaper model saves (especially with your 86% cache hit rate). Turn it on for short sessions or new conversations.

See the router's README for other options.

If the router gets no answer (out of credit, a bad key, or slower than its 800 ms limit), it leaves the model as it is, and the route row says so in amber, e.g. kept opus · ! typesafe 402. After three misses in a row it adds a hint to check the key or credit.

Updating

/plugin → Installed → Deck → update. See CHANGELOG.md for what changed.

Privacy

Deck makes two kinds of network request, both to Open-Meteo and only when you set a city: one to look the city up, then the forecast every 20 minutes. Nothing about your prompts or code leaves your machine.

Develop

claude plugin validate ./claude-deck
cd claude-deck && claude plugin test
node assets/make-banner.mjs && node assets/make-demo.mjs && node assets/make-styles.mjs && node assets/make-social.mjs

The banner, the demos, the styles gallery and the social card are generated from the mod's own drawing code, so they always match what Deck really draws.

Credits

  • The desktop pixel bar, gliding pill, agent strips and plan parsing adapt plan-progress by Kirill Serditov (MIT, see THIRD_PARTY_NOTICES.md).
  • The model route row and the agent model tags read jev-model-router by Daniel Ávila (MIT), built on TypeSafe's Jev.
  • The README layout takes its cue from jev-pilot.

Deck is an independent project, not made or endorsed by Anthropic.

License

MIT

Source 5 files
hooks/register.js 1960 lines
1// deck: a collapsible HUD in the band above the prompt.
2// Bars: todo lists, task tools, approved plans, and subagents as strips.
3// Meters: context and usage limits with reset countdowns. A model-route row shows what a
4// router (jev-model-router) decided. A pet walks above it all. Header: clock and weather.
5//
6// Desktop drawing techniques adapt plan-progress (zycck/claude-mods, MIT, Kirill Serditov).
7
8import { STYLE, TIER, TRACK, DIM, MUTED, TEXT, trunc, textWidth, esc, runs, textCells } from './theme.js'
9import { TERMINAL_STYLES, TERMINAL_STYLE_NAMES, terminalBar, terminalMini } from './styles-terminal.js'
10import { DESKTOP_STYLES, DESKTOP_STYLE_NAMES, desktopTrack, stripsSvg, stripsHeight } from './styles-desktop.js'
11import { createPet, stepPet, petX, petLaneSvg, petFrame, PET_RASTER } from './pet.js'
12
13const SECTIONS = ['plans', 'agents', 'context', 'limits', 'route', 'pet', 'clock', 'weather']
14
15const DEFAULT_PREFS = {
16  show: { plans: true, agents: true, context: true, limits: true, route: true, pet: true, clock: true, weather: false },
17  style: { terminal: 'solid', desktop: 'pixel' },
18  open: false,
19  auto: true,
20  sound: true,
21  quiet: true,
22  size: 'compact', // 'compact' (tight rows, short pet lane) or 'roomy' (the 0.4 spacing)
23  collapsed: 'chips', // 'chips' (mini bars), 'text' (one plain status line) or 'rings' (limit rings and cost)
24  cost: false, // track spend today and this month (pay-per-use API keys)
25  city: null, // { name, lat, lon }
26}
27
28const STATE_STYLE = { running: 'run', needs_input: 'ask', error: 'hot', done: 'done' }
29const AGENT_STYLE = { running: 'run', waiting: 'ask', error: 'hot', done: 'done' }
30const MAX_BARS = 4
31const BAR_LINGER_MS = 120_000
32const AGENTS_LINGER_MS = 30_000
33const FOLD_MS = 5000
34const MAX_STRIPS = 4
35const ASK_DELAY_MS = 600
36const TRACK_H = 22
37const VERSION = '0.4.9' // keep in step with .claude-plugin/plugin.json (a test checks)
38const RIGHT_W = 92
39const TITLE_MAX_CH = 22 // a long prompt as a bar title would squeeze every track
40const TITLE_MAX_PX = 160
41const PET_COLS = 12
42const PET_ROWS = 4
43const SLEEPY_MS = 180_000
44const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
45const TIER_MODEL = { fast: 'haiku', balanced: 'sonnet', deep: 'opus' }
46const MODEL_RANK = { haiku: 0, sonnet: 1, opus: 2 }
47
48const clone = (v) => JSON.parse(JSON.stringify(v))
49let prefs = clone(DEFAULT_PREFS)
50let frame = 0
51let lastPrompt = ''
52let weather = null
53let usage = { context: null, rateLimits: [] }
54let sawTerminal = false
55let demoTimer = null
56let lastSecond = 0
57
58// id -> { id, title, source, stages:[{name, steps:[{title, active, status}]}], explicit, startedAt, doneAt, agentsDoneAt, dismissed }
59const bars = new Map()
60// agentId -> { id, title, state, tool, model, startedAt, endedAt, depth, home, todo }
61const agents = new Map()
62// task tools: id -> { subject, active, status }
63const tasks = new Map()
64const inFlight = new Map()
65const waiting = new Set()
66let askedUser = false
67const lastSeen = new Map()
68const lastHead = new Map()
69const lastStrip = new Map()
70
71// model routing, as a router reports it, and what the turn actually ran with
72let route = null // { tier, conf, model, effort, unchanged, effortScore, risky, ms, at }
73const routeHist = []
74let routeFail = null // why the router's last classification failed: "typesafe 402", "timeout 800ms", "failed"
75let routeFails = 0 // failures in a row
76let sessionModel = ''
77let seen = { model: '', effort: '' }
78
79// a turn with no todo list still gets a bar: what Claude is doing right now
80let activity = null // { title, start, tools, action, done, doneAt }
81const ACTIVITY_LINGER_MS = 20_000
82
83function actionOf(e) {
84  const base = (p) => String(p ?? '').split('/').pop()
85  switch (e.tool) {
86    case 'Read':
87      return 'Reading ' + base(e.file_path)
88    case 'Grep':
89    case 'Glob':
90      return 'Searching'
91    case 'Edit':
92    case 'MultiEdit':
93    case 'Write':
94    case 'NotebookEdit':
95      return 'Editing ' + base(e.file_path ?? e.notebook_path)
96    case 'Bash':
97      return 'Running ' + trunc(String(e.command ?? '').trim().split(/\s+/).slice(0, 2).join(' '), 22)
98    case 'WebFetch':
99    case 'WebSearch':
100      return 'Browsing'
101    case 'Agent':
102    case 'Task':
103      return 'Delegating'
104    case 'TodoWrite':
105      return 'Planning'
106    default:
107      return String(e.tool).replace(/^mcp__[^_]+__/, '')
108  }
109}
110
111function activityRow(now) {
112  if (!activity || !prefs.show.plans) return null
113  if (activity.done && now - activity.doneAt > ACTIVITY_LINGER_MS) return null
114  // a plain chat turn: worth a row while it thinks, not once it has answered
115  if (activity.done && activity.tools === 0 && ![...agents.values()].some((a) => a.home === 'activity')) return null
116  const planned = [...bars.values()].some((b) => b.id !== 'agents' && !b.dismissed && b.startedAt >= activity.start - 1000)
117  if (planned) return null
118  const asking = !activity.done && (waiting.has('main') || [...agents.values()].some((a) => a.home === 'activity' && a.state === 'waiting'))
119  const style = activity.done ? 'done' : asking ? 'ask' : 'run'
120  const pct = activity.done ? 100 : 95 * (1 - Math.exp(-activity.tools / 18))
121  const time = elapsed((activity.done ? activity.doneAt : now) - activity.start)
122  const name = activity.done ? 'Done' : asking ? 'Needs you' : trunc(activity.action, 28)
123  const mine = [...agents.values()].filter((a) => a.home === 'activity')
124  const agentNote = mine.length ? ' · ' + mine.filter((a) => a.state === 'done').length + '/' + mine.length + ' agents' : ''
125  return {
126    id: 'activity',
127    kind: 'activity',
128    style,
129    state: activity.done ? 'done' : asking ? 'needs_input' : 'running',
130    title: activity.title,
131    pct,
132    marks: [],
133    steps: 0,
134    finished: 0,
135    name,
136    count: activity.tools + (activity.tools === 1 ? ' tool' : ' tools') + agentNote,
137    strips: visibleStrips({ id: 'activity', agentsDoneAt: activity.agentsDoneAt }, now),
138    right: time,
139    short: name + ' · ' + activity.tools,
140    stage: 0,
141    animating: !activity.done,
142  }
143}
144
145// usage history, for sparklines and burn rates: key -> [{ t, p }]
146let hist = {}
147const HIST_KEEP_MS = 26 * 3600_000
148
149function sampleUsage(now) {
150  const add = (key, p) => {
151    if (p == null) return false
152    const list = (hist[key] = hist[key] ?? [])
153    const last = list[list.length - 1]
154    if (last && last.p === p && now - last.t < 300_000) return false
155    if (last && now - last.t < 20_000) last.p = p
156    else list.push({ t: now, p })
157    while (list.length && now - list[0].t > HIST_KEEP_MS) list.shift()
158    if (list.length > 400) list.splice(0, list.length - 400)
159    return true
160  }
161  let changed = add('context', usage.context?.percent)
162  for (const rl of usage.rateLimits ?? []) changed = add(rl.kind, rl.percentUsed) || changed
163  return changed
164}
165
166// the values over a window, for a sparkline; the newest point is now
167function series(key, windowMs, now) {
168  const list = (hist[key] ?? []).filter((x) => now - x.t <= windowMs)
169  return list.map((x) => x.p)
170}
171
172// how fast a window moves: percent per hour (or per day for the weekly window)
173function burnRate(key, now) {
174  const daily = key.startsWith('seven_day')
175  const list = (hist[key] ?? []).filter((x) => now - x.t <= (daily ? 24 : 1) * 3600_000)
176  if (list.length < 2) return ''
177  const a = list[0]
178  const b = list[list.length - 1]
179  const span = b.t - a.t
180  if (span < 5 * 60_000 || b.p < a.p) return ''
181  const per = ((b.p - a.p) / span) * (daily ? 86_400_000 : 3_600_000)
182  if (per < 0.05) return ''
183  return '+' + (per < 10 ? per.toFixed(1) : Math.round(per)) + (daily ? '%/day' : '%/h')
184}
185
186// spend: this session's running total, added into today and this month across sessions
187let sessionId = ''
188let costSeen = null
189let spend = { day: '', dayUsd: 0, month: '', monthUsd: 0 }
190const today = () => {
191  const d = new Date()
192  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
193}
194const fmtUsd = (v) => '$' + (v < 100 ? v.toFixed(2) : Math.round(v))
195
196// open and close unfold row by row instead of jumping
197let fold = null // { dir: 'open' | 'close', start, timer }
198const FOLD_STEP = 35
199const FOLD_FADE = 220
200
201const isOpening = () => (fold ? fold.dir === 'open' : prefs.open)
202
203// how far the fold has run: wall time, or the timer's own ticks if those ran further
204const foldAge = (now) => (fold ? Math.max(now - fold.start, fold.ticks * 33) : 0)
205
206function foldCount(n, now) {
207  if (!fold) return n
208  const k = Math.floor(foldAge(now) / FOLD_STEP)
209  return fold.dir === 'open' ? Math.min(n, k + 1) : Math.max(0, n - k)
210}
211
212// a row that just unfolded fades and rises in; its age keeps a redraw from restarting it
213function withFadeIn(source, ageMs) {
214  if (ageMs >= FOLD_FADE) return source
215  const head = source.indexOf('>') + 1
216  const tail = source.lastIndexOf('</svg>')
217  return (
218    source.slice(0, head) +
219    `<style>@keyframes rin{from{opacity:0;transform:translateY(5px)}}.rin{animation:rin ${FOLD_FADE}ms cubic-bezier(.22,1,.36,1) both;animation-delay:-${Math.max(0, Math.round(ageMs))}ms}@media (prefers-reduced-motion:reduce){.rin{animation:none}}</style><g class="rin">` +
220    source.slice(head, tail) +
221    '</g>' +
222    source.slice(tail)
223  )
224}
225
226// the pet and what it reacts to
227const pet = createPet(Date.now())
228let turnActive = false
229// /deck demo fills in a sample route and limits when the real ones haven't arrived yet;
230// the first real reading replaces them
231let demoRoute = false
232let demoUsage = false
233let lastActivity = Date.now()
234let celebrateUntil = 0
235
236// ---------- small helpers ----------
237
238function slug(s) {
239  return String(s ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 40) || 'plan'
240}
241
242// the words the person typed: tagged blocks (system reminders, pasted content, attachments) removed
243function cleanPrompt(text) {
244  return String(text)
245    .replace(/<([a-z][\w-]*)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
246    .replace(/<[^>]+>/g, ' ')
247    .replace(/\s+/g, ' ')
248    .trim()
249}
250
251function fmtK(n) {
252  if (n == null) return '?'
253  if (n >= 1_000_000) return +(n / 1_000_000).toFixed(1) + 'M'
254  return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
255}
256
257function countdown(iso) {
258  if (!iso) return ''
259  let s = Math.max(0, Math.floor((Date.parse(iso) - Date.now()) / 1000))
260  const d = Math.floor(s / 86400)
261  const h = Math.floor((s % 86400) / 3600)
262  const m = Math.floor((s % 3600) / 60)
263  s = s % 60
264  if (d > 0) return d + 'd ' + h + 'h ' + String(m).padStart(2, '0') + 'm'
265  return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
266}
267
268// "1h7m", "3d 5h", "42m": for the rings, where seconds would only flicker
269function humanReset(iso) {
270  const s = Math.max(0, Math.floor((Date.parse(iso) - Date.now()) / 1000))
271  const d = Math.floor(s / 86400)
272  const h = Math.floor((s % 86400) / 3600)
273  const m = Math.floor((s % 3600) / 60)
274  return d > 0 ? d + 'd ' + h + 'h' : h > 0 ? h + 'h' + m + 'm' : m + 'm'
275}
276
277function shortCountdown(iso) {
278  const c = countdown(iso)
279  return c.includes('d ') ? c.split(' ').slice(0, 2).join(' ') : c
280}
281
282function elapsed(ms) {
283  const s = Math.max(0, Math.round(ms / 1000))
284  return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's'
285}
286
287function clockText() {
288  const d = new Date()
289  return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':')
290}
291
292const WMO = [
293  [0, '☀', 'clear'], [3, '⛅', 'cloudy'], [48, '🌫', 'fog'], [57, '☂', 'drizzle'],
294  [67, '☂', 'rain'], [77, '❄', 'snow'], [82, '☂', 'showers'], [86, '❄', 'snow'], [99, '⛈', 'storm'],
295]
296function weatherText(desktop) {
297  if (!weather) return prefs.city ? '… ' + prefs.city.name : ''
298  const [, icon, word] = WMO.find(([max]) => weather.code <= max) ?? WMO[WMO.length - 1]
299  const t = Math.round(weather.temp) + '°'
300  return desktop ? icon + ' ' + t + ' ' + weather.name : t + ' ' + word + ' · ' + weather.name
301}
302
303function limitName(kind) {
304  if (kind === 'five_hour') return ['Session · 5h', '5h']
305  if (kind === 'seven_day') return ['Weekly · 7d', '7d']
306  if (kind === 'seven_day_opus') return ['Weekly · Opus', '7d-opus']
307  if (kind === 'spend_limit') return ['Spend limit', '$']
308  return [kind, kind]
309}
310
311function shortModel(id) {
312  const s = String(id ?? '').toLowerCase()
313  if (s.includes('haiku')) return 'haiku'
314  if (s.includes('sonnet')) return 'sonnet'
315  if (s.includes('opus')) return 'opus'
316  if (s.includes('fable')) return 'fable'
317  return trunc(id, 14)
318}
319
320function tierColor(model, desktop = true) {
321  const t = TIER[shortModel(model)]
322  return t ? (desktop ? t.desk : t.term) : MUTED
323}
324
325function toBase64(bytes) {
326  if (typeof bytes.toBase64 === 'function') return bytes.toBase64()
327  let s = ''
328  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
329  return btoa(s)
330}
331
332// ---------- plans ----------
333
334const isFinished = (s) => s === 'done' || s === 'skipped'
335const todoStatus = (s) => (s === 'completed' ? 'done' : s === 'in_progress' ? 'active' : 'pending')
336const allSteps = (bar) => bar.stages.flatMap((s) => s.steps)
337
338function where(bar) {
339  const flat = bar.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
340  const at = flat.findIndex((x) => !isFinished(x.step.status))
341  const pos = at < 0 ? flat.length : at
342  const cur = flat[Math.min(pos, flat.length - 1)]
343  const stage = cur?.i ?? 0
344  return {
345    pos,
346    total: flat.length,
347    finished: flat.filter((x) => isFinished(x.step.status)).length,
348    active: flat.find((x) => x.step.status === 'active')?.step ?? null,
349    stage,
350    step: pos >= flat.length ? (bar.stages[stage]?.steps.length ?? 0) : (cur?.j ?? 0) + 1,
351    stageSize: bar.stages[stage]?.steps.length ?? 0,
352  }
353}
354
355function newBar(id, title, source, stages) {
356  return { id, title, source, stages, explicit: null, startedAt: Date.now(), doneAt: null, agentsDoneAt: null, dismissed: false }
357}
358
359function agentsOf(bar) {
360  return [...agents.values()].filter((a) => a.home === bar.id)
361}
362
363function barState(bar) {
364  if (bar.explicit === 'error') return 'error'
365  if (bar.id === 'agents') {
366    const list = agentsOf(bar)
367    if (list.some((a) => a.state === 'waiting')) return 'needs_input'
368    if (list.length && list.every((a) => a.state === 'done' || a.state === 'error')) return list.some((a) => a.state === 'error') ? 'error' : 'done'
369    return 'running'
370  }
371  const steps = allSteps(bar)
372  if (steps.length && steps.every((s) => isFinished(s.status))) return 'done'
373  if (bar.explicit === 'needs_input') return 'needs_input'
374  if (agentsOf(bar).some((a) => a.state === 'waiting')) return 'needs_input'
375  return 'running'
376}
377
378function liveBar() {
379  return [...bars.values()].reverse().find((b) => b.id !== 'agents' && !b.dismissed && barState(b) !== 'done')
380}
381
382function placeBar(bar) {
383  bars.delete(bar.id)
384  bars.set(bar.id, bar)
385  while (bars.size > MAX_BARS) {
386    const done = [...bars.values()].find((b) => barState(b) === 'done')
387    bars.delete((done ?? bars.values().next().value).id)
388  }
389}
390
391function syncAgentsBar() {
392  const bar = bars.get('agents')
393  if (!bar) return
394  const rank = (a) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
395  const list = agentsOf(bar).sort((a, b) => rank(a) - rank(b))
396  bar.stages = [{ name: 'Agents', steps: list.map((a) => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active' })) }]
397}
398
399// moves the demo bar one step
400function stepOn(stages) {
401  const next = stages.map((s) => ({ ...s, steps: s.steps.map((st) => ({ ...st })) }))
402  const steps = next.flatMap((s) => s.steps)
403  let at = steps.findIndex((st) => st.status === 'active')
404  if (at < 0) at = steps.findIndex((st) => !isFinished(st.status))
405  if (steps[at]) steps[at].status = 'done'
406  const following = steps.slice(at + 1).find((st) => st.status === 'pending')
407  if (following) following.status = 'active'
408  return next
409}
410
411// an approved plan's markdown: headings become stages, top-level list items steps
412function parsePlan(markdown) {
413  const clean = (s) => s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/^(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '').trim()
414  let title = ''
415  const headed = []
416  const items = []
417  for (const line of String(markdown).split(/\r?\n/)) {
418    const h = line.match(/^(#{1,4})\s+(.*)$/)
419    if (h) {
420      const text = clean(h[2] ?? '')
421      if (h[1] === '#' && !title) title = text
422      else headed.push({ name: trunc(text, 60), steps: [] })
423      continue
424    }
425    const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
426    if (!li) continue
427    const depth = Math.floor(li[1].replace(/\t/g, '  ').length / 2)
428    const text = trunc(clean(li[3] ?? ''), 80)
429    if (!text) continue
430    items.push({ depth, text })
431    const stage = headed[headed.length - 1]
432    if (stage && depth === 0) stage.steps.push({ title: text, status: 'pending' })
433  }
434  let stages = headed.filter((s) => s.steps.length > 0)
435  if (!stages.length && items.length) stages = [{ name: 'Plan', steps: items.filter((i) => i.depth === 0).map((i) => ({ title: i.text, status: 'pending' })) }]
436  stages = stages.filter((s) => s.steps.length > 0)
437  if (!stages.length) return null
438  stages[0].steps[0].status = 'active'
439  return { title: trunc(title, 60) || 'Plan', stages }
440}
441
442function upsertTodos(key, todos) {
443  const steps = todos.map((t) => ({ title: trunc(t.content, 80), active: trunc(t.activeForm, 80), status: todoStatus(t.status) }))
444  if (key !== 'main') {
445    const a = agents.get(key)
446    if (a) {
447      const cur = steps.find((s) => s.status === 'active')
448      a.todo = { done: steps.filter((s) => s.status === 'done').length, total: steps.length, active: cur ? cur.active || cur.title : '' }
449    }
450    return
451  }
452  const prev = bars.get('todo')
453  const fresh = !prev || barState(prev) === 'done' || prev.dismissed
454  const bar = fresh ? newBar('todo', lastPrompt || 'Tasks', 'todo', []) : prev
455  bar.stages = [{ name: 'Tasks', steps }]
456  bar.explicit = null
457  placeBar(bar)
458}
459
460function applyTaskResult(input, result) {
461  if (input.tool === 'TaskCreate') {
462    const id = result?.task?.id
463    if (id == null) return
464    const list = [...tasks.values()].filter((t) => t.status !== 'deleted')
465    if (list.length && list.every((t) => t.status === 'completed')) tasks.clear()
466    tasks.set(String(id), { subject: trunc(input.subject ?? result.task.subject, 80), active: trunc(input.activeForm, 80), status: 'pending' })
467  } else if (input.tool === 'TaskUpdate') {
468    if (result && result.success === false) return
469    const t = tasks.get(String(input.taskId))
470    if (!t) return
471    if (input.status) t.status = input.status
472    if (input.subject) t.subject = trunc(input.subject, 80)
473    if (input.activeForm) t.active = trunc(input.activeForm, 80)
474  } else if (Array.isArray(result?.tasks)) {
475    const ids = new Set()
476    for (const r of result.tasks) {
477      const id = String(r.id)
478      ids.add(id)
479      const t = tasks.get(id)
480      if (t) t.status = r.status
481    }
482    for (const id of [...tasks.keys()]) if (!ids.has(id)) tasks.delete(id)
483  }
484  syncTasksBar()
485}
486
487function syncTasksBar() {
488  const list = [...tasks.values()].filter((t) => t.status !== 'deleted')
489  if (!list.length) return
490  const steps = list.map((t) => ({ title: t.subject, active: t.active || t.subject, status: todoStatus(t.status) }))
491  const prev = bars.get('tasks')
492  const bar = prev && !prev.dismissed && barState(prev) !== 'done' ? prev : newBar('tasks', lastPrompt || 'Tasks', 'tasks', [])
493  bar.stages = [{ name: 'Tasks', steps }]
494  placeBar(bar)
495}
496
497// ---------- routing: read what a router says ----------
498
499// "jev · fast 0.87 → haiku/low", "jev · fast 0.41 · unchanged", "jev · no answer"
500function dropDemoRoute() {
501  if (!demoRoute) return
502  demoRoute = false
503  route = null
504  routeHist.length = 0
505}
506
507function readRouteStatus(text) {
508  if (/^jev · no answer/.test(String(text))) {
509    dropDemoRoute()
510    const prev = route && !route.noAnswer ? route : route?.last
511    route = { noAnswer: true, reason: routeFail || 'no answer', fails: routeFails, last: prev ?? null, at: Date.now() }
512    routeHist.push('none')
513    if (routeHist.length > 8) routeHist.shift()
514    return true
515  }
516  const m = String(text).match(/^jev · (\w+) ([\d.]+|n\/d)(?: → (\S+)| · unchanged)?/)
517  if (!m) return false
518  dropDemoRoute()
519  const r = { tier: m[1], conf: m[2] === 'n/d' ? null : Number(m[2]), model: '', effort: '', unchanged: !m[3], at: Date.now() }
520  for (const part of String(m[3] ?? '').split('/').filter(Boolean)) {
521    if (EFFORTS.includes(part)) r.effort = part
522    else r.model = shortModel(part)
523  }
524  const prev = route
525  route = { ...(prev && Date.now() - prev.at < 5000 ? prev : {}), ...r }
526  // filled when the router changed the model, outlined when Jev only suggested it
527  routeHist.push(r.model ? r.model : '~' + (TIER_MODEL[r.tier] ?? r.tier))
528  if (routeHist.length > 8) routeHist.shift()
529  return true
530}
531
532// "[jev-model-router] jev: tier fast (0.87) · effort 0.4 → low (0.71) · risky 0.02 · 249ms"
533function readRouteLog(text) {
534  const t = String(text)
535  const fail =
536    t.match(/\] (\w+) responded (\d+)/)?.slice(1).join(' ') ??
537    (t.match(/classification passed (\d+)ms/)?.[1] ? 'timeout ' + t.match(/classification passed (\d+)ms/)[1] + 'ms' : null) ??
538    (/classification failed|built-in classifier failed/.test(t) ? 'request failed' : null)
539  if (fail) {
540    routeFail = fail
541    routeFails++
542    return true
543  }
544  const m = String(text).match(/jev: tier (\w+) \(([\d.]+|n\/d)\)(?: · effort ([\d.]+) → (\w+) \([^)]*\))?(?: · risky ([\d.]+|n\/d))?(?: · (\d+)ms)?/)
545  if (!m) return false
546  dropDemoRoute()
547  routeFail = null
548  routeFails = 0
549  route = {
550    ...(route && !route.noAnswer ? route : {}),
551    tier: m[1],
552    conf: m[2] === 'n/d' ? null : Number(m[2]),
553    effortScore: m[3] ? Number(m[3]) : null,
554    risky: m[5] && m[5] !== 'n/d' ? Number(m[5]) : null,
555    ms: m[6] ? Number(m[6]) : null,
556    at: Date.now(),
557  }
558  return true
559}
560
561function routeRow() {
562  if (!prefs.show.route || !route) return null
563  const from = shortModel(seen.model || sessionModel) || 'session'
564  if (route.noAnswer) {
565    return {
566      id: 'route',
567      kind: 'route',
568      style: 'warn',
569      title: 'Model route',
570      noAnswer: true,
571      reason: route.reason,
572      hint: route.fails >= 3 ? 'check router key or credit' : '',
573      from,
574      want: from,
575      to: from,
576      effort: seen.effort || '',
577      dir: '=',
578      right: '! no answer',
579      short: '⇄ ' + from + ' · no answer',
580      pct: 0,
581      marks: [],
582    }
583  }
584  if (!route.tier) return null
585  const want = TIER_MODEL[route.tier] ?? route.tier
586  const to = route.model || from
587  const effort = route.effort || seen.effort || ''
588  const toRank = MODEL_RANK[to]
589  const fromRank = MODEL_RANK[from]
590  // only a model the router actually switched to counts as cheaper or deeper
591  const dir = !route.model ? '=' : toRank != null && fromRank != null ? (toRank < fromRank ? '↓' : toRank > fromRank ? '↑' : '=') : '='
592  const unapplied = want !== to
593  return {
594    id: 'route',
595    kind: 'route',
596    style: 'run',
597    title: 'Model route',
598    from,
599    want,
600    to,
601    effort,
602    conf: route.conf ?? null,
603    risky: route.risky ?? null,
604    ms: route.ms ?? null,
605    tier: route.tier,
606    unapplied,
607    dir,
608    right: dir === '↓' ? '↓ cheaper' : dir === '↑' ? '↑ deeper' : '= kept',
609    short: '⇄ ' + to + (effort ? '·' + effort : ''),
610    pct: 0,
611    marks: [],
612  }
613}
614
615// ---------- rows ----------
616
617function visibleStrips(bar, now) {
618  if (!prefs.show.agents) return null
619  const list = agentsOf(bar)
620  if (!list.length) return null
621  const hasError = list.some((a) => a.state === 'error')
622  if (bar.agentsDoneAt && now - bar.agentsDoneAt > FOLD_MS && !hasError) return null
623  if (list.length <= MAX_STRIPS) return { shown: list, hidden: [] }
624  const keep = new Set(list.filter((a) => a.state !== 'done').slice(0, MAX_STRIPS - 1).map((a) => a.id))
625  for (const a of [...list].reverse()) {
626    if (keep.size >= MAX_STRIPS - 1) break
627    keep.add(a.id)
628  }
629  return { shown: list.filter((a) => keep.has(a.id)), hidden: list.filter((a) => !keep.has(a.id)) }
630}
631
632function stripTool(a) {
633  if (a.state === 'waiting') return 'Needs approval'
634  if (a.todo && a.todo.total && a.state === 'running') return trunc(a.todo.active || 'Tasks', 24) + ' ' + Math.min(a.todo.total, a.todo.done + 1) + '/' + a.todo.total
635  return a.tool
636}
637
638function barRow(bar, now) {
639  const state = barState(bar)
640  const w = where(bar)
641  const done = state === 'done'
642  const single = bar.stages.length === 1
643  const list = agentsOf(bar)
644  const agentNote = list.length && bar.id !== 'agents' ? ' · ' + list.filter((a) => a.state === 'done').length + '/' + list.length + ' agents' : ''
645  let name
646  let count
647  if (bar.id === 'agents') {
648    name = done ? 'Done' : 'Agents'
649    count = w.finished + '/' + w.total
650  } else if (done) {
651    name = 'Done'
652    count = w.total + '/' + w.total
653  } else if (single) {
654    name = w.active ? w.active.active || w.active.title : bar.stages[0].name
655    count = Math.min(w.total, w.pos + 1) + '/' + w.total
656  } else {
657    name = bar.stages[w.stage]?.name ?? ''
658    count = w.step + '/' + w.stageSize
659  }
660  const marks = []
661  let k = 0
662  for (const s of bar.stages) {
663    s.steps.forEach((_, j) => {
664      if (k > 0) marks.push({ f: k / w.total, stage: !single && j === 0 })
665      k++
666    })
667  }
668  const pct = done ? 100 : ((w.finished + (w.active ? 0.4 : 0)) / Math.max(1, w.total)) * 100
669  return {
670    id: 'bar:' + bar.id,
671    kind: 'bar',
672    bar,
673    style: STATE_STYLE[state],
674    state,
675    title: bar.title || 'Plan',
676    pct,
677    marks,
678    steps: w.total,
679    finished: w.finished,
680    name: trunc(name, 30),
681    count: count + agentNote,
682    right: Math.round(pct) + '%',
683    short: done ? '✓ ' + trunc(bar.title, 14) : trunc(name, 16) + ' ' + count,
684    stage: w.finished,
685    animating: !done,
686    strips: visibleStrips(bar, now),
687  }
688}
689
690function collectRows(now) {
691  const rows = []
692  const rr = routeRow()
693  if (rr) rows.push(rr)
694  for (const bar of bars.values()) {
695    if (bar.dismissed) continue
696    if (bar.id === 'agents' ? !prefs.show.agents : !prefs.show.plans) continue
697    if (bar.id === 'agents' && !agentsOf(bar).length) continue
698    if (!allSteps(bar).length) continue
699    const linger = bar.id === 'agents' ? AGENTS_LINGER_MS : BAR_LINGER_MS
700    if (barState(bar) === 'done' && bar.doneAt && now - bar.doneAt > linger) continue
701    rows.push(barRow(bar, now))
702  }
703  const act = activityRow(now)
704  if (act) rows.push(act)
705  const ctx = usage.context
706  if (prefs.show.context && ctx && ctx.percent != null) {
707    const p = ctx.percent
708    rows.push({
709      id: 'context',
710      kind: 'meter',
711      style: p >= 80 ? 'hot' : p >= 60 ? 'warn' : 'run',
712      title: 'Context',
713      pct: p,
714      marks: [{ f: 0.5 }, { f: 0.8 }],
715      name: fmtK(ctx.tokens) + ' / ' + fmtK(ctx.window),
716      count: '',
717      right: Math.round(p) + '%',
718      short: 'ctx ' + Math.round(p) + '%',
719      section: 'context',
720      hist: series('context', 3600_000, now),
721      rate: burnRate('context', now),
722    })
723  }
724  if (prefs.show.limits) {
725    for (const rl of usage.rateLimits ?? []) {
726      const [title, tag] = limitName(rl.kind)
727      const p = rl.percentUsed
728      rows.push({
729        id: 'limit:' + rl.kind,
730        kind: 'meter',
731        limit: true,
732        style: p >= 80 ? 'hot' : p >= 50 ? 'warn' : 'ok',
733        title,
734        pct: Math.min(100, p),
735        marks: [{ f: 0.25 }, { f: 0.5 }, { f: 0.75 }],
736        name: tag + ' · ' + Math.round(p) + '% used',
737        count: '',
738        right: rl.resetsAt ? '↻ ' + countdown(rl.resetsAt) : '',
739        short: tag + ' ' + Math.round(p) + '%' + (rl.resetsAt ? ' ↻' + humanReset(rl.resetsAt) : ''),
740        section: 'limits',
741        kindKey: rl.kind,
742        resets: rl.resetsAt ? humanReset(rl.resetsAt) : '',
743        hist: series(rl.kind, rl.kind.startsWith('seven_day') ? 24 * 3600_000 : 3600_000, now),
744        rate: burnRate(rl.kind, now),
745      })
746    }
747  }
748  return rows
749}
750
751// collapsed: what needs you first, then limits, context, bars, the route
752function collapsedOrder(rows) {
753  const rank = (r) => (r.state === 'needs_input' ? 0 : r.limit ? 1 : r.id === 'context' ? 2 : r.kind === 'bar' ? 3 : 4)
754  return [...rows].sort((a, b) => rank(a) - rank(b))
755}
756
757function petMood(rows, now) {
758  if (rows.some((r) => r.state === 'needs_input')) return 'alert'
759  if (now < celebrateUntil) return 'celebrate'
760  if (turnActive) return 'working'
761  if (now - lastActivity > SLEEPY_MS) return 'sleepy'
762  return 'calm'
763}
764
765// ---------- drawing pieces ----------
766
767function routeCellsTerminal(row) {
768  const cells = []
769  const push = (s, color, bg, bold) => cells.push(...textCells(s, color, bg, bold))
770  if (row.noAnswer) {
771    push('kept ', DIM)
772    push(' ' + row.to + ' ', '#1e1e1e', tierColor(row.to, false), true)
773    push('  ! ' + row.reason, STYLE.warn.fill)
774    if (row.hint) push(' · ' + row.hint, DIM)
775    push('  ', MUTED)
776    for (const t of routeHist) push(...histCell(t))
777    return { cells, dirColor: STYLE.warn.fill }
778  }
779  const dirColor = row.dir === '↓' ? STYLE.ok.fill : row.dir === '↑' ? STYLE.warn.fill : MUTED
780  push(row.from + ' ', DIM)
781  push(row.dir === '=' ? '= ' : '→ ', dirColor)
782  push(' ' + row.to + ' ', '#1e1e1e', tierColor(row.to, false), true)
783  if (row.unapplied) push(' jev: ' + row.want + ' · not applied', DIM)
784  if (row.effort) {
785    const lvl = Math.max(1, EFFORTS.indexOf(row.effort) + 1)
786    push('  effort ', MUTED)
787    push('▁▃▅▇█'.slice(0, Math.min(5, lvl)), STYLE.run.fill)
788    push('·'.repeat(Math.max(0, 4 - lvl)) + ' ' + row.effort, DIM)
789  }
790  if (row.conf != null) push('  conf ' + row.conf.toFixed(2), row.conf >= 0.6 ? STYLE.ok.fill : STYLE.warn.fill)
791  if (row.risky != null && row.risky > 0.7) push('  ⚠ risky', STYLE.hot.fill)
792  if (row.ms != null) push('  ' + row.ms + 'ms', DIM)
793  push('  ', MUTED)
794  for (const t of routeHist) push(...histCell(t))
795  return { cells, dirColor }
796}
797
798// one route-history slot: a failure, a suggestion the router did not apply, or an applied model
799function histSquare(t, hx) {
800  if (t === 'none') return `<rect x="${hx + 0.5}" y="8" width="6" height="6" rx="2" fill="none" stroke="${STYLE.warn.desk}"/>`
801  if (t.startsWith('~')) return `<rect x="${hx + 0.75}" y="8.25" width="5.5" height="5.5" rx="1.75" fill="none" stroke="${tierColor(t.slice(1))}" stroke-width="1.5"/>`
802  return `<rect x="${hx}" y="7.5" width="7" height="7" rx="2" fill="${tierColor(t)}" fill-opacity=".85"/>`
803}
804
805function histCell(t) {
806  if (t === 'none') return ['□', STYLE.warn.fill]
807  if (t.startsWith('~')) return ['□', tierColor(t.slice(1), false)]
808  return ['■', tierColor(t, false)]
809}
810
811function routeSvg(row, W) {
812  const F = "'Anthropic Sans',ui-sans-serif,system-ui,sans-serif"
813  let x = 0
814  if (row.noAnswer) {
815    let o = `<text x="0" y="15" style="font:400 12px ${F};fill:#77756f">kept</text>`
816    x = 34
817    const w = textWidth(row.to, 6.4) + 14
818    o += `<rect x="${x}" y="3" width="${w}" height="16" rx="4" fill="${tierColor(row.to)}"/><text x="${x + 7}" y="15" style="font:500 11.5px ${F};fill:#fff">${esc(row.to)}</text>`
819    x += w + 12
820    o += `<text x="${x}" y="15" style="font:500 12px ${F};fill:${STYLE.warn.desk}">! ${esc(row.reason)}</text>`
821    x += textWidth('! ' + row.reason, 6.6) + 10
822    if (row.hint) {
823      o += `<text x="${x}" y="15" style="font:400 11.5px ${F};fill:#9a9893">· ${esc(row.hint)}</text>`
824      x += textWidth('· ' + row.hint, 6.4) + 10
825    }
826    const histW = routeHist.length * 10
827    if (x + 12 + histW <= W) {
828      let hx = Math.max(x + 12, W - histW)
829      for (const t of routeHist) {
830        o += histSquare(t, hx)
831        hx += 10
832      }
833    }
834    return o
835  }
836  let out = `<text x="0" y="15" style="font:400 12px ${F};fill:#77756f">${esc(row.from)}</text>`
837  x += textWidth(row.from, 6.4) + 6
838  const dirColor = row.dir === '↓' ? STYLE.ok.desk : row.dir === '↑' ? STYLE.warn.desk : '#9a9893'
839  out += `<text x="${x}" y="15" style="font:500 12px ${F};fill:${dirColor}">${row.dir === '=' ? '=' : '→'}</text>`
840  x += 14
841  for (const t of ['haiku', 'sonnet', 'opus']) {
842    const on = t === row.to
843    const want = t === row.want && !on
844    const w = textWidth(t, 6.4) + 14
845    out += `<rect x="${x}" y="3" width="${w}" height="16" rx="4" fill="${on ? tierColor(t) : '#808080'}" fill-opacity="${on ? 1 : 0.16}"${want ? ` stroke="${tierColor(t)}" stroke-dasharray="2 2"` : ''}/>`
846    out += `<text x="${x + 7}" y="15" style="font:${on ? 500 : 400} 11.5px ${F};fill:${on ? '#fff' : '#9a9893'}">${t}</text>`
847    x += w + 4
848  }
849  x += 8
850  if (row.effort) {
851    const lvl = Math.max(1, EFFORTS.indexOf(row.effort) + 1)
852    for (let i = 1; i <= 4; i++) out += `<rect x="${x + (i - 1) * 7}" y="${17 - (3 + i * 3)}" width="5" height="${3 + i * 3}" rx="1.5" fill="${i <= lvl ? STYLE.run.desk : '#808080'}" fill-opacity="${i <= lvl ? 1 : 0.25}"/>`
853    x += 32
854    out += `<text x="${x}" y="15" style="font:400 12px ${F};fill:#c9c7c1">${esc(row.effort)}</text>`
855    x += textWidth(row.effort, 6.4) + 14
856  }
857  if (row.conf != null) {
858    const c = row.conf >= 0.6 ? STYLE.ok.desk : STYLE.warn.desk
859    out += `<text x="${x}" y="15" style="font:400 11.5px ${F};fill:#77756f">conf</text>`
860    x += 30
861    out += `<rect x="${x}" y="8" width="44" height="6" rx="3" fill="#808080" fill-opacity=".25"/><rect x="${x}" y="8" width="${(44 * row.conf).toFixed(1)}" height="6" rx="3" fill="${c}"/>`
862    x += 50
863    out += `<text x="${x}" y="15" style="font:400 11.5px ${F};fill:#9a9893">${row.conf.toFixed(2)}</text>`
864    x += 40
865  }
866  if (row.risky != null && row.risky > 0.7) {
867    out += `<text x="${x}" y="15" style="font:500 11.5px ${F};fill:${STYLE.hot.desk}">⚠ risky</text>`
868    x += 56
869  }
870  if (row.unapplied) {
871    out += `<text x="${x}" y="15" style="font:400 11.5px ${F};fill:#77756f">not applied</text>`
872    x += 72
873  }
874  const histW = routeHist.length * 10
875  if (x + 12 + histW > W) return out
876  let hx = Math.max(x + 12, W - histW)
877  for (const t of routeHist) {
878    out += histSquare(t, hx)
879    hx += 10
880  }
881  return out
882}
883
884// a small progress ring with an icon in the middle
885function ringSvg(pct, color, icon) {
886  const r = 8.5
887  const C = 2 * Math.PI * r
888  const glyph =
889    icon === 'usd'
890      ? `<text x="11" y="15" text-anchor="middle" style="font:600 10px ui-sans-serif,system-ui,sans-serif;fill:${color}">$</text>`
891      : icon === 'week'
892        ? `<rect x="7.2" y="7.6" width="7.6" height="7" rx="1.2" fill="none" stroke="${color}" stroke-width="1.3"/><rect x="7.2" y="7.6" width="7.6" height="2" fill="${color}"/>`
893        : `<path d="M11 7.2V11l2.4 1.6" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`
894  return (
895    `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">` +
896    `<circle cx="11" cy="11" r="${r}" fill="none" stroke="#808080" stroke-opacity=".3" stroke-width="2.2"/>` +
897    `<circle cx="11" cy="11" r="${r}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linecap="round" stroke-dasharray="${((C * Math.min(100, pct)) / 100).toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 11 11)"/>` +
898    glyph +
899    '</svg>'
900  )
901}
902
903function rowSvg(row, W, nowMs) {
904  const stripsH = row.strips ? 5 + stripsHeight(row.strips) : 0
905  // compact rows sit with no gap between them; a little air above and below keeps the pills apart
906  const pad = prefs.size === 'roomy' ? 0 : 3
907  const H = TRACK_H + stripsH + pad * 2
908  const total = W + RIGHT_W
909  const track = row.kind === 'route' ? routeSvg(row, W) : desktopTrack(prefs.style.desktop, row, W, nowMs, lastHead)
910  const rightColor = row.kind === 'route' ? (row.noAnswer ? STYLE.warn.desk : row.dir === '↓' ? STYLE.ok.desk : row.dir === '↑' ? STYLE.warn.desk : '#9a9893') : '#9a9893'
911  return {
912    height: H,
913    width: total,
914    source:
915      `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${H}" viewBox="0 ${-pad} ${total} ${H}">` +
916      track +
917      `<text x="${total - 4}" y="${TRACK_H / 2 + 4.2}" text-anchor="end" style="font:400 12px 'Anthropic Sans',ui-sans-serif,system-ui,sans-serif;fill:${rightColor};font-variant-numeric:tabular-nums">${esc(row.right)}</text>` +
918      (row.strips ? `<g transform="translate(0 ${TRACK_H + 5})">${stripsSvg(row.strips, W, nowMs, stripTool, (m) => tierColor(m), lastStrip)}</g>` : '') +
919      '</svg>',
920  }
921}
922
923function sampleRow() {
924  return {
925    id: 'sample',
926    kind: 'bar',
927    style: 'run',
928    state: 'running',
929    title: 'Refactor auth',
930    pct: 52,
931    marks: [0.2, 0.4, 0.6, 0.8].map((f, i) => ({ f, stage: i === 1 })),
932    steps: 5,
933    finished: 2,
934    name: 'Fixing auth',
935    count: '3/5',
936    right: '52%',
937    animating: true,
938  }
939}
940
941// ---------- side effects that need $ ----------
942
943function play($, name) {
944  if (!prefs.sound) return
945  $.audio.play({ asset: 'sounds/' + name + '.wav' }, { gain: 0.6 }).catch(() => {})
946}
947
948function setOpen($, want) {
949  if (want === isOpening()) return
950  if (fold && fold.timer) fold.timer.cancel()
951  const now = Date.now()
952  if (want) prefs.open = true
953  fold = { dir: want ? 'open' : 'close', start: now, ticks: 0, timer: null }
954  $.store.set('prefs', { ...prefs, open: want }).catch(() => {})
955  fold.timer = $.clock.every(33, () => {
956    if (!fold) return
957    fold.ticks++
958    const n = collectRows(Date.now()).length
959    const span = (n + 1) * FOLD_STEP + (fold.dir === 'open' ? FOLD_FADE : 0)
960    if (foldAge(Date.now()) >= span) {
961      fold.timer.cancel()
962      if (fold.dir === 'close') prefs.open = false
963      fold = null
964    }
965    $.ui.invalidate('ui.render')
966  })
967  $.ui.invalidate('ui.render')
968}
969
970async function savePrefs($) {
971  await $.store.set('prefs', prefs)
972}
973
974// a response may report only some windows; keep the others, drop ones already reset
975function mergeLimits(old, fresh) {
976  const byKind = new Map((old ?? []).map((r) => [r.kind, r]))
977  for (const r of fresh ?? []) byKind.set(r.kind, r)
978  const now = Date.now()
979  return [...byKind.values()].filter((r) => !r.resetsAt || Date.parse(r.resetsAt) > now).sort((a, b) => (a.kind < b.kind ? -1 : 1))
980}
981
982function dropDemoUsage() {
983  if (!demoUsage) return
984  demoUsage = false
985  usage = { context: null, rateLimits: [] }
986}
987
988async function refreshUsage($) {
989  try {
990    const u = await $.session.usage()
991    if (demoUsage && ((u.rateLimits ?? []).length || u.context?.percent != null)) dropDemoUsage()
992    usage = { context: u.context ?? usage.context, rateLimits: mergeLimits(usage.rateLimits, u.rateLimits) }
993    if (u.cost) await addSpend($, u.cost.usd)
994    await noteUsage($)
995  } catch {}
996}
997
998async function noteUsage($) {
999  if (sampleUsage(Date.now())) await $.store.set('hist', hist)
1000}
1001
1002// add what this session spent since the last reading into today and this month
1003async function addSpend($, usd) {
1004  if (!prefs.cost || typeof usd !== 'number') return
1005  if (costSeen == null) {
1006    const seen = (await $.store.get('costSeen')) ?? {}
1007    costSeen = typeof seen[sessionId] === 'number' ? seen[sessionId] : 0
1008  }
1009  const delta = usd >= costSeen ? usd - costSeen : usd
1010  costSeen = usd
1011  const saved = (await $.store.get('spend')) ?? {}
1012  const day = today()
1013  const month = day.slice(0, 7)
1014  spend = {
1015    day,
1016    dayUsd: (saved.day === day ? saved.dayUsd : 0) + delta,
1017    month,
1018    monthUsd: (saved.month === month ? saved.monthUsd : 0) + delta,
1019  }
1020  if (delta > 0) {
1021    await $.store.set('spend', spend)
1022    const seen = (await $.store.get('costSeen')) ?? {}
1023    seen[sessionId] = usd
1024    const ids = Object.keys(seen)
1025    if (ids.length > 30) for (const id of ids.slice(0, ids.length - 30)) delete seen[id]
1026    await $.store.set('costSeen', seen)
1027  }
1028}
1029
1030async function loadSpend($) {
1031  const saved = (await $.store.get('spend')) ?? {}
1032  const day = today()
1033  spend = { day, dayUsd: saved.day === day ? saved.dayUsd : 0, month: day.slice(0, 7), monthUsd: saved.month === day.slice(0, 7) ? saved.monthUsd : 0 }
1034}
1035
1036
1037
1038async function refreshWeather($) {
1039  if (!prefs.city || !prefs.show.weather) return
1040  try {
1041    const { lat, lon, name } = prefs.city
1042    const r = await $.http.fetch('https://api.open-meteo.com/v1/forecast?latitude=' + lat + '&longitude=' + lon + '&current=temperature_2m,weather_code')
1043    if (!r.ok) return
1044    const cur = JSON.parse(r.text).current
1045    weather = { temp: cur.temperature_2m, code: cur.weather_code, name }
1046  } catch {}
1047}
1048
1049async function syncAgents($) {
1050  if (![...agents.values()].some((a) => a.state === 'running' || a.state === 'waiting')) return
1051  try {
1052    for (const info of await $.agent.list()) {
1053      const a = agents.get(info.id)
1054      if (!a || a.state === 'done' || a.state === 'error') continue
1055      if (info.status === 'completed') finishAgent(a.id, 'done', 'Done')
1056      else if (info.status === 'failed' || info.status === 'killed') finishAgent(a.id, 'error', info.status === 'killed' ? 'Stopped' : 'Failed')
1057    }
1058  } catch {}
1059}
1060
1061async function readSessionModel($) {
1062  try {
1063    sessionModel = await $.session.model()
1064  } catch {}
1065}
1066
1067function finishAgent(id, state, tool) {
1068  const a = agents.get(id)
1069  if (!a || a.state === 'done' || a.state === 'error') return
1070  a.state = state
1071  a.tool = tool
1072  a.endedAt = Date.now()
1073  waiting.delete(id)
1074  syncAgentsBar()
1075  const bar = a.home === 'activity' ? activity && { id: 'activity', set agentsDoneAt(v) { activity.agentsDoneAt = v } } : bars.get(a.home)
1076  if (bar && agentsOf(bar).every((x) => x.state === 'done' || x.state === 'error')) bar.agentsDoneAt = Date.now()
1077}
1078
1079// compare rows with the last tick: sounds, done timestamps, the pet's cues, auto-expand
1080function react($, rows, now) {
1081  for (const bar of bars.values()) {
1082    const s = barState(bar)
1083    if (s === 'done' && !bar.doneAt) bar.doneAt = now
1084    if (s !== 'done') bar.doneAt = null
1085  }
1086  const live = rows.filter((r) => r.kind === 'bar' && r.animating).length
1087  let anyAsk = false
1088  for (const row of rows) {
1089    if (row.kind !== 'bar') continue
1090    const prev = lastSeen.get(row.id)
1091    if (row.state === 'needs_input') anyAsk = true
1092    if (prev) {
1093      if (row.state === 'needs_input' && prev.state !== 'needs_input') play($, 'alert')
1094      else if (row.state === 'error' && prev.state !== 'error') play($, 'error')
1095      else if (row.state === 'done' && prev.state !== 'done') {
1096        play($, live === 0 ? 'fanfare' : 'chime')
1097        celebrateUntil = now + 3500
1098      } else if (row.stage > prev.stage && row.state !== 'done') play($, 'tick')
1099    }
1100    lastSeen.set(row.id, { state: row.state, stage: row.stage })
1101  }
1102  if (anyAsk && !isOpening() && prefs.auto) setOpen($, true)
1103}
1104
1105function statusText() {
1106  const lines = []
1107  for (const r of collectRows(Date.now())) {
1108    if (r.kind === 'route' && r.noAnswer) {
1109      lines.push('⇄ Model route — no answer (' + r.reason + '), kept ' + r.to + (r.hint ? ' · ' + r.hint : ''))
1110      continue
1111    }
1112    if (r.kind === 'route') {
1113      lines.push('⇄ Model route — ' + r.from + ' ' + (r.dir === '=' ? '=' : '→') + ' ' + r.to + (r.effort ? ' · effort ' + r.effort : '') + ' · jev ' + r.tier + (r.conf != null ? ' ' + r.conf.toFixed(2) : '') + (r.unapplied ? ', suggested ' + r.want + ', not applied' : '') + ' (' + r.right + ')')
1114      continue
1115    }
1116    lines.push(STYLE[r.style].glyph + ' ' + r.title + ' — ' + r.name + (r.count ? ' ' + r.count : '') + (r.right ? ' (' + r.right + ')' : ''))
1117    for (const a of r.strips?.shown ?? []) lines.push('   ↳ ' + a.title + ': ' + stripTool(a) + ' [' + a.state + ']' + (a.model ? ' {' + a.model + '}' : ''))
1118  }
1119  const on = SECTIONS.filter((s) => prefs.show[s]).join(', ') || 'nothing'
1120  return (
1121    (lines.length ? lines.join('\n') : 'No rows yet.') +
1122    '\nShowing: ' + on + ' · ' + (prefs.open ? 'expanded' : 'collapsed') +
1123    ' · style ' + prefs.style.terminal + ' (CLI) / ' + prefs.style.desktop + ' (Desktop)' +
1124    ' · pet ' + pet.act + ' · auto-expand ' + (prefs.auto ? 'on' : 'off') + ' · sound ' + (prefs.sound ? 'on' : 'off') +
1125    (prefs.city ? ' · city ' + prefs.city.name : '') +
1126    ' · Deck ' + VERSION
1127  )
1128}
1129
1130const HELP = [
1131  '/deck                      toggle collapsed / expanded (also: 0 in an empty prompt, or Deck in the footer)',
1132  '/deck expand | collapse',
1133  '/deck style                pick a bar style, with live previews',
1134  '/deck style <name>         CLI: ' + TERMINAL_STYLES.join(', ') + ' · Desktop: ' + DESKTOP_STYLES.join(', '),
1135  '/deck <section> on|off     sections: ' + SECTIONS.join(', ') + ', all',
1136  '/deck city <name>          set the weather city (turns weather on)',
1137  "/deck quiet on|off         hide the router's own status and log lines while the HUD shows the route",
1138  '/deck panel [off]          open Deck in a panel (terminal and Desktop; the VS Code extension draws none yet)',
1139  '/deck version              show which Deck version is running',
1140  '/deck size compact|roomy   compact (default) packs the rows tighter on Desktop',
1141  '/deck collapsed chips|text|rings  collapsed look: chips with mini bars, one plain line, or limit rings',
1142  '/deck cost on|off         track spend today and this month (pay-per-use API keys)',
1143  '/deck auto on|off          expand when something needs you',
1144  '/deck sound on|off',
1145  '/deck demo                 play a sample plan with agents',
1146  '/deck clear                remove all bars',
1147  '/deck status               print what the HUD shows',
1148].join('\n')
1149
1150function demoStages() {
1151  const st = (title, status = 'pending') => ({ title, status })
1152  return [
1153    { name: 'Analysis', steps: [st('Read modules', 'done'), st('Find dependencies', 'done'), st('List changes', 'active')] },
1154    { name: 'DB migration', steps: [st('Table schema'), st('Create migration'), st('Move data'), st('Indexes')] },
1155    { name: 'API', steps: [st('Endpoints'), st('Validation'), st('Access rules')] },
1156    { name: 'Verify', steps: [st('Tests'), st('Build')] },
1157  ]
1158}
1159
1160// ---------- hooks ----------
1161
1162// the band above the prompt, and the same view in a pane (/deck panel)
1163async function deckView($, e, next) {
1164  // the same view draws in the band above the prompt and, opened by /deck panel, in a pane:
1165  // VS Code's panel draws panes but not the band
1166  const inPane = e.component === 'Pane'
1167  if (inPane && e.requestId !== 'deck-panel') return next(e)
1168  if (!inPane && e.props.hasSurvey) return next(e)
1169  const open = inPane || prefs.open
1170  const { Box, Text, Button, Svg, Image } = $.ui.resolve(e)
1171  const desktop = e.surface !== 'terminal' // desktop, VS Code and mobile all draw Svg
1172  if (e.surface === 'terminal') sawTerminal = true
1173  const nowMs = Date.now()
1174  const cols = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 100)
1175  const rows = collectRows(nowMs)
1176  const extras = []
1177  if (prefs.show.clock) extras.push((desktop ? '◷ ' : '') + clockText())
1178  const wt = prefs.show.weather ? weatherText(desktop) : ''
1179  if (wt) extras.push(wt)
1180  if (!rows.length && !extras.length && !prefs.show.pet) return next(e)
1181
1182  // the pet's lane
1183  const lane = []
1184  const inlinePet = desktop && Svg && prefs.show.pet && open && rows.length && prefs.size !== 'roomy'
1185  if (prefs.show.pet && !inlinePet) {
1186    if (desktop && Svg) {
1187      const W = Math.max(320, cols * 8)
1188      const LH = prefs.size === 'roomy' ? 54 : 44
1189      lane.push(Svg({ source: petLaneSvg(pet, nowMs, W, LH), alt: 'Claude pet, ' + pet.act, width: W, height: LH }))
1190    } else if (!desktop && Image) {
1191      // leave a margin: a row as wide as the band gets cut and marked with [-]
1192      const room = Math.max(0, cols - PET_COLS - 4)
1193      const x = Math.round(petX(pet, nowMs) * room)
1194      const px = petFrame(pet.act, nowMs - pet.start, pet.dir)
1195      lane.push(
1196        Box({
1197          flexDirection: 'row',
1198          height: PET_ROWS,
1199          overflow: 'hidden',
1200          children: [
hooks/theme.js 80 lines
1// Colors and small helpers shared by the drawings. Pure, no $.
2
3// fill and pill-text colors for the terminal, glyph, and the desktop color (white text on it)
4export const STYLE = {
5  run: { fill: '#9C95EC', on: '#26215C', glyph: '●', desk: '#8B7CF6' },
6  ask: { fill: '#EBA83A', on: '#412402', glyph: '?', desk: '#E09A1E' },
7  hot: { fill: '#E2706F', on: '#501313', glyph: '!', desk: '#E5484D' },
8  done: { fill: '#5DCAA5', on: '#04342C', glyph: '✓', desk: '#30A46C' },
9  ok: { fill: '#5DCAA5', on: '#04342C', glyph: '◔', desk: '#30A46C' },
10  warn: { fill: '#EBA83A', on: '#412402', glyph: '◑', desk: '#E09A1E' },
11}
12
13export const TIER = {
14  haiku: { term: '#5DCAA5', desk: '#30A46C' },
15  sonnet: { term: '#9C95EC', desk: '#8B7CF6' },
16  opus: { term: '#EBA83A', desk: '#E09A1E' },
17}
18
19export const TRACK = '#3a3936'
20export const TRACK_BG = '#2b2a28'
21export const TICK_ON = '#f2f0ea'
22export const TICK_OFF = '#55534e'
23export const DIM = '#77756f'
24export const MUTED = '#9a9893'
25export const TEXT = '#e8e6e1'
26
27export function hash(a, b, k) {
28  const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
29  return x - Math.floor(x)
30}
31
32export function seedOf(id) {
33  let h = 0
34  for (const ch of String(id)) h = (Math.imul(h, 31) + ch.codePointAt(0)) | 0
35  return h
36}
37
38export function trunc(s, n) {
39  s = String(s ?? '').replace(/\s+/g, ' ').trim()
40  return s.length > n ? s.slice(0, Math.max(1, n - 1)) + '…' : s
41}
42
43export function esc(s) {
44  return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
45}
46
47export function textWidth(s, px = 6.7) {
48  let w = 0
49  for (const ch of String(s)) w += /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMW]/.test(ch) ? 9.5 : /[ -鿿]/.test(ch) ? 12 : px
50  return w
51}
52
53export const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16))
54export const mix = (a, b, m) => a.map((v, i) => Math.round(v + (b[i] - v) * m))
55export const rgb = (c) => 'rgb(' + c.join(',') + ')'
56
57// one Text per run of same-styled cells
58export function runs(Text, cells) {
59  const out = []
60  let cur = null
61  for (const c of cells) {
62    if (cur && cur.color === c.color && cur.bg === c.bg && cur.bold === c.bold) cur.text += c.ch
63    else {
64      if (cur) out.push(cur)
65      cur = { text: c.ch, color: c.color, bg: c.bg, bold: c.bold }
66    }
67  }
68  if (cur) out.push(cur)
69  return out.map((r) => {
70    const props = { color: r.color, children: [r.text] }
71    if (r.bg) props.backgroundColor = r.bg
72    if (r.bold) props.bold = true
73    return Text(props)
74  })
75}
76
77export function textCells(s, color, bg, bold) {
78  return [...String(s)].map((ch) => ({ ch, color, bg, bold }))
79}
80
hooks/styles-terminal.js 171 lines
1// Terminal bar styles. Each returns exactly W cells for a row. Pure, no $.
2import { STYLE, TRACK, TRACK_BG, TICK_ON, TICK_OFF, MUTED, hash, seedOf, trunc, textCells } from './theme.js'
3
4export const TERMINAL_STYLES = ['segments', 'line', 'solid', 'dots', 'pixel', 'spark']
5export const TERMINAL_STYLE_NAMES = { segments: 'Segments', line: 'Line', solid: 'Solid', dots: 'Dots', pixel: 'Pixel dots', spark: 'Spark' }
6
7const DOTS = '⠁⠂⠄⠈⠃⠅⠉⠑⠆⠊⠒⠇⠋⠓⠧⠏⠗⠯⠷⠿⡿⣟⣯⣷⣽⣾⣿'
8
9function labelCells(row, room) {
10  const st = STYLE[row.style]
11  const name = trunc(row.name, Math.max(3, room - (row.count ? row.count.length + 1 : 0)))
12  const cells = textCells(name, st.fill)
13  if (row.count && name.length + row.count.length + 1 <= room) cells.push(...textCells(' ' + row.count, MUTED))
14  return cells
15}
16
17// a bar of width bw, then two spaces and the label, padded to W
18function withLabel(bar, row, W) {
19  const room = W - bar.length - 2
20  const out = [...bar, ...textCells('  ', MUTED), ...labelCells(row, room)]
21  while (out.length < W) out.push({ ch: ' ', color: MUTED })
22  return out.slice(0, W)
23}
24
25function barWidth(row, W) {
26  const want = Math.min(W - 8, Math.max(10, Math.round(W * 0.58)))
27  return Math.max(6, want)
28}
29
30function segments(row, W, frame) {
31  const st = STYLE[row.style]
32  const bw = barWidth(row, W)
33  const fx = (bw * row.pct) / 100
34  const gaps = new Set(row.marks.filter((m) => m.stage).map((m) => Math.round(bw * m.f)))
35  const cells = []
36  for (let x = 0; x < bw; x++) {
37    if (gaps.has(x)) {
38      cells.push({ ch: ' ', color: TRACK })
39      continue
40    }
41    const head = row.animating && x === Math.floor(fx)
42    cells.push(x < Math.floor(fx) ? { ch: '▰', color: st.fill } : head ? { ch: '▰', color: frame % 4 < 2 ? st.fill : '#6e6aa8' } : { ch: '▱', color: TRACK })
43  }
44  return withLabel(cells, row, W)
45}
46
47function line(row, W, frame) {
48  const st = STYLE[row.style]
49  const bw = barWidth(row, W)
50  const full = Math.floor((bw * row.pct) / 100)
51  const marks = new Set(row.marks.map((m) => Math.round(bw * m.f)))
52  const cells = []
53  for (let x = 0; x < bw; x++) {
54    if (x < full) cells.push({ ch: '━', color: st.fill })
55    else if (x === full && row.pct < 100) cells.push({ ch: '╸', color: row.animating && frame % 4 < 2 ? '#f2f0ea' : st.fill })
56    else cells.push({ ch: marks.has(x) ? '┼' : '─', color: marks.has(x) ? TICK_OFF : TRACK })
57  }
58  return withLabel(cells, row, W)
59}
60
61function solid(row, W, frame) {
62  const st = STYLE[row.style]
63  const bw = W
64  const f = Math.round((bw * row.pct) / 100)
65  const text = (' ' + row.name + (row.count ? ' · ' + row.count : '') + ' ').slice(0, bw)
66  const sweep = row.animating ? ((frame * 2) % (bw + 12)) - 6 : -99
67  const cells = []
68  for (let x = 0; x < bw; x++) {
69    const ch = text[x] ?? ' '
70    const inFill = x < f
71    const shine = inFill && Math.abs(x - sweep) < 2
72    cells.push(
73      inFill
74        ? { ch, color: st.on, bg: shine ? '#cfc9f7' : st.fill, bold: true }
75        : { ch, color: MUTED, bg: TRACK_BG },
76    )
77  }
78  return cells
79}
80
81function dots(row, W, frame) {
82  const st = STYLE[row.style]
83  const cells = []
84  if (row.steps && row.steps <= 16) {
85    for (let i = 0; i < row.steps; i++) {
86      const done = i < row.finished
87      const cur = i === row.finished && row.animating
88      cells.push(done ? { ch: '●', color: st.fill } : cur ? { ch: frame % 4 < 2 ? '◉' : '○', color: st.fill } : { ch: '○', color: TICK_OFF })
89      if (i < row.steps - 1) cells.push({ ch: done ? '─' : '┄', color: done ? st.fill : TRACK })
90    }
91  } else {
92    const n = 10
93    for (let i = 0; i < n; i++) {
94      const v = row.pct / 10 - i
95      cells.push({ ch: v >= 1 ? '●' : v > 0.4 ? '◐' : '○', color: v > 0.4 ? st.fill : TICK_OFF })
96      if (i < n - 1) cells.push({ ch: ' ', color: TRACK })
97    }
98  }
99  return withLabel(cells.slice(0, Math.max(6, W - 8)), row, W)
100}
101
102function pixel(row, W, frame) {
103  const st = STYLE[row.style]
104  const fx = Math.round((W * row.pct) / 100)
105  const pill = ' ' + trunc(row.name + (row.count ? ' ' + row.count : ''), Math.max(4, W - 4)) + ' '
106  const L = pill.length
107  const ps = Math.max(0, Math.min(W - L, fx >= L ? fx - L : fx))
108  const marks = new Map(row.marks.map((m) => [Math.round(W * m.f), m.stage]))
109  const seed = seedOf(row.id)
110  const cells = []
111  for (let x = 0; x < W; x++) {
112    if (x >= ps && x < ps + L) cells.push({ ch: pill[x - ps], color: st.on, bg: st.fill, bold: true })
113    else if (marks.has(x)) cells.push({ ch: marks.get(x) ? (x < fx ? '┃' : '│') : '╎', color: x < fx ? TICK_ON : TICK_OFF, bg: TRACK_BG })
114    else if (x < fx) {
115      const t = (x + 1) / Math.max(fx, 1)
116      const d = (0.15 + 0.8 * t * t) * (0.6 + 0.4 * hash(seed, x, frame >> 1))
117      cells.push({ ch: DOTS[Math.min(DOTS.length - 1, Math.floor(d * DOTS.length))], color: st.fill, bg: TRACK_BG })
118    } else cells.push({ ch: ' ', color: TRACK, bg: TRACK_BG })
119  }
120  return cells
121}
122
123const BLOCKS = '▁▂▃▄▅▆▇█'
124
125// a thin bar, then the recent history as a sparkline, then the label and how fast it moves
126function spark(row, W, frame) {
127  const st = STYLE[row.style]
128  // the label and rate always fit; the bar and sparkline get whatever room is left
129  const tail = trunc(row.name, 24).length + (row.rate ? row.rate.length + 2 : row.count ? row.count.length + 1 : 0) + 3
130  const bw = Math.max(3, Math.min(18, Math.round(W * 0.22), W - tail - 1))
131  const room = Math.max(0, Math.min(24, W - bw - 1 - tail))
132  const hist = room >= 4 ? (row.hist ?? []).slice(-room) : []
133  const f = Math.round((bw * row.pct) / 100)
134  const cells = []
135  for (let x = 0; x < bw; x++) cells.push({ ch: x < f ? '━' : '─', color: x < f ? st.fill : TRACK })
136  cells.push({ ch: ' ', color: TRACK })
137  if (hist.length > 1) {
138    const lo = Math.min(...hist)
139    const hi = Math.max(...hist)
140    for (const v of hist) cells.push({ ch: BLOCKS[Math.round(((v - lo) / Math.max(1e-9, hi - lo)) * 7)], color: st.fill })
141  } else if (row.animating && room >= 8) {
142    for (let i = 0; i < 8; i++) cells.push({ ch: BLOCKS[(i + frame) % 8], color: st.fill })
143  }
144  const out = [...cells, ...textCells('  ', MUTED), ...textCells(trunc(row.name, 24), st.fill)]
145  if (row.rate) out.push(...textCells('  ' + row.rate, MUTED))
146  else if (row.count) out.push(...textCells(' ' + row.count, MUTED))
147  while (out.length < W) out.push({ ch: ' ', color: MUTED })
148  return out.slice(0, W)
149}
150
151const FN = { segments, line, solid, dots, pixel, spark }
152
153export function terminalBar(style, row, W, frame) {
154  return (FN[style] ?? solid)(row, W, frame)
155}
156
157// a short bar for the collapsed line
158export function terminalMini(style, row, W) {
159  const st = STYLE[row.style]
160  const f = Math.round((W * row.pct) / 100)
161  const cells = []
162  for (let x = 0; x < W; x++) {
163    if (style === 'solid' || style === 'pixel') cells.push({ ch: ' ', color: st.fill, bg: x < f ? st.fill : TRACK_BG })
164    else if (style === 'line') cells.push({ ch: x < f ? '━' : '─', color: x < f ? st.fill : TRACK })
165    else if (style === 'dots') cells.push({ ch: x < f ? '●' : '○', color: x < f ? st.fill : TICK_OFF })
166    else if (style === 'spark') cells.push({ ch: x < f ? '━' : '─', color: x < f ? st.fill : TRACK })
167    else cells.push({ ch: x < f ? '▰' : '▱', color: x < f ? st.fill : TRACK })
168  }
169  return cells
170}
171
hooks/styles-desktop.js 314 lines
1// Desktop bar styles as SVG markup (22px tall), plus agent strips. Pure, no $.
2// The pixel style and the strips adapt plan-progress (zycck/claude-mods, MIT, Kirill Serditov).
3import { STYLE, hash, seedOf, esc, textWidth, hex, mix, rgb } from './theme.js'
4
5export const DESKTOP_STYLES = ['pixel', 'segments', 'line', 'solid', 'dots', 'spark']
6export const DESKTOP_STYLE_NAMES = { pixel: 'Pixel', segments: 'Segments', line: 'Line', solid: 'Solid', dots: 'Dots', spark: 'Spark' }
7
8const H = 22
9const FONT = "'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,sans-serif"
10const NARROW = 360
11
12const ICON_PATH = {
13  ask: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
14  hot: 'M18 6 6 18M6 6l12 12',
15  done: 'M20 6 9 17l-5-5',
16}
17
18// twinkle classes keep their phase across redraws
19export function twinkleCss(nowMs, done) {
20  const t = nowMs / 1000
21  const durs = done ? [3.2, 3.8, 4.4, 3.5] : [2.2, 2.8, 1.9, 3.3]
22  const base = [0, 0.7, 1.3, 0.4]
23  let css = ''
24  durs.forEach((d, i) => {
25    css += `.t${i}{animation:tw ${d}s ease-in-out infinite;animation-delay:-${((t + base[i]) % d).toFixed(2)}s}`
26  })
27  return css + `@keyframes tw{0%,100%{opacity:1}50%{opacity:${done ? 0.8 : 0.45}}}@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}`
28}
29
30function glideFrom(lastHead, id, fx) {
31  const from = lastHead.get(id) ?? fx
32  lastHead.set(id, fx)
33  return from
34}
35
36const EASE = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
37
38function pillMarkup(row, W, fx, from, color, maxShare = 0.55) {
39  const icon = ICON_PATH[row.style]
40  let knob
41  let kw
42  if (W < NARROW) {
43    kw = H
44    knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>`
45  } else {
46    const iconW = icon ? 16 : 0
47    const countW = row.count ? textWidth(row.count, 6.5) + 6 : 0
48    const maxW = Math.max(80, W * maxShare)
49    let shown = row.name
50    while (shown.length > 3 && 20 + iconW + textWidth(shown) + countW > maxW) shown = shown.slice(0, -1)
51    if (shown !== row.name) shown = shown.trimEnd() + '…'
52    kw = Math.round(20 + iconW + textWidth(shown) + countW)
53    const left = -kw / 2 + 10
54    knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
55    if (icon) knob += `<path d="${icon}" transform="translate(${left} 5) scale(.5)" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
56    knob += `<text x="${left + iconW}" y="${H / 2 + 4.2}" style="font:500 12px ${FONT};fill:#fff">${esc(shown)}${row.count ? `<tspan dx="6" style="font-weight:400;fill-opacity:.75">${esc(row.count)}</tspan>` : ''}</text>`
57  }
58  const at = (x) => Math.max(kw / 2, Math.min(W - kw / 2, x >= kw ? x - kw / 2 : x + kw / 2))
59  const kx = at(fx)
60  const kFrom = at(from)
61  const glide = Math.abs(kFrom - kx) > 0.5 ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${EASE} fill="freeze"/>` : ''
62  return `<g transform="translate(${kx.toFixed(1)} 0)">${glide}${knob}</g>`
63}
64
65function pixel(row, W, nowMs, lastHead) {
66  const done = row.style === 'done'
67  const fx = (Math.max(0, Math.min(100, row.pct)) / 100) * W
68  const from = glideFrom(lastHead, row.id, fx)
69  const color = STYLE[row.style].desk
70  const acc = hex(color)
71  const light = mix(acc, [255, 255, 255], 0.32)
72  const grey = [132, 130, 138]
73  const seed = Math.abs(seedOf(row.id) % 997)
74  const buckets = [0, 1, 2, 3, 4].map((b) => {
75    const m = b / 4
76    const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(m, 1.5)
77    return { color: rgb(done ? light : mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
78  })
79  let px = ''
80  for (let col = 0; col * 3 < fx; col++) {
81    const x = col * 3
82    const u = Math.min(1, (x + 1.5) / fx)
83    const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
84    const bucket = done ? 4 : Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
85    for (let r = 0; r < 7; r++) {
86      if (hash(col + seed, r, 1) > dense + 0.1) continue
87      px += `<rect x="${x}" y="${1 + r * 3}" class="pxd b${bucket} t${Math.floor(hash(col + seed, r, 2) * 4)}"/>`
88    }
89  }
90  let marks = ''
91  for (const m of row.marks) {
92    const x = m.f * W
93    const passed = x < fx - 1
94    const h = m.stage ? H : 8
95    marks += `<rect x="${(x - (m.stage ? 1 : 0.75)).toFixed(1)}" y="${(H - h) / 2}" width="${m.stage ? 2 : 1.5}" height="${h}" rx=".75" fill="${passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'}" opacity="${passed ? (m.stage ? 0.95 : 0.6) : m.stage ? 0.7 : 0.45}"/>`
96  }
97  const id = 'p' + seed
98  const glideFill = Math.abs(from - fx) > 0.5 ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${EASE} fill="freeze"/>` : ''
99  const css = buckets.map((b, i) => `.b${i}{fill:${b.color};fill-opacity:${b.opacity}}`).join('') + '.pxd{width:2px;height:2px}' + twinkleCss(nowMs, done)
100  return (
101    `<style>${css}</style>` +
102    `<defs><clipPath id="${id}a"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="${id}b"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>` +
103    `<linearGradient id="${id}g" x1="0" x2="${Math.max(1, fx).toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity="${done ? 0.3 : 0.05}"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>` +
104    `<g clip-path="url(#${id}a)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>` +
105    `<g clip-path="url(#${id}b)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#${id}g)"/>${px}</g>${marks}</g>` +
106    pillMarkup(row, W, fx, from, color)
107  )
108}
109
110function label(row, x, color) {
111  return `<text x="${x}" y="15" style="font:500 12px ${FONT};fill:${color}">${esc(row.name)}${row.count ? `<tspan dx="6" style="font-weight:400;fill:#9a9893">${esc(row.count)}</tspan>` : ''}</text>`
112}
113
114function barShare(W) {
115  return Math.max(80, Math.round(W * 0.62))
116}
117
118function segments(row, W, nowMs, lastHead) {
119  const color = STYLE[row.style].desk
120  const bw = barShare(W)
121  const n = Math.max(8, Math.min(40, Math.round(bw / 9)))
122  const gap = 2
123  const sw = (bw - gap * (n - 1)) / n
124  const filled = (row.pct / 100) * n
125  const stages = new Set(row.marks.filter((m) => m.stage).map((m) => Math.round(m.f * n)))
126  let out = ''
127  for (let i = 0; i < n; i++) {
128    const x = i * (sw + gap) + (stages.has(i) ? 2 : 0)
129    const on = i < Math.floor(filled)
130    const head = !on && i === Math.floor(filled) && row.animating
131    out += `<rect x="${x.toFixed(1)}" y="5" width="${(sw - (stages.has(i) ? 2 : 0)).toFixed(1)}" height="12" rx="2" fill="${on || head ? color : '#808080'}" fill-opacity="${on ? 1 : head ? 0.5 : 0.18}"${head ? ' class="t1"' : ''}/>`
132  }
133  glideFrom(lastHead, row.id, 0)
134  return `<style>${twinkleCss(nowMs, false)}</style>${out}${label(row, bw + 12, color)}`
135}
136
137function line(row, W, nowMs, lastHead) {
138  const color = STYLE[row.style].desk
139  const bw = barShare(W)
140  const fx = (row.pct / 100) * bw
141  const from = glideFrom(lastHead, row.id, fx)
142  const glide = Math.abs(from - fx) > 0.5 ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${EASE} fill="freeze"/>` : ''
143  const glideDot = Math.abs(from - fx) > 0.5 ? `<animate attributeName="cx" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${EASE} fill="freeze"/>` : ''
144  let marks = ''
145  for (const m of row.marks) marks += `<rect x="${(m.f * bw).toFixed(1)}" y="${m.stage ? 6 : 8}" width="1.5" height="${m.stage ? 10 : 6}" rx=".75" fill="${m.f * bw < fx ? color : '#8A8984'}" opacity=".7"/>`
146  const pulse = row.animating ? '<animate attributeName="r" values="4;5.5;4" dur="1.2s" repeatCount="indefinite"/>' : ''
147  return (
148    `<rect x="0" y="10" width="${bw}" height="2" rx="1" fill="#808080" fill-opacity=".25"/>` +
149    `<rect x="0" y="9.5" width="${fx.toFixed(1)}" height="3" rx="1.5" fill="${color}">${glide}</rect>${marks}` +
150    `<circle cx="${fx.toFixed(1)}" cy="11" r="4" fill="${color}">${glideDot}${pulse}</circle>` +
151    label(row, bw + 14, color)
152  )
153}
154
155function solid(row, W, nowMs, lastHead) {
156  const st = STYLE[row.style]
157  const fx = (row.pct / 100) * W
158  const from = glideFrom(lastHead, row.id, fx)
159  const id = 's' + Math.abs(seedOf(row.id) % 997)
160  const glide = Math.abs(from - fx) > 0.5 ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${EASE} fill="freeze"/>` : ''
161  const t = (nowMs / 1000) % 2.4
162  const sweep = row.animating
163    ? `<rect x="-60" y="0" width="60" height="${H}" fill="#fff" fill-opacity=".18"><animate attributeName="x" from="-60" to="${W}" dur="2.4s" begin="-${t.toFixed(2)}s" repeatCount="indefinite"/></rect>`
164    : ''
165  const text = esc(row.name) + (row.count ? ' · ' + esc(row.count) : '')
166  return (
167    `<defs><clipPath id="${id}a"><rect width="${W}" height="${H}" rx="6"/></clipPath><clipPath id="${id}b"><rect width="${fx.toFixed(1)}" height="${H}">${glide}</rect></clipPath></defs>` +
168    `<g clip-path="url(#${id}a)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>` +
169    `<text x="10" y="15" style="font:500 12px ${FONT};fill:#9a9893">${text}</text>` +
170    `<g clip-path="url(#${id}b)"><rect width="${W}" height="${H}" fill="${st.desk}"/>${sweep}<text x="10" y="15" style="font:500 12px ${FONT};fill:#fff">${text}</text></g></g>`
171  )
172}
173
174function dots(row, W, nowMs, lastHead) {
175  const color = STYLE[row.style].desk
176  glideFrom(lastHead, row.id, 0)
177  let out = ''
178  let x = 8
179  if (row.steps && row.steps <= 16) {
180    const gapW = Math.min(26, Math.max(12, (barShare(W) - 16) / Math.max(1, row.steps - 1)))
181    for (let i = 0; i < row.steps; i++) {
182      const done = i < row.finished
183      const cur = i === row.finished && row.animating
184      if (i > 0) out += `<rect x="${x - gapW + 5}" y="10.25" width="${gapW - 10}" height="1.5" rx=".75" fill="${done || cur ? color : '#808080'}" fill-opacity="${done || cur ? 0.8 : 0.3}"/>`
185      out += done
186        ? `<circle cx="${x}" cy="11" r="5" fill="${color}"/>`
187        : cur
188          ? `<circle cx="${x}" cy="11" r="5" fill="none" stroke="${color}" stroke-width="2"/><circle cx="${x}" cy="11" r="2.4" fill="${color}" class="t0"/>`
189          : `<circle cx="${x}" cy="11" r="4.5" fill="none" stroke="#808080" stroke-opacity=".45" stroke-width="1.5"/>`
190      x += gapW
191    }
192  } else {
193    for (let i = 0; i < 10; i++) {
194      const v = row.pct / 10 - i
195      out += `<circle cx="${x}" cy="11" r="5" fill="#808080" fill-opacity=".2"/>`
196      if (v > 0) out += `<clipPath id="d${seedOf(row.id) % 997}${i}"><rect x="${x - 5}" y="6" width="${10 * Math.min(1, v)}" height="10"/></clipPath><circle cx="${x}" cy="11" r="5" fill="${color}" clip-path="url(#d${seedOf(row.id) % 997}${i})"/>`
197      x += 16
198    }
199  }
200  return `<style>${twinkleCss(nowMs, false)}</style>${out}${label(row, x + 6, color)}`
201}
202
203// a slim bar, a sparkline of recent history ending in a dot, then the label and its rate
204function spark(row, W, nowMs, lastHead) {
205  const color = STYLE[row.style].desk
206  const bw = Math.max(60, Math.round(W * 0.26))
207  const fx = (row.pct / 100) * bw
208  const from = glideFrom(lastHead, row.id, fx)
209  const glide = Math.abs(from - fx) > 0.5 ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${EASE} fill="freeze"/>` : ''
210  let out = `<rect x="0" y="7" width="${bw}" height="8" rx="4" fill="#808080" fill-opacity=".2"/><rect x="0" y="7" width="${fx.toFixed(1)}" height="8" rx="4" fill="${color}">${glide}</rect>`
211  const sw = Math.max(60, Math.round(W * 0.3))
212  const sx = bw + 14
213  const hist = (row.hist ?? []).slice(-30)
214  if (hist.length > 1) {
215    const lo = Math.min(...hist)
216    const hi = Math.max(...hist)
217    const pts = hist.map((v, i) => [sx + (i * sw) / (hist.length - 1), 18 - ((v - lo) / Math.max(1e-9, hi - lo)) * 14])
218    out += `<path d="${pts.map(([x, y], i) => (i ? 'L' : 'M') + x.toFixed(1) + ',' + y.toFixed(1)).join(' ')}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>`
219    const [lx, ly] = pts[pts.length - 1]
220    out += `<circle cx="${lx.toFixed(1)}" cy="${ly.toFixed(1)}" r="2.6" fill="${color}"><animate attributeName="r" values="2.6;3.6;2.6" dur="1.6s" repeatCount="indefinite"/></circle>`
221  } else {
222    out += `<rect x="${sx}" y="10.5" width="${sw}" height="1" fill="#808080" fill-opacity=".3"/>`
223  }
224  const lx = sx + sw + 14
225  out += `<text x="${lx}" y="15" style="font:500 12px ${FONT};fill:${color}">${esc(row.name)}${row.rate ? `<tspan dx="8" style="font-weight:400;fill:#9a9893">${esc(row.rate)}</tspan>` : row.count ? `<tspan dx="6" style="font-weight:400;fill:#9a9893">${esc(row.count)}</tspan>` : ''}</text>`
226  void nowMs
227  return out
228}
229
230const FN = { pixel, segments, line, solid, dots, spark }
231
232export function desktopTrack(style, row, W, nowMs, lastHead) {
233  return (FN[style] ?? pixel)(row, W, nowMs, lastHead)
234}
235
236// ---------- agent strips under a bar ----------
237
238export const STRIP_H = 18
239export const STRIP_GAP = 3
240
241const AGENT_STYLE = { running: 'run', waiting: 'ask', error: 'hot', done: 'done' }
242
243function elapsed(ms) {
244  const s = Math.max(0, Math.round(ms / 1000))
245  return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's'
246}
247
248export function stripsHeight(v) {
249  const n = v.shown.length + (v.hidden.length ? 1 : 0)
250  return n * STRIP_H + (n - 1) * STRIP_GAP
251}
252
253export function stripsSvg(v, W, nowMs, stripTool, tierColor, lastStrip) {
254  const narrow = W < NARROW
255  let out = ''
256  v.shown.forEach((a, i) => {
257    const c = STYLE[AGENT_STYLE[a.state]].desk
258    const y = i * (STRIP_H + STRIP_GAP)
259    const indent = a.depth > 0 ? 12 : 0
260    let px = ''
261    if (a.state === 'running') {
262      for (let col = 0; col * 3 < W; col++) {
263        for (let r = 0; r < 4; r++) {
264          if (hash(col + i * 41, r, 5) > 0.2) continue
265          px += `<rect x="${col * 3}" y="${(y + 3 + r * 3.6).toFixed(1)}" width="2" height="2" class="t${Math.floor(hash(col, r, 6) * 4)}" fill="${c}" fill-opacity=".32"/>`
266        }
267      }
268    }
269    const room = narrow ? W - 30 - indent : W * 0.45
270    const full = (a.depth > 0 ? '↳ ' : '') + a.title
271    let name = full
272    while (name.length > 4 && textWidth(name, 6.9) > room) name = name.slice(0, -1)
273    if (name !== full) name = name.trimEnd() + '…'
274    const nameX = 19 + indent
275    const toolX = nameX + textWidth(name, 6.9) + 12
276    const tool = stripTool(a)
277    const was = lastStrip.get(a.id)
278    lastStrip.set(a.id, { tool, color: c })
279    const changed = was !== undefined && was.tool !== tool
280    const flow = was && was.color !== c ? `<animate attributeName="fill" from="${was.color}" to="${c}" dur=".2s" fill="freeze"/>` : ''
281    const time = elapsed((a.endedAt ?? nowMs) - a.startedAt)
282    let tag = ''
283    if (a.model && !narrow) {
284      const tc = tierColor(a.model)
285      const tw = textWidth(a.model, 6) + 12
286      tag = `<rect x="${W - 52 - tw}" y="${y + 3}" width="${tw}" height="12" rx="3" fill="${tc}" fill-opacity=".25"/><text x="${W - 46 - tw}" y="${y + 12.5}" style="font:500 10.5px ${FONT};fill:${tc}">${esc(a.model)}</text>`
287    }
288    out +=
289      `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow}</rect>${px}` +
290      `<circle cx="${10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow}</circle>` +
291      `<text x="${nameX}" y="${y + 12.5}" class="sn">${esc(name)}</text>` +
292      (narrow
293        ? ''
294        : (changed ? `<text x="${toolX}" y="${y + 12.5}" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
295          `<text x="${toolX}" y="${y + 12.5}" class="sn${changed ? ' mi' : ''}" style="fill:${c}">${esc(tool)}</text>` +
296          tag +
297          `<text x="${W - 9}" y="${y + 12.5}" text-anchor="end" class="sn st">${time}</text>`)
298  })
299  if (v.hidden.length) {
300    const y = v.shown.length * (STRIP_H + STRIP_GAP)
301    const done = v.hidden.filter((a) => a.state === 'done').length
302    out += `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/><text x="10" y="${y + 12.5}" class="sn st">+${v.hidden.length} more · ${done} done</text>`
303  }
304  return (
305    `<style>.sn{font:400 11.5px ${FONT};fill:#F0EEFC}.st{fill-opacity:.65}` +
306    twinkleCss(nowMs, false) +
307    '.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}' +
308    '.mi{animation:mi .2s ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}' +
309    '.mo{animation:mo .2s ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}' +
310    '@media (prefers-reduced-motion:reduce){.sd,.mi,.mo{animation:none}.mo{opacity:0}}</style>' +
311    out
312  )
313}
314
hooks/pet.js 299 lines
1// The pet: one design, drawn as animated SVG on the desktop and as RGBA frames
2// (an Image) in terminals that show pictures. Pure functions, no $.
3
4export const PET_ACTS = ['idle', 'walk', 'run', 'jump', 'console', 'read', 'sleep', 'celebrate', 'alert']
5
6const SPEED = { walk: 0.035, run: 0.11 } // lane fractions per second
7
8// ---------- behaviour: one scene at a time, the same on every surface ----------
9
10export function createPet(now) {
11  return { act: 'idle', dir: 1, x0: 0.3, x1: 0.3, start: now, dur: 3000, mood: 'calm', id: 1 }
12}
13
14export function petX(pet, now) {
15  const p = pet.dur > 0 ? Math.min(1, Math.max(0, (now - pet.start) / pet.dur)) : 1
16  return pet.x0 + (pet.x1 - pet.x0) * p
17}
18
19function pick(list, r) {
20  return list[Math.floor(r * list.length) % list.length]
21}
22
23// mood: 'alert' | 'celebrate' | 'working' | 'sleepy' | 'calm'
24export function stepPet(pet, mood, now, rand = Math.random) {
25  const moodChanged = mood !== pet.mood && (mood === 'alert' || mood === 'celebrate' || pet.mood === 'alert')
26  if (!moodChanged && now < pet.start + pet.dur) return false
27  const x = petX(pet, now)
28  pet.mood = mood
29  pet.start = now
30  pet.x0 = x
31  pet.x1 = x
32  pet.id++
33  let act
34  if (mood === 'alert') act = 'alert'
35  else if (mood === 'celebrate') act = 'celebrate'
36  else if (mood === 'working') act = pick(['console', 'console', 'read', 'walk', 'idle'], rand())
37  else if (mood === 'sleepy') act = pick(['sleep', 'sleep', 'idle'], rand())
38  else act = pick(['idle', 'walk', 'walk', 'run', 'jump', 'read'], rand())
39  pet.act = act
40  if (act === 'walk' || act === 'run') {
41    let target = rand()
42    if (Math.abs(target - x) < 0.15) target = x < 0.5 ? Math.min(1, x + 0.4) : Math.max(0, x - 0.4)
43    pet.x1 = target
44    pet.dir = target >= x ? 1 : -1
45    pet.dur = (Math.abs(target - x) / SPEED[act]) * 1000
46  } else {
47    if (rand() < 0.3) pet.dir *= -1
48    pet.dur = act === 'sleep' ? 14000 : act === 'alert' ? 2500 : act === 'celebrate' ? 3200 : act === 'jump' ? 2000 : 3000 + rand() * 4000
49  }
50  return true
51}
52
53// ---------- desktop: animated SVG ----------
54
55export const PET_CSS =
56  '.pb{fill:#D97757}.pd{fill:#2a1c17}.ps{fill:#C4623F}' +
57  '.fb{transform-box:fill-box;transform-origin:center}.ft{transform-box:fill-box;transform-origin:top center}.fbot{transform-box:fill-box;transform-origin:bottom center}' +
58  '@keyframes blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.1)}}' +
59  '@keyframes breathe{0%,100%{transform:scaleY(1)}50%{transform:scaleY(.965)}}' +
60  '@keyframes legA{0%,100%{transform:rotate(18deg)}50%{transform:rotate(-18deg)}}' +
61  '@keyframes legB{0%,100%{transform:rotate(-18deg)}50%{transform:rotate(18deg)}}' +
62  '@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.2px)}}' +
63  '@keyframes jump{0%,100%{transform:translateY(0) scaleY(.92)}12%{transform:translateY(0) scaleY(1.04)}45%{transform:translateY(-12px)}80%{transform:translateY(0)}}' +
64  '@keyframes tap{0%,100%{transform:translateY(0)}50%{transform:translateY(1.5px)}}' +
65  '@keyframes glow{0%,100%{opacity:1}50%{opacity:.55}}' +
66  '@keyframes zz{0%{transform:translate(0,0);opacity:0}20%{opacity:1}100%{transform:translate(6px,-14px);opacity:0}}' +
67  '@keyframes wave{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(25deg)}}' +
68  '@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-1px)}75%{transform:translateX(1px)}}' +
69  '@keyframes conf{0%{transform:translateY(0);opacity:1}100%{transform:translateY(16px);opacity:0}}' +
70  '@keyframes look{0%,40%{transform:translateX(-1px)}50%,90%{transform:translateX(1.2px)}}' +
71  '@keyframes puff{0%{transform:translateX(0) scale(.6);opacity:.7}100%{transform:translateX(-8px) scale(1.3);opacity:0}}' +
72  '@media (prefers-reduced-motion:reduce){*{animation:none!important}}'
73
74// the pet in its own 48x38 box (viewBox -4 -6 48 38), facing right
75export function petSvgBody(state) {
76  const run = state === 'run'
77  const walk = state === 'walk' || run
78  const sit = state === 'console' || state === 'read'
79  const sleep = state === 'sleep'
80  const legDur = run ? '.28s' : '.6s'
81  const legs = sit
82    ? '<rect x="12" y="25" width="16" height="3" rx="1.5" class="ps"/>'
83    : [11, 15.5, 21.5, 26]
84        .map((x, i) => `<rect x="${x}" y="21" width="3" height="7" rx="1.5" class="pb ft"${walk ? ` style="animation:${i % 2 ? 'legB' : 'legA'} ${legDur} ease-in-out infinite"` : ''}/>`)
85        .join('')
86  const eyes = sleep
87    ? '<rect x="13.5" y="13.5" width="4" height="1.4" rx=".7" class="pd"/><rect x="22.5" y="13.5" width="4" height="1.4" rx=".7" class="pd"/>'
88    : `<g${state === 'read' ? ' style="animation:look 2.4s ease-in-out infinite"' : ''}><rect x="14" y="11" width="3" height="5" rx="1.5" class="pd fb" style="animation:blink 3.6s infinite"/><rect x="23" y="11" width="3" height="5" rx="1.5" class="pd fb" style="animation:blink 3.6s infinite"/></g>`
89  const arms =
90    state === 'celebrate'
91      ? '<rect x="3" y="4" width="4" height="9" rx="2" class="pb fbot" style="animation:wave .5s ease-in-out infinite"/><rect x="33" y="4" width="4" height="9" rx="2" class="pb fbot" style="animation:wave .5s ease-in-out infinite reverse"/>'
92      : `<rect x="3" y="12" width="6" height="5" rx="2.5" class="pb"${state === 'console' ? ' style="animation:tap .25s infinite"' : ''}/><rect x="31" y="12" width="6" height="5" rx="2.5" class="pb"${state === 'console' ? ' style="animation:tap .25s infinite .12s"' : ''}/>`
93  let props = ''
94  if (state === 'console')
95    props = '<rect x="11" y="17" width="18" height="9" rx="2" fill="#4a4a48"/><rect x="13" y="18.5" width="9" height="6" rx="1" fill="#5DCAA5" style="animation:glow .4s infinite"/><circle cx="25" cy="20" r="1" fill="#E5484D"/><circle cx="26.5" cy="23" r="1" fill="#378ADD"/>'
96  if (state === 'read')
97    props = '<rect x="10" y="15" width="20" height="10" rx="1.5" fill="#378ADD"/><rect x="11.5" y="16" width="8" height="8" rx=".8" fill="#f2f0ea"/><rect x="20.5" y="16" width="8" height="8" rx=".8" fill="#e8e4da"/><path d="M13 18.5h5M13 20.5h5M13 22.5h4M22 18.5h5M22 20.5h5" stroke="#9a9893" stroke-width=".6"/>'
98  if (sleep)
99    props = '<text x="31" y="6" font-size="6" fill="#9a9893" font-family="system-ui" style="animation:zz 2s infinite">z</text><text x="34" y="3" font-size="4.5" fill="#9a9893" font-family="system-ui" style="animation:zz 2s infinite .9s">z</text>'
100  if (state === 'alert')
101    props = '<g style="animation:shake .3s infinite"><rect x="29" y="-3" width="9" height="9" rx="4.5" fill="#E09A1E"/><text x="33.5" y="4.3" font-size="7" font-weight="600" text-anchor="middle" fill="#412402" font-family="system-ui">!</text></g>'
102  if (state === 'celebrate')
103    props = ['#EBA83A', '#9C95EC', '#5DCAA5', '#E2706F'].map((c, i) => `<rect x="${6 + i * 9}" y="-2" width="2" height="2" rx=".5" fill="${c}" style="animation:conf 1s infinite ${i * 0.22}s"/>`).join('')
104  if (run) props = '<circle cx="6" cy="27" r="2" fill="#4a4a48" class="fb" style="animation:puff .5s infinite"/><circle cx="4" cy="25" r="1.5" fill="#4a4a48" class="fb" style="animation:puff .5s infinite .25s"/>'
105  const bodyAnim =
106    state === 'jump' || state === 'celebrate'
107      ? 'animation:jump 1s cubic-bezier(.3,.6,.4,1) infinite'
108      : walk
109        ? `animation:bob ${legDur} ease-in-out infinite`
110        : `animation:breathe ${sleep ? '3s' : '2.4s'} ease-in-out infinite`
111  const lean = run ? ' style="transform:rotate(-7deg);transform-origin:20px 28px"' : ''
112  return (
113    `<ellipse cx="20" cy="29.5" rx="${state === 'jump' ? 8 : 12}" ry="1.6" fill="#000" opacity=".35"/>` +
114    `<g${lean}><g class="fbot" style="${bodyAnim}"><g transform="translate(0 ${sit ? 3 : 0})">${legs}${arms}` +
115    '<rect x="8" y="5" width="24" height="17" rx="4" class="pb"/><rect x="8" y="18" width="24" height="4" rx="2" class="ps" opacity=".55"/>' +
116    `${eyes}</g></g></g>${props}`
117  )
118}
119
120// the lane: the pet walks from x0 to x1 by CSS, phased by the scene's age so a redraw does not restart it
121export function petLaneSvg(pet, now, W, H = 62, scale = 1.05) {
122  const PW = 48 * scale
123  const PH = 38 * scale
124  const room = Math.max(0, W - PW)
125  const a = pet.x0 * room
126  const b = pet.x1 * room
127  const age = Math.max(0, now - pet.start) / 1000
128  const dur = Math.max(0.001, pet.dur / 1000)
129  const move = `@keyframes mv${pet.id}{from{transform:translateX(${a.toFixed(1)}px)}to{transform:translateX(${b.toFixed(1)}px)}}`
130  const flip = pet.dir < 0 ? `translate(${PW} 0) scale(-1 1)` : ''
131  let dots = ''
132  if (scale >= 1) for (let x = 4; x < W; x += 12) dots += `<rect x="${x}" y="${H - 4}" width="4" height="1" fill="#fff" opacity=".07"/>`
133  return (
134    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
135    `<style>${PET_CSS}${move}.mv{animation:mv${pet.id} ${dur.toFixed(2)}s linear both;animation-delay:-${Math.min(age, dur).toFixed(2)}s}</style>` +
136    dots +
137    `<g class="mv"><g transform="translate(0 ${H - PH - 2})"><g transform="${flip}"><svg width="${PW}" height="${PH}" viewBox="-4 -6 48 38" overflow="visible">${petSvgBody(pet.act)}</svg></g></g></g>` +
138    '</svg>'
139  )
140}
141
142// ---------- terminal: the same shapes rasterized, frame by frame ----------
143
144const COL = {
145  O: [217, 119, 87], C: [196, 98, 63], D: [42, 28, 23], G: [74, 74, 72], S: [93, 202, 165], R: [229, 72, 77],
146  B: [55, 138, 221], W: [242, 240, 234], P: [232, 228, 218], Y: [224, 154, 30], Z: [154, 152, 147], K: [0, 0, 0],
147  V: [156, 149, 236], E: [226, 112, 111],
148}
149
150function canvas(W, H) {
151  return { W, H, px: new Uint8Array(W * H * 4) }
152}
153
154function blend(cv, x, y, rgb, a) {
155  if (x < 0 || y < 0 || x >= cv.W || y >= cv.H || a <= 0) return
156  const i = (y * cv.W + x) * 4
157  const da = cv.px[i + 3] / 255
158  const oa = a + da * (1 - a)
159  for (let k = 0; k < 3; k++) cv.px[i + k] = Math.round((rgb[k] * a + cv.px[i + k] * da * (1 - a)) / (oa || 1))
160  cv.px[i + 3] = Math.round(oa * 255)
161}
162
163// a shape in pet units, drawn at scale s with 2x2 coverage sampling
164function shape(cv, s, inside, bx0, by0, bx1, by1, rgb, alpha = 1) {
165  const X0 = Math.floor((bx0 + 4) * s)
166  const Y0 = Math.floor((by0 + 6) * s)
167  const X1 = Math.ceil((bx1 + 4) * s)
168  const Y1 = Math.ceil((by1 + 6) * s)
169  for (let py = Y0; py < Y1; py++) {
170    for (let px = X0; px < X1; px++) {
171      let hit = 0
172      for (const [ox, oy] of [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]) {
173        if (inside((px + ox) / s - 4, (py + oy) / s - 6)) hit++
174      }
175      if (hit) blend(cv, px, py, rgb, (hit / 4) * alpha)
176    }
177  }
178}
179
180function rrect(cv, s, x, y, w, h, r, c, a) {
181  const inside = (u, v) => {
182    if (u < x || v < y || u > x + w || v > y + h) return false
183    const cx = Math.min(Math.max(u, x + r), x + w - r)
184    const cy = Math.min(Math.max(v, y + r), y + h - r)
185    return (u - cx) ** 2 + (v - cy) ** 2 <= r * r
186  }
187  shape(cv, s, inside, x, y, x + w, y + h, COL[c], a)
188}
189
190function ellipse(cv, s, cx, cy, rx, ry, c, a) {
191  shape(cv, s, (u, v) => ((u - cx) / rx) ** 2 + ((v - cy) / ry) ** 2 <= 1, cx - rx, cy - ry, cx + rx, cy + ry, COL[c], a)
192}
193
194export const PET_RASTER = { scale: 2, width: 96, height: 76 }
195
196export function petFrame(act, tMs, dir) {
197  const s = PET_RASTER.scale
198  const cv = canvas(PET_RASTER.width, PET_RASTER.height)
199  const ph = tMs / 1000
200  const sin = (period, off = 0) => Math.sin(((ph + off) / period) * Math.PI * 2)
201  const run = act === 'run'
202  const walk = act === 'walk' || run
203  const sit = act === 'console' || act === 'read'
204  let dy = 0
205  let squash = 0
206  if (act === 'jump' || act === 'celebrate') {
207    const p = ph % 1
208    if (p < 0.12) squash = 1.2
209    else if (p < 0.8) dy = -12 * Math.sin(((p - 0.12) / 0.68) * Math.PI)
210  } else if (walk) dy = -Math.abs(sin(run ? 0.28 : 0.6)) * 1.2
211  else dy = (sin(act === 'sleep' ? 3 : 2.4) * 0.5 + 0.5) * 0.6
212  const lean = run ? 1.5 : 0
213  const sy = sit ? 3 : 0
214  ellipse(cv, s, 20, 29.5, act === 'jump' && dy < -4 ? 8 : 12, 1.6, 'K', 0.35)
215  if (run) {
216    const q = (ph % 0.5) / 0.5
217    ellipse(cv, s, 6 - q * 8, 27, 2 * (0.6 + q * 0.7), 2 * (0.6 + q * 0.7), 'G', 0.7 * (1 - q))
218  }
219  const Y = (v) => v + dy + sy
220  if (sit) rrect(cv, s, 12, Y(25), 16, 3, 1.5, 'C')
221  else {
222    ;[11, 15.5, 21.5, 26].forEach((x, i) => {
223      const swing = walk ? sin(run ? 0.28 : 0.6, i % 2 ? 0 : (run ? 0.14 : 0.3)) * (run ? 2.2 : 1.4) : 0
224      rrect(cv, s, x + swing, Y(21) + squash, 3, 7 - squash, 1.5, 'O')
225    })
226  }
227  const tap = act === 'console' ? (sin(0.25) > 0 ? 1.5 : 0) : 0
228  if (act === 'celebrate') {
229    const w = sin(0.5) * 1.2
230    rrect(cv, s, 3 + w + lean, Y(4), 4, 9, 2, 'O')
231    rrect(cv, s, 33 - w + lean, Y(4), 4, 9, 2, 'O')
232  } else {
233    rrect(cv, s, 3 + lean, Y(12) + tap, 6, 5, 2.5, 'O')
234    rrect(cv, s, 31 + lean, Y(12) + tap, 6, 5, 2.5, 'O')
235  }
236  rrect(cv, s, 8 + lean, Y(5) + squash, 24, 17 - squash, 4, 'O')
237  rrect(cv, s, 8 + lean, Y(18), 24, 4, 2, 'C', 0.55)
238  if (act === 'sleep') {
239    rrect(cv, s, 13.5 + lean, Y(13.5), 4, 1.4, 0.7, 'D')
240    rrect(cv, s, 22.5 + lean, Y(13.5), 4, 1.4, 0.7, 'D')
241  } else {
242    const blinking = ph % 3.6 > 3.42
243    const look = act === 'read' ? (ph % 2.4 < 1.2 ? -1 : 1.2) : 0
244    const eh = blinking ? 0.8 : 5
245    const ey = blinking ? 13 : 11
246    rrect(cv, s, 14 + lean + look, Y(ey), 3, eh, Math.min(1.5, eh / 2), 'D')
247    rrect(cv, s, 23 + lean + look, Y(ey), 3, eh, Math.min(1.5, eh / 2), 'D')
248  }
249  if (act === 'console') {
250    rrect(cv, s, 11, Y(17), 18, 9, 2, 'G')
251    rrect(cv, s, 13, Y(18.5), 9, 6, 1, 'S', sin(0.4) > 0 ? 1 : 0.6)
252    ellipse(cv, s, 25, Y(20), 1, 1, 'R')
253    ellipse(cv, s, 26.5, Y(23), 1, 1, 'B')
254  }
255  if (act === 'read') {
256    rrect(cv, s, 10, Y(15), 20, 10, 1.5, 'B')
257    rrect(cv, s, 11.5, Y(16), 8, 8, 0.8, 'W')
258    rrect(cv, s, 20.5, Y(16), 8, 8, 0.8, 'P')
259  }
260  if (act === 'sleep') {
261    for (const [off, size] of [[0, 1], [0.9, 0.75]]) {
262      const q = ((ph + off) % 2) / 2
263      const zx = 31 + q * 6 + (off ? 3 : 0)
264      const zy = 2 - q * 14 - (off ? 3 : 0)
265      const a = q < 0.2 ? q * 5 : 1 - q
266      rrect(cv, s, zx, zy, 3 * size, 0.8, 0.3, 'Z', a)
267      rrect(cv, s, zx + 1.1 * size, zy + 0.8, 0.8, 1.6 * size, 0.3, 'Z', a)
268      rrect(cv, s, zx, zy + 2.4 * size, 3 * size, 0.8, 0.3, 'Z', a)
269    }
270  }
271  if (act === 'alert') {
272    const sx = Math.sin(ph * 40) * 0.8
273    rrect(cv, s, 29 + sx, -3, 9, 9, 4.5, 'Y')
274    rrect(cv, s, 32.8 + sx, -1.5, 1.6, 3.6, 0.6, 'D')
275    rrect(cv, s, 32.8 + sx, 2.8, 1.6, 1.5, 0.6, 'D')
276  }
277  if (act === 'celebrate') {
278    ;['Y', 'V', 'S', 'E'].forEach((c, i) => {
279      const q = ((ph + i * 0.22) % 1) / 1
280      rrect(cv, s, 6 + i * 9, -2 + q * 16, 2, 2, 0.5, c, 1 - q)
281    })
282  }
283  if (dir < 0) {
284    const { W, H, px } = cv
285    for (let y = 0; y < H; y++) {
286      for (let x = 0; x < W / 2; x++) {
287        const a = (y * W + x) * 4
288        const b = (y * W + (W - 1 - x)) * 4
289        for (let k = 0; k < 4; k++) {
290          const t = px[a + k]
291          px[a + k] = px[b + k]
292          px[b + k] = t
293        }
294      }
295    }
296  }
297  return cv.px
298}
299