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…

<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.
/usage needed./deck style; see them all in Styles.<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">
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.
| Row | What it shows | Where it comes from |
|---|---|---|
| Plan bar | Steps, the current step, stage lines | Claude's todo list, task tools, or a plan you approve in plan mode |
| Activity bar | What Claude is doing right now (Editing auth.ts · 14 tools · 1m 12s) | Any turn without a todo list |
| Agent strips | Each subagent's current tool, its model, elapsed time | Subagents, under the bar that started them |
| Context | Tokens used out of the window | Your session |
| 5h and 7d limits | Percent 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 month | Your session cost, added up across sessions. Turn on with /deck cost on; it matters on a pay-per-use API key |
| Model route | Which model and effort a router picked, and how sure it was | jev-model-router, if installed |
| Clock and weather | Local time and the weather where you are | Open-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 moPick 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">
| Style | CLI | Desktop |
|---|---|---|
| Pixel | braille dots and a colored pill | twinkling pixels and a gliding pill |
| Segments | ▰▰▰▰▱▱▱ | rounded blocks |
| Line | ━━━━╸──── | thin line with a pulsing head |
| Solid | filled bar with the text inside | filled bar with a light sweep |
| Dots | ●─●─◉┄○ one per step | step dots |
| Spark | slim bar and a ▁▂▃▅▇ history of the last hour | slim bar and a live sparkline, with the burn rate |
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.
| Command | What it does | ||
|---|---|---|---|
/deck, 0 in an empty prompt, or the Deck footer button | Collapse or expand | ||
/deck style | Style 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 version | Show 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 demo | A sample plan with two agents (plus a sample route and limits until real ones arrive) | ||
/deck clear | Remove all bars | ||
/deck status | Everything Deck shows, as text |
Settings are saved and shared by every session on your machine.
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.
| Where | What Deck shows |
|---|---|
| Model route row | Your 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 strips | The 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.
/plugin → Installed → Deck → update. See CHANGELOG.md for what changed.
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.
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.
Deck is an independent project, not made or endorsed by Anthropic.
hooks/register.js 1960 lines1// 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 + '¤t=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 lines1// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
80hooks/styles-terminal.js 171 lines1// 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}
171hooks/styles-desktop.js 314 lines1// 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}
314hooks/pet.js 299 lines1// 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