SLOPSHOPPER

crab-progress

Mission Control for Claude Code: a HUD band above the prompt, a live agents pane (/missioncontrol), skins, pixel-art casts and Night City easter eggs.

newpanebandspinnerrowsguard
v0.4.0MITupdated 2026-10-25Chinchuan24/MIssonControl-Skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-progress
│ ┃ 🦀 Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ Collapse │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Usage ⏺ Update(src/auth.ts) │ ┃ Session limit ████░░░░░░░░ 31% ⎿ Added 2 lines, removed 1 line │ ┃ Today 1 tasks · 0 agents · 0 tokens · ≈$0.00 ⏺ Bash(bun test) │ ┃ Workflow ⎿ 3 pass, 1 fail │ ┃ ✓ fix the failing auth test and add an au… │ ┃ ○ Manager fix the failing auth test and add… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Token Manager Healthy 0/min │ ┃ ● Lean and on budget ✓ ✻ Worked for 42s · done 4:20 PM │ ┃ no sub-agents yet │ ┃ › /missioncontrol │ ┃ Memory ⎿ crab-progress: Mission Control opened. │ ┃ ▍Keep every answer short and skimmable: lea… │ ✻ Sidestepping… ● Claude ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Idle □ ◔ 49% ◈ 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Claude ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Idle □ ◔ 49% ◈ 0
Pane · 🦀 Mission Control
Collapse ≈$0.00 · 0 tokens · 0:00 Usage Session limit ████░░░░░░░░ 31% Today 1 tasks · 0 agents · 0 tokens · ≈$0.00 Workflow ✓ fix the failing auth test and add an audit log call 10… ○ Manager fix the failing auth test and add an audit log ca… Token Manager Healthy 0/min ● Lean and on budget ✓ no sub-agents yet Memory ▍Keep every answer short and skimmable: lead with the answe…
Pane · Memory
How Claude answers you 1. Keep every answer short and skimmable: lead with the answer, then at most about 5 bullets or a few short lines. No long explanations, background or recaps unless asked. The user has ADHD. Sent to Claude at the start of every session. Changes apply next session.
Pane · Skins
Cyberpunk Skins add-on: Cyberpunk 2077, Lucy · Edgerunners and Arasaka. Get it: https://github.com/Chinchuan24/MIssonControl-Skins#-choose-y our-edition Default No skin: plain greys, system font, no effects. use ▸ Crab Classic The original: soft violet, gold boss, rounded
README

<img src="docs/hero.png" alt="Mission Control, a mod for Claude Code. Left: Crab Classic crabs. Right: the Cyberpunk Skins cast in Night City." width="100%">

<h3 align="center">See everything Claude is doing. Live.</h3>

A live HUD and dashboard for <a href="https://claude.com/claude-code">Claude Code</a> (desktop app and CLI):<br> who's working, on what, how far along, and what it costs.

<a href="#-choose-your-edition">Download</a> · <a href="#-what-mission-control-does">Features</a> · <a href="#-what-the-cyberpunk-skins-add">Cyberpunk Skins</a> · <a href="#-skin-showcase">Skins</a> · <a href="#-install">Install</a> · <a href="#-commands">Commands</a>

<img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2B2320?style=flat-square"> <img alt="Platforms" src="https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-E76F51?style=flat-square&labelColor=2B2320&label=runs%20on"> <img alt="Skins" src="https://img.shields.io/badge/skins-5%20%2B%201%20hidden-FCEE0A?style=flat-square&labelColor=0A0A0F">


🎯 Choose your edition

<table align="center"> <tr> <td align="center" width="50%" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-core.zip"> <img src="docs/edition-core.png" alt="Mission Control Core: default and Crab Classic skins" width="100%"> </a> </td> <td align="center" width="50%" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-cyberpunk.zip"> <img src="docs/edition-cyberpunk.png" alt="Cyberpunk Skins: Cyberpunk 2077, Lucy Edgerunners, Arasaka and a hidden skin" width="100%"> </a> </td> </tr> <tr> <td align="center" valign="top"> <b>The live dashboard.</b><br>Clean, light, free. </td> <td align="center" valign="top"> <b>Jack in, choom.</b><br>Your crew just moved to Night City. </td> </tr> <tr> <td align="center" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-core.zip"> <img src="docs/btn-core.png" alt="Download Core" width="300"> </a> </td> <td align="center" valign="top"> <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/mission-control-cyberpunk.zip"> <img src="docs/btn-cyberpunk.png" alt="Download Core + Cyberpunk" width="300"> </a><br> <sub>Have Core already? <a href="https://github.com/Chinchuan24/MIssonControl-Skins/releases/latest/download/cyberpunk-skins-addon.zip">Get the add-on only</a>.</sub> </td> </tr> </table>

Unofficial, non-commercial fan project. Not affiliated with, endorsed by or sponsored by CD PROJEKT S.A., CD PROJEKT RED, Studio Trigger or Netflix. Cyberpunk 2077, Cyberpunk: Edgerunners and all related names, characters and terms belong to their respective owners and are used as fan homage only. The MIT license covers this project's code, not those properties. Removal requests: open an issue.


🧭 What Mission Control does

Claude works in steps, calls tools and spins up sub-agents. Mission Control shows it all at a glance.

Mission Control: the HUD band and the pane

  • 📊 HUD band above the prompt, always on: the boss, the task, what it's doing now, a progress gauge
  • 🧪 Readouts: context, 5-hour limit, burn rate, tok/s, turn and total cost, prompt-cache countdown
  • 👥 Crew and 🪜 Steps: every sub-agent by role, the plan as pips
  • 💸 Token Manager: cost per task, cache hits, budget warnings; optional Haiku autopilot for read-only explorers
  • 🧊 Smart Keep-Warm: refreshes the prompt cache just before it expires, only when that saves more than it costs (/tokens warm on)
  • 🚀 Crew mode: big jobs get split across sub-agents automatically
  • 🚨 Stuck alarm (2 min quiet) and 🔔 done chime (toast, notification, sound)
  • 🧾 Rich replies: diffs, code, tables, math and mermaid as cards
  • 🌍 7 currencies, 3 languages (English, Russian, Hebrew)
  • 🎨 Skins: default (the app's own look) and Crab Classic (a crab in costume per role), or make your own
SectionWhat's in it
UsageYour limits, plus tokens per model for this session and all time
TodayTasks, agents, tokens and cost
WorkflowThe plan's steps, live
ManagerThe boss card: model, effort, sub-agents, cost and time
Token ManagerBurn rate, cost per task, cache hits, budget warnings, optional Haiku autopilot and Smart Keep-Warm
CrewEvery agent by role in one long list, however big the crew, plus a hierarchy from the manager down. Collapse folds the whole list into a short view; ▾ Finished folds finished agents and steps into one line each. Both are remembered.
MemoryAnswer rules Claude follows in every session (/prefs)
Settings☑ open on start · ☑ crew mode · currency · ☑ sounds · change skin

Claude's prompt cache keeps your conversation for an hour after each answer. Reading it costs about a tenth of the normal price; once it expires, your next prompt pays to write the whole conversation again (about 2× the normal price). Keep-Warm sends one tiny side request a few minutes before the hour runs out, so the cache stays warm.

  • 🙈 Never in your chat: the refresh is a side request over your own conversation; nothing is added to it.
  • 🧮 Only when it pays: it goes out only when the expected saving beats its cost by at least $0.05. Small conversations are left alone.
  • 🏃 Knows when you'll be back: while agents or background tasks still run, you're sure to return, so it keeps going (up to 12 refreshes). Idle, it stops after 3 (close to three more warm hours).
  • 🗜️ Skips a dying context: at 85%+ context a /compact is coming, which rewrites the cache anyway.
  • 📊 Shows its work: each refresh's cost and saving in a toast and in the Token Manager; /tokens totals them.
  • ⏸️ Pauses itself if a refresh finds the cache already gone, until your next prompt.

Off by default: turn it on with /tokens warm on. On a Pro or Max plan there's no per-token bill, so each refresh uses a little quota instead; the gain is mostly speed.


🌃 What the Cyberpunk Skins add

The add-on swaps the crabs for a pixel-art cast, moves the HUD to Night City and adds a progression system.

Street Cred and Braindance

FeatureCoreCyberpunk
HUD band, pane, crew, Token Manager, crew mode, done chime, rich replies✓✓
default and Crab Classic skins, custom JSON skins✓✓
Cyberpunk 2077, Lucy · Edgerunners, Arasaka skins + a hidden one 🎸—✓
Pixel-art cast: David, Lucy, Rebecca, Maine, Kiwi, Johnny… Saburo and Adam Smasher on Arasaka—✓
Street Cred: XP per job, ranks NOBODY → LEGEND OF THE AFTERLIFE, 9 badges—✓
Braindance: replay the session, every tool call a mark—✓
Breach Protocol card, NCPD heat stars, CYBERPSYCHOSIS / MAXTAC warnings—✓
Night City skyline, Relic glitch, Sandevistan afterimages, the Afterlife—✓
Per-skin sound packs, eddies (€$), radio, street slang—✓

🎨 Skin showcase

Pick one with /skins (a live picker) or /skin <id>. Each skin restyles the band, the pane, the chat's tool rows and the spinner.

SkinidOne line
defaultdefaultKeeps the app's own look. Just the data.
🦀 Crab Classiccrab-classicThe original. A working crab in costume for every role.
🟡 Cyberpunk 2077cyberpunk-2077Neon yellow, cyan and hot red. David leads the crew.
🌙 Lucy · Edgerunnerslucy-edgerunnersRose-pink and cyan under the moon. Lucy runs the net.
🔴 ArasakaarasakaCorporate red and black. Saburo at the top, Smasher on call.
🎸 ???—Not in the picker. Wake up…

Crab Classic band Crab Classic crew

Night City HUD: neon yellow, cyan and hot red, cut corners and scanlines. David leads. Lucy, Rebecca, Maine, Dorio, Kiwi, Pilar, Falco and Johnny take the roles; chooms and NPCs do the work.

Cyberpunk 2077 band Cyberpunk 2077 crew

Moonlit netrunner: rose-pink and cyan on deep violet, with the moon in the skyline. Lucy leads, and David rides with the crew.

Lucy band Lucy crew

Corporate red and black. Saburo at the top. Adam Smasher, handlers, agents, netrunners, guards, a cyborg and a captive Johnny under him. Troopers, salarymen and drones on the floor. The tower stands in the skyline.

Arasaka band Arasaka crew

Skins are small .json files; see skins/README.md for every field. They load from, in order:

  1. the folder you used last
  2. a Custom Claude Skins/ folder in your working folder
  3. this repo's skins/ examples

Copy an example, give it a new id, edit it, then run /skin reload and /skin <id>.


⚡ Install

1. Get it (one of):

  • Download a zip above and unzip it anywhere. It holds one folder, mission-control/ (the one with .claude-plugin/ inside): that is the folder to point Claude Code at. Windows' "Extract All" may wrap it in a second folder; use the inner one. Or
  • clone the repo (this gets everything, add-on included): `` git clone https://github.com/Chinchuan24/MIssonControl-Skins.git ` A clone has no mission-control/ folder: point Claude Code at the cloned MIssonControl-Skins/ folder itself (it holds .claude-plugin/`).

2. Point Claude Code at the folder in ~/.claude/settings.json (Windows: C:\Users\<you>\.claude\settings.json):

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/mission-control",
    "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
  }
}
  • Windows: write the path with forward slashes or doubled backslashes, e.g. "C:/Users/<you>/mission-control" or "C:\\Users\\<you>\\mission-control". A single \ breaks the JSON.
  • Several mod folders: separate them with ; on Windows, : on macOS / Linux.
  • WSL: the CLI there is Linux: use the WSL ~/.claude/settings.json, : separators and /mnt/c/... paths.
  • Keep the mod on a local drive: network and \\wsl.localhost paths can hide the skins and the add-on.
  • CLAUDE_CODE_PLUGIN_DIR_WATCH=1 reloads the mod when its files change.

3. Restart Claude Code, then run /missioncontrol and /skins. On a Mac, quit the desktop app with ⌘Q and open it again.

Unzip cyberpunk-skins-addon.zip into your Mission Control folder. It creates addons/cyberpunk/. Restart Claude Code, then pick a skin with /skins.

On a Mac, double-clicking the zip unzips it into a folder of its own, and dragging addons/ onto your install in Finder replaces the folder instead of merging it. Unzip it from Terminal instead:

unzip -o ~/Downloads/cyberpunk-skins-addon.zip -d /path/to/mission-control

On Windows, "Extract All" also makes a folder of its own. In PowerShell instead:

Expand-Archive -Force "$env:USERPROFILE\Downloads\cyberpunk-skins-addon.zip" -DestinationPath "C:\path\to\mission-control"
  • Works on Windows, macOS and Linux.
  • 🔈 Sounds play on macOS and in the Claude Desktop app; Linux/Windows terminals stay silent.
  • Optional: the free Rajdhani font gives the true HUD look (otherwise your OS's own sans is used).

💻 Commands

CommandWhat it does
/missioncontrolOpen Mission Control (Settings at the bottom)
/missioncontrol currency <code> [rate]Costs in USD (the default), AUTO (eddies on the cyberpunk skins), EDDIES, EUR, GBP, JPY, MYR or SGD; optional rate per US dollar
/skinsThe skin picker, every skin as a live preview
/skin/skin to list, /skin <id> to switch, /skin off, /skin reload
/autocrewCrew mode: state, on, off, or <context %> to call the crew
/tokensToken Manager report, `budget <dollars\off>, autopilot <on\off>, warm <on\off>` (Smart Keep-Warm)
/prefsHow Claude should answer you, in every session: add <rule>, remove <n>, clear

  • hooks/register.tsx: hooks, UI and commands
  • hooks/cast.ts: the pixel casts
  • hooks/eggs.ts: the easter eggs
  • hooks/richtext.ts: diffs, tables, math and mermaid in replies
  • hooks/warm.ts: Smart Keep-Warm's arithmetic (when a refresh pays)
  • Tests: claude plugin test /path/to/MIssonControl-Skins (and claude plugin validate …)
  • .claude-plugin/types/ is generated on each machine by the CLI and is git-ignored.

🙏 Credits and license

  • Started from savvy-progress by johnnyvizz (claude-kit, MIT), since heavily reworked. Its copyright notice is kept in LICENSE.
  • Smart Keep-Warm was inspired by cache-warmer by paulbkim-dev (claude-code-cache-warmer, MIT), itself after Pi's cache warmer: the side-request refresh, the expected-value rule and the idle limit. Mission Control's version is its own code, built into the Token Manager.
  • Character designs and pixel art: most were created by Chinchuan24 with Higgsfield, then converted to sprite grids. The Crab Classic crab is based on Pixel Clawd from DockCrab (Clawdy). The done chime (assets/done.wav) was synthesised for this project.
  • Open source under the MIT License: use it, fork it, remix it. Game and show names, characters and terms are not covered.
Source 8 files
hooks/register.tsx 6335 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { CASTS, CAST_CSS, hashOf, spriteMarkup } from './cast'
5import { BREACH_H, BREACH_W, CJK_FONT, EGG_CSS, EGG_TOOLS, EGG_WORDS, GREETING, KATAKANA, breachCard, breachTicker, celebration, heatStars, legendsCard, legendsHeight, psychoChip, radio, relicText, sandevistan, skyline, sysTag } from './eggs'
6import type { Egg, Legend } from './eggs'
7import { BEATS_KEPT, BLANK_CRED, BOOT_H, award, rockerRankOf, bdHeight, bootStrip, braindanceCard, cleanCred, credCard, credHeight, levelOf, rankOf, shiftPick, stepPick } from './extras'
8import type { Beat, Cred } from './extras'
9import { hasInlineMath, inlineMath, mermaidChart, splitBlocks, texToUnicode, unifiedDiff } from './richtext'
10import { LIVE_N, glide, isNewReading, isSame, liveText, liveWidth, pin, steady, steadyTree, wallMinute } from './smooth'
11import { WARM_PROMPT, WARM_TAG, isWarmDue, isWarmed, warmCall, warmLimitHit } from './warm'
12import type { AgentRun, AgentStatus, AllTime, Background, BgCron, BgTask, Env, Flow, Manager, Panel, Phase, PlanStep, PlannedTask, SessionStats, Today, Watch } from '../types'
13import type { On } from 'claude-code'
14import type { Timekeeper } from '../types'
15
16// Note: `update` writes, and redraws every reader, even when its change hands back
17// the same value: guard no-op writes with a read first.
18const flow = atom({ plugin: 'crab-progress', key: 'flow' } as const, null)
19const agents = atom({ plugin: 'crab-progress', key: 'agents' } as const, [])
20const panel = atom({ plugin: 'crab-progress', key: 'panel' } as const, {
21  isCompact: false,
22  isDoneCollapsed: false,
23  autoOpenedFor: '',
24})
25const now = atom({ plugin: 'crab-progress', key: 'now' } as const, 0)
26// The desktop's ticks: written only when what the band, or the pane, draws from the
27// time has changed (a minute, a threshold, a label), never just because a second passed.
28const bandTick = atom({ plugin: 'crab-progress', key: 'bandTick' } as const, 0)
29const paneTick = atom({ plugin: 'crab-progress', key: 'paneTick' } as const, 0)
30const memory = atom({ plugin: 'crab-progress', key: 'memory' } as const, [])
31const BLANK_ENV: Env = { cwd: '', model: '', surface: '', contextPct: 0, contextTokens: 0, contextWindow: 0, costUsd: 0, connectors: [], limits: [] }
32const env = atom({ plugin: 'crab-progress', key: 'env' } as const, BLANK_ENV)
33const BLANK_MANAGER: Manager = { task: '', isWorking: false, startedAt: 0 }
34const manager = atom({ plugin: 'crab-progress', key: 'manager' } as const, BLANK_MANAGER)
35const BLANK_WARM: Watch['warm'] = { on: false, idle: 0, busy: 0, halted: '', count: 0, spentUsd: 0, savedUsd: 0 }
36const BLANK_WATCH: Watch = { burn: [], cacheRead: 0, promptAll: 0, autopilot: false, budgetUsd: 0, warm: BLANK_WARM }
37const watch = atom({ plugin: 'crab-progress', key: 'watch' } as const, BLANK_WATCH)
38const BLANK_TODAY: Today = { date: '', tasks: 0, agents: 0, tokens: 0, costUsd: 0 }
39const today = atom({ plugin: 'crab-progress', key: 'today' } as const, BLANK_TODAY)
40const allTime = atom({ plugin: 'crab-progress', key: 'allTime' } as const, { since: '', models: {} } as AllTime)
41const BLANK_SESSION: SessionStats = { turn: 0, speed: null, costAtStart: 0, cacheUntil: null, cacheLeft: null, isBusy: false, warned: 0 }
42const session = atom({ plugin: 'crab-progress', key: 'session' } as const, BLANK_SESSION)
43// Claude's background work. The engine lists it only on Stop and SubagentStop, so
44// `at` keeps when that was: the pane says how fresh the list is.
45const BLANK_BG: Background = { tasks: [], crons: [], at: 0 }
46const background = atom({ plugin: 'crab-progress', key: 'background' } as const, BLANK_BG)
47// Today and All-time as this session last read or wrote them in the store.
48const saved = atom({ plugin: 'crab-progress', key: 'saved' } as const, { today: BLANK_TODAY, allTime: { since: '', models: {} } as AllTime })
49// Crew mode: a big job is split across sub-agents. On by default; `/autocrew` turns
50// it off or moves the context line. Kept in the store, so it holds across sessions.
51type AutoCrew = { on: boolean; ctxPct: number }
52const AUTO_CREW: AutoCrew = { on: true, ctxPct: 60 }
53const readAutoCrew = async ($: EngineInterface): Promise<AutoCrew> => {
54  const v = (await $.store.get('autoCrew').catch(() => null)) as Partial<AutoCrew> | null
55  return { on: v?.on !== false, ctxPct: typeof v?.ctxPct === 'number' && v.ctxPct >= 10 && v.ctxPct <= 95 ? v.ctxPct : AUTO_CREW.ctxPct }
56}
57
58// Why a request is a big job, or '' when it is not: a long brief, a list of asks,
59// many files named, a sweep over a whole codebase, a long plan, or a full context.
60const bigJobReason = (text: string, ctxPct: number, planSteps: number, ctxLine: number): string => {
61  const asks = text.split('\n').filter(l => /^\s*(?:[-*•]|\d+[.)])\s+\S/.test(l)).length
62  const files = new Set(text.match(/[\w./-]+\.(?:tsx?|jsx?|py|go|rs|java|kt|swift|rb|php|cs|cpp|c|h|css|html|json|md|ya?ml|sql)\b/g) ?? []).size
63  if (text.length >= 1500) return 'a long brief'
64  if (asks >= 4) return `${asks} asks in one request`
65  if (files >= 6) return `${files} files named`
66  if (text.length >= 300 && /\b(?:whole|entire|every|all (?:the )?(?:files|pages|modules|components|tests))\b|\b(?:refactor|migrate|port|rewrite|audit)\b/i.test(text)) return 'a sweep over the codebase'
67  if (planSteps >= 6) return `a ${planSteps}-step plan`
68  if (ctxPct >= ctxLine) return `context at ${ctxPct}%`
69  return ''
70}
71
72const crewText = (reason: string): string => `# Crew mode (Mission Control)
73This task is a big job (${reason}). Split it instead of carrying it all in this context; it is faster, and it keeps this context, and its cost, small.
74- Break the work into independent pieces. Give each to its own sub-agent with the Agent tool, and start the independent ones together, in one message, so they run in parallel.
75- Brief each one fully: the files, the goal, what done looks like. Name it "<Role>: <piece>" (e.g. "Engineer: wire the parser", "Researcher: map the API") so Mission Control files it under its role.
76- Pick the worker by the job: mc-* agents when they are listed, else general-purpose for building and editing; Explore for read-only searching and summing up.
77- Ask workers for conclusions, not file dumps; do not read big files here that a worker can read.
78- Pieces that edit the same files go to one worker, or run one after another.
79- Keep small things here: a few tool calls are not worth a sub-agent.
80- Then review and merge what comes back, and verify it.`
81
82// The prompt cache lives an hour after an answer; warn at ten and five minutes left.
83const CACHE_TTL_MS = 60 * 60 * 1000
84const CACHE_WARN_MIN = [10, 5] as const
85// Responses this short, or this fast, are tool stubs and bursts: their timing is noise.
86const SPEED_MIN_TOKENS = 20
87const SPEED_MIN_SECONDS = 0.5
88
89// Every session's tokens per model. The first run seeds it from assets/history.json,
90// counted from the session history files; each model request adds to it after.
91async function loadAllTime($: EngineInterface): Promise<void> {
92  const kept = (await $.store.get('allTime').catch(() => undefined)) as AllTime | undefined
93  if (kept && typeof kept === 'object' && kept.models) {
94    await update($, allTime, () => kept)
95    await update($, saved, prev => ({ ...prev, allTime: kept }))
96    return
97  }
98  try {
99    const seed = parseJson(await $.fs.read(`${$.plugin.root}/assets/history.json`)) as { since?: string; models?: Record<string, number> }
100    const models = Object.fromEntries(Object.entries(seed.models ?? {}).filter(([, n]) => typeof n === 'number' && n > 0))
101    const value: AllTime = { since: typeof seed.since === 'string' ? seed.since : '', models }
102    await update($, allTime, () => value)
103    await $.store.set('allTime', value)
104    await update($, saved, prev => ({ ...prev, allTime: value }))
105  } catch {
106    // No seed: the totals start from this session.
107  }
108}
109
110// A day by the local calendar, for the daily scoreboard.
111const dayOf = (at: number): string => {
112  const d = new Date(at)
113  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
114}
115// Today's totals, rolled over at midnight; persisted at each turn's end.
116async function bumpToday($: EngineInterface, change: Partial<Omit<Today, 'date'>>): Promise<void> {
117  const at = await $.clock.now()
118  const date = dayOf(at)
119  await update($, today, prev => {
120    const base = prev.date === date ? prev : { ...BLANK_TODAY, date }
121    return {
122      ...base,
123      tasks: base.tasks + (change.tasks ?? 0),
124      agents: base.agents + (change.agents ?? 0),
125      tokens: base.tokens + (change.tokens ?? 0),
126      costUsd: base.costUsd + Math.max(0, change.costUsd ?? 0),
127    }
128  })
129}
130
131// What the day's card shows: past midnight with nothing new yet, the new day's empty count.
132const todayOn = (d: Today, at: number): Today => (at && d.date && d.date !== dayOf(at) ? { ...BLANK_TODAY, date: dayOf(at) } : d)
133
134// Two sessions open at once share the store: a save adds this session's own share since
135// it last read or wrote the store onto what is there now, never its whole snapshot.
136let saveQueue: Promise<unknown> = Promise.resolve()
137function saveTotals($: EngineInterface): Promise<void> {
138  const run = saveQueue.then(() => saveTotalsNow($))
139  saveQueue = run.catch(() => undefined)
140  return run.catch(() => undefined)
141}
142const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
143async function saveTotalsNow($: EngineInterface): Promise<void> {
144  const [day, every, base] = await Promise.all([read($, today), read($, allTime), read($, saved)])
145  if (day.date) {
146    const was = base.today.date === day.date ? base.today : { ...BLANK_TODAY, date: day.date }
147    const kept = (await $.store.get('today').catch(() => undefined)) as Today | undefined
148    const on = kept && typeof kept === 'object' && kept.date === day.date ? kept : { ...BLANK_TODAY, date: day.date }
149    const keys = ['tasks', 'agents', 'tokens', 'costUsd'] as const
150    const merged = { ...BLANK_TODAY, date: day.date }
151    // No base yet (a hot reload into this version): the store already holds this
152    // session's earlier saves, so the larger count stands rather than both added.
153    for (const k of keys) merged[k] = base.today.date ? num(on[k]) + Math.max(0, day[k] - was[k]) : Math.max(num(on[k]), day[k])
154    await $.store.set('today', merged)
155    // What came in meanwhile stays on top of the merged count.
156    await update($, today, prev => (prev.date !== day.date ? prev : { ...prev, ...Object.fromEntries(keys.map(k => [k, merged[k] + prev[k] - day[k]])) }))
157    await update($, saved, prev => ({ ...prev, today: merged }))
158  }
159  const keptAll = (await $.store.get('allTime').catch(() => undefined)) as AllTime | undefined
160  const onAll = keptAll && typeof keptAll === 'object' && keptAll.models ? keptAll : { since: '', models: {} }
161  const models: Record<string, number> = { ...onAll.models }
162  const hasBase = Boolean(base.allTime.since) || Object.keys(base.allTime.models).length > 0
163  for (const [m, n] of Object.entries(every.models)) models[m] = hasBase ? num(models[m]) + Math.max(0, n - num(base.allTime.models[m])) : Math.max(num(models[m]), n)
164  const mergedAll: AllTime = { since: onAll.since || every.since, models }
165  await $.store.set('allTime', mergedAll)
166  await update($, allTime, prev => ({
167    since: mergedAll.since,
168    models: Object.fromEntries([...new Set([...Object.keys(mergedAll.models), ...Object.keys(prev.models)])].map(m => [m, num(mergedAll.models[m]) + num(prev.models[m]) - num(every.models[m])])),
169  }))
170  await update($, saved, prev => ({ ...prev, allTime: mergedAll }))
171}
172
173// A running agent with no model request or tool call for this long is flagged.
174const STUCK_MS = 120_000
175// A background agent's end queues its report for the main loop: the job waits this
176// long for the turn that answers it before it may finish without one.
177const REPORT_WAIT_MS = 30_000
178const CELEBRATE_MS = 6_000
179
180// ---------------------------------------------------------------------------
181// Timekeeper: every job and every sub-agent runs on a time budget. The plan's
182// `minutes` set it, else the person's defaults; the 1 s tick flags overruns and
183// alerts once each. The alert marks live in `$.state`, so a hot reload never re-alerts.
184
185const TK_KEY = 'timekeeper'
186const TK_MAX_MIN = 1440
187const TK_WARN = 0.8
188const BLANK_TK: Timekeeper = { on: true, jobMin: 60, agentMin: 20, warnedFor: 0, overFor: 0 }
189const timekeeper = atom({ plugin: 'crab-progress', key: 'timekeeper' } as const, BLANK_TK)
190const TK_USAGE = 'Usage: /timekeeper for its state, /timekeeper on|off, /timekeeper job <minutes>, /timekeeper agent <minutes> (whole minutes, 1–1440)'
191
192const TK_STRINGS = {
193  en: {
194    warn: (task: string, min: number) => `⏱ ${task} has used 80% of its ${min} min budget`,
195    over: (task: string, min: number) => `⏱ Over budget: ${task} has run past its ${min} min. Ask Claude for a new estimate.`,
196    agentOver: (task: string, min: number) => `⏱ "${task}" is over its ${min} min budget`,
197    agentsOver: (n: number, tasks: string) => `⏱ ${n} sub-agents are over budget: ${tasks}`,
198    overWord: 'over budget',
199    setting: (job: number, agent: number) => `Timekeeper: ${job} min per job, ${agent} min per sub-agent`,
200  },
201  ru: {
202    warn: (task: string, min: number) => `⏱ ${task}: израсходовано 80% бюджета в ${min} мин`,
203    over: (task: string, min: number) => `⏱ Бюджет превышен: ${task} идёт дольше ${min} мин. Попросите у Claude новую оценку.`,
204    agentOver: (task: string, min: number) => `⏱ «${task}» превысил бюджет в ${min} мин`,
205    agentsOver: (n: number, tasks: string) => `⏱ Субагентов сверх бюджета: ${n} — ${tasks}`,
206    overWord: 'бюджет превышен',
207    setting: (job: number, agent: number) => `Хронометраж: ${job} мин на задачу, ${agent} мин на субагента`,
208  },
209  he: {
210    warn: (task: string, min: number) => `⏱ ${task} ניצל 80% מתקציב של ${min} דק׳`,
211    over: (task: string, min: number) => `⏱ חריגה מהתקציב: ${task} עבר את ${min} הדקות. בקשו מ-Claude הערכה חדשה.`,
212    agentOver: (task: string, min: number) => `⏱ "${task}" חרג מתקציב של ${min} דק׳`,
213    agentsOver: (n: number, tasks: string) => `⏱ ${n} סוכנים חרגו מהתקציב: ${tasks}`,
214    overWord: 'חריגה מהתקציב',
215    setting: (job: number, agent: number) => `שומר זמן: ${job} דק׳ למשימה, ${agent} דק׳ לסוכן`,
216  },
217}
218const tkStr = () => TK_STRINGS[lang]
219
220// A budget is whole minutes, one to a day; anything else is no budget.
221const cleanMin = (v: unknown): number | undefined =>
222  typeof v === 'number' && Number.isFinite(v) && v >= 1 ? Math.min(TK_MAX_MIN, Math.round(v)) : undefined
223
224// The plan's own budget: its `minutes`, else the sum of its tasks'.
225const planBudget = (f: Flow | null): number | undefined => {
226  if (!f) return undefined
227  if (f.minutes) return f.minutes
228  const sum = f.tasks.reduce((n, t) => n + (t.minutes ?? 0), 0)
229  return sum > 0 ? Math.min(TK_MAX_MIN, sum) : undefined
230}
231
232// A run's budget: its task's when the plan names it (matched as the panel matches runs), else the default.
233const agentBudget = (a: AgentRun, f: Flow | null, tk: Timekeeper): number =>
234  f?.tasks.find(t => norm(t.title) === norm(a.description))?.minutes ?? tk.agentMin
235
236// "12/45m": whole minutes used of the budget. Drawn calm, warn past 80%, alert past 100%.
237const budgetLabel = (usedMs: number, min: number): string => `${Math.floor(Math.max(0, usedMs) / 60_000)}/${min}m`
238const budgetTone = (usedMs: number, min: number, calm = ''): string => {
239  const share = usedMs / (min * 60_000)
240  return share > 1 ? SKIN.colors.alert : share >= TK_WARN ? SKIN.colors.warn : calm
241}
242// Flagged only while the timekeeper is on: turning it off hides the flag, the alert mark stays.
243const isOver = (a: AgentRun): boolean => Boolean(a.isOverBudget && a.budgetMin)
244// The band's reading while a job runs; none before a request, after it is done, or when off.
245const bandBudget = (boss: Manager, at: number): { usedMs: number; min: number } | undefined =>
246  boss.budgetMin && boss.task && boss.startedAt && !boss.doneAt ? { usedMs: Math.max(0, at - boss.startedAt), min: boss.budgetMin } : undefined
247// The band's clock, the last of its header's right-hand parts, in the budget's tone.
248const budgetClock = (parts: string[], clock: string, b: { usedMs: number; min: number }): string => {
249  const tone = budgetTone(b.usedMs, b.min)
250  const head = parts.slice(0, -1).join('   ')
251  return `${xml(head ? `${head}   ` : '')}<tspan font-weight="700"${tone ? ` fill="${tone}"` : ''}>${xml(clock)}</tspan>`
252}
253
254const readTimekeeper = async ($: EngineInterface): Promise<Pick<Timekeeper, 'on' | 'jobMin' | 'agentMin'>> => {
255  const v = (await $.store.get(TK_KEY).catch(() => null)) as Partial<Timekeeper> | null
256  return { on: v?.on !== false, jobMin: cleanMin(v?.jobMin) ?? BLANK_TK.jobMin, agentMin: cleanMin(v?.agentMin) ?? BLANK_TK.agentMin }
257}
258
259async function setTimekeeper($: EngineInterface, change: Partial<Pick<Timekeeper, 'on' | 'jobMin' | 'agentMin'>>): Promise<Timekeeper> {
260  const saved = { ...(await readTimekeeper($)), ...change }
261  await $.store.set(TK_KEY, saved).catch(() => undefined)
262  const tk = await update($, timekeeper, prev => ({ ...prev, ...saved }))
263  if (saved.on) await keepTime($, await $.clock.now().catch(() => 0))
264  else await clearBudgets($)
265  return tk
266}
267
268// Off: the drawings lose every budget; the next tick after turning it on puts them back.
269async function clearBudgets($: EngineInterface): Promise<void> {
270  if ((await read($, manager)).budgetMin !== undefined) await update($, manager, prev => ({ ...prev, budgetMin: undefined }))
271  if ((await read($, agents)).some(a => a.budgetMin !== undefined)) await update($, agents, list => list.map(a => (a.budgetMin === undefined ? a : { ...a, budgetMin: undefined })))
272}
273
274// Claimed in one atomic step, as finishIfIdle claims the cheer: two ticks never alert twice.
275async function claimAlert($: EngineInterface, mark: 'warnedFor' | 'overFor', job: number): Promise<boolean> {
276  let isMine = false
277  await update($, timekeeper, prev => {
278    isMine = prev[mark] !== job
279    // Past 100% the 80% warning is spent too: a job that jumps the line gets one alert.
280    return isMine ? { ...prev, [mark]: job, ...(mark === 'overFor' ? { warnedFor: job } : {}) } : prev
281  })
282  return isMine
283}
284
285// Two short beeps, made here: the core skins ship no alert sound of their own.
286let BEEP = ''
287const beepWav = (): string => {
288  if (BEEP) return BEEP
289  const rate = 8000
290  const n = Math.round(rate * 0.34)
291  const bytes = new Uint8Array(44 + n)
292  const v = new DataView(bytes.buffer)
293  const ascii = (at: number, s: string) => [...s].forEach((ch, i) => v.setUint8(at + i, ch.charCodeAt(0)))
294  ascii(0, 'RIFF')
295  v.setUint32(4, 36 + n, true)
296  ascii(8, 'WAVEfmt ')
297  v.setUint32(16, 16, true)
298  v.setUint16(20, 1, true)
299  v.setUint16(22, 1, true)
300  v.setUint32(24, rate, true)
301  v.setUint32(28, rate, true)
302  v.setUint16(32, 1, true)
303  v.setUint16(34, 8, true)
304  ascii(36, 'data')
305  v.setUint32(40, n, true)
306  for (let i = 0; i < n; i++) {
307    const t = i / rate
308    bytes[44 + i] = t < 0.12 || (t >= 0.2 && t < 0.32) ? 128 + Math.round(56 * Math.sin(2 * Math.PI * 880 * t)) : 128
309  }
310  const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
311  let out = ''
312  for (let i = 0; i < bytes.length; i += 3) {
313    const w = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
314    out += `${B64[(w >> 18) & 63]}${B64[(w >> 12) & 63]}${i + 1 < bytes.length ? B64[(w >> 6) & 63] : '='}${i + 2 < bytes.length ? B64[w & 63] : '='}`
315  }
316  return (BEEP = out)
317}
318
319// The skin's alert: MaxTac's siren on the cyberpunk skins, the beeps elsewhere.
320async function playAlert($: EngineInterface): Promise<void> {
321  if (!(await read($, settings)).sounds) return
322  await $.audio.play(eggOf() ? { asset: `${ADDON}/assets/maxtac.wav` } : { base64: beepWav(), mime: 'audio/wav' }).catch(() => undefined)
323}
324
325// Each tick: keep every budget current, then alert once per job at 80% and 100%, and
326// once per agent past its own. Writes only when something changed.
327async function keepTime($: EngineInterface, at: number): Promise<void> {
328  try {
329    const tk = await read($, timekeeper)
330    if (!tk.on) return
331    const [boss, list, f] = await Promise.all([read($, manager), read($, agents), read($, flow)])
332    const jobMin = boss.task ? (boss.planMin ?? tk.jobMin) : undefined
333    if (boss.budgetMin !== jobMin) await update($, manager, prev => (prev.startedAt === boss.startedAt ? { ...prev, budgetMin: jobMin } : prev))
334    if (jobMin && boss.startedAt && !boss.doneAt) {
335      const job = boss.startedAt
336      const share = (at - job) / (jobMin * 60_000)
337      const task = `"${clip(boss.task.replace(/\s+/g, ' '), 40)}"`
338      if (share > 1 && tk.overFor !== job) {
339        if (await claimAlert($, 'overFor', job)) {
340          $.ui.toast(tkStr().over(task, jobMin), { timeoutMs: 30000 })
341          void playAlert($)
342        }
343      } else if (share >= TK_WARN && tk.warnedFor !== job && tk.overFor !== job) {
344        if (await claimAlert($, 'warnedFor', job)) $.ui.toast(tkStr().warn(task, jobMin), { timeoutMs: 30000 })
345      }
346    }
347    const isDue = (a: AgentRun) => {
348      const min = agentBudget(a, f, tk)
349      return a.status === 'running' && (a.budgetMin !== min || (!a.isOverBudget && elapsed(a, at) > min * 60_000))
350    }
351    if (!list.some(isDue)) return
352    const fresh: AgentRun[] = []
353    await update($, agents, l => {
354      fresh.length = 0
355      return l.map(a => {
356        if (!isDue(a)) return a
357        const min = agentBudget(a, f, tk)
358        const isOverNow = elapsed(a, at) > min * 60_000
359        if (isOverNow && !a.isOverBudget) fresh.push({ ...a, budgetMin: min })
360        return { ...a, budgetMin: min, ...(isOverNow ? { isOverBudget: true } : {}) }
361      })
362    })
363    if (!fresh.length) return
364    // Several over in one tick share one toast and one sound: a big crew never makes a din.
365    const [one] = fresh
366    if (one && fresh.length === 1) $.ui.toast(tkStr().agentOver(clip(one.description || one.type, 40), one.budgetMin ?? tk.agentMin), { timeoutMs: 30000 })
367    else $.ui.toast(tkStr().agentsOver(fresh.length, clip(fresh.map(a => a.description || a.type).join(', '), 120)), { timeoutMs: 30000 })
368    void playAlert($)
369  } catch {
370    // A nudge: never in the way of the tick.
371  }
372}
373
374// The plan's `minutes` become the job's budget; a plan without them leaves the default.
375// Never in the way of the progress call itself.
376async function notePlanBudget($: EngineInterface, f: Flow | null): Promise<void> {
377  try {
378    const min = planBudget(f)
379    if ((await read($, manager)).planMin !== min) await update($, manager, prev => ({ ...prev, planMin: min }))
380    await keepTime($, await $.clock.now())
381  } catch {
382    // The next tick catches up.
383  }
384}
385
386// The standing rule, sent while the timekeeper is on. Short and imperative: it rides every request.
387const timekeeperText = (tk: Timekeeper): string => `# Timekeeper (Mission Control)
388Every job runs on a time budget. Follow these strictly:
389- Any job longer than a few minutes, or one that uses sub-agents: before the work starts, report the plan with \`${TOOL}\`, giving \`minutes\` for each task and for the whole job.
390- Keep to the budgets. As each task finishes, check the time spent against its budget.
391- The moment a task overruns, stalls or needs more time, tell the user plainly, with a new estimate. Never extend a budget silently.
392- For multi-step work keep a short written tracker: task, owner, budget, status.
393Without a plan the budgets are ${tk.jobMin} min per job and ${tk.agentMin} min per sub-agent.`
394
395const timekeeperStatus = async ($: EngineInterface, tk: Timekeeper): Promise<string> => {
396  if (!tk.on) return `Timekeeper off: no budgets, alerts or standing rule.\n${TK_USAGE}`
397  const boss = await read($, manager)
398  const at = await $.clock.now()
399  const job = boss.task && boss.budgetMin ? `\nThis job: ${budgetLabel((boss.doneAt ?? at) - boss.startedAt, boss.budgetMin)}${boss.planMin ? ' (from its plan)' : ''}` : ''
400  return `Timekeeper on: ${tk.jobMin} min per job, ${tk.agentMin} min per sub-agent, unless the plan gives \`minutes\`.${job}\n${TK_USAGE}`
401}
402
403const TIMEKEEPER_COMMAND = {
404  name: 'timekeeper',
405  description: 'Timekeeper: time budgets for every job and sub-agent. /timekeeper, /timekeeper on|off, /timekeeper job <minutes>, /timekeeper agent <minutes>',
406}
407
408// At session start: the command, and the settings. A reload keeps the alert marks
409// $.state holds; only the settings come from the store.
410async function startTimekeeper($: EngineInterface): Promise<void> {
411  await $.command.register(TIMEKEEPER_COMMAND).catch(() => undefined)
412  const saved = await readTimekeeper($)
413  await update($, timekeeper, prev => ({ ...prev, ...saved }))
414  if (!saved.on) await clearBudgets($)
415}
416
417// The standing rule rides at the end of the system prompt, as the memory rules do, while it is on.
418const timekeeperSections = async ($: EngineInterface) => {
419  const tk = await read($, timekeeper)
420  return tk.on ? [{ id: 'crab-progress:timekeeper', text: timekeeperText(tk), scope: 'session' as const }] : []
421}
422
423// Its command, beside the rest.
424function timekeeperHooks(on: On): void {
425  on('command.run', { command: 'timekeeper' }, async ($, e) => {
426    const [verb = '', arg, ...extra] = (e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
427    if (!verb || verb === 'status') return { text: await timekeeperStatus($, await read($, timekeeper)) }
428    if ((verb === 'on' || verb === 'off') && arg === undefined) {
429      const tk = await setTimekeeper($, { on: verb === 'on' })
430      return { text: await timekeeperStatus($, tk) }
431    }
432    // A bad number is answered, not clamped: a typo must not set a day-long budget.
433    const n = arg !== undefined && /^\d+$/.test(arg) ? Number(arg) : NaN
434    if ((verb === 'job' || verb === 'agent') && !extra.length && n >= 1 && n <= TK_MAX_MIN) {
435      const tk = await setTimekeeper($, verb === 'job' ? { jobMin: n } : { agentMin: n })
436      return { text: verb === 'job' ? `Job budget: ${tk.jobMin} min, unless the plan gives \`minutes\`.` : `Sub-agent budget: ${tk.agentMin} min, unless its task gives \`minutes\`.` }
437    }
438    return { text: TK_USAGE }
439  })
440}
441
442// ---------------------------------------------------------------------------
443// The manager's own task list: the desktop app's plan tool, read as it is called.
444// `items` replaces the list; `advance` closes the step in progress and opens the
445// next; `done` / `start` name steps by number, from 1.
446
447const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
448const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
449const MAX_PLAN = 30
450
451type PlanInput = { items?: { content?: string; status?: string }[]; advance?: boolean; done?: number[]; start?: number }
452
453const applyPlan = (prev: PlanStep[], input: PlanInput): PlanStep[] => {
454  let steps = prev
455  if (Array.isArray(input.items)) {
456    steps = input.items
457      .filter(i => typeof i?.content === 'string' && i.content.trim())
458      .slice(0, MAX_PLAN)
459      .map(i => ({
460        content: clip((i.content ?? '').trim(), 120),
461        status: i.status === 'completed' || i.status === 'in_progress' ? i.status : 'pending',
462      }))
463  }
464  if (input.advance) {
465    const at = steps.findIndex(st => st.status === 'in_progress')
466    steps = steps.map((st, i) => (i === at ? { ...st, status: 'completed' } : st))
467    const next = steps.findIndex((st, i) => i > at && st.status === 'pending')
468    if (next >= 0) steps = steps.map((st, i) => (i === next ? { ...st, status: 'in_progress' } : st))
469  }
470  if (Array.isArray(input.done)) {
471    const done = new Set(input.done.filter(n => Number.isInteger(n)).map(n => n - 1))
472    steps = steps.map((st, i) => (done.has(i) ? { ...st, status: 'completed' } : st))
473  }
474  if (Number.isInteger(input.start)) {
475    const at = (input.start as number) - 1
476    steps = steps.map((st, i) => (i === at ? { ...st, status: 'in_progress' } : st))
477  }
478  return steps
479}
480
481// Finished steps count whole, the one in progress half: a bar that moves as work does.
482const planProgress = (plan: PlanStep[] | undefined): number | null => {
483  if (!plan?.length) return null
484  const done = plan.filter(st => st.status === 'completed').length
485  const half = plan.some(st => st.status === 'in_progress') ? 0.5 : 0
486  return Math.min(1, (done + half) / plan.length)
487}
488
489// ---------------------------------------------------------------------------
490// Session card: where Claude runs, how full the context is, what it is connected to.
491
492// `mcp__<server>__<tool>`: the server is the middle part. A plugin's server reads
493// `plugin_<plugin>_<name>`; claude.ai connectors are bare UUIDs and count apart.
494const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-/i
495const serverOf = (tool: string): string | null => {
496  const m = /^mcp__(.+?)__/.exec(tool)
497  return m ? (m[1] ?? null) : null
498}
499const prettyServer = (s: string): string => {
500  const plugin = /^plugin_([^_]+(?:-[^_]+)*)_(.+)$/.exec(s)
501  return (plugin ? plugin[2] ?? s : s).replace(/[_-]+/g, ' ')
502}
503const connectorsOf = (tools: readonly string[]): string[] => {
504  const servers = [...new Set(tools.map(serverOf).filter((s): s is string => Boolean(s)))]
505  const named = servers.filter(s => !UUID.test(s)).map(prettyServer)
506  const linked = servers.length - named.length
507  return [...new Set(named)].sort().concat(linked ? [`${linked} claude.ai`] : [])
508}
509
510const ENV_EVERY_MS = 1000
511// One at a time, in order: two readings racing could count the same spend twice.
512let envQueue: Promise<unknown> = Promise.resolve()
513// When the last reading was taken, and whether a push came in since that waits for the tick.
514let envReadAt = -Infinity
515let isEnvStale = false
516function refreshEnv($: EngineInterface, isBaseline = false): Promise<void> {
517  isEnvStale = false
518  const run = envQueue.then(async () => {
519    envReadAt = await $.clock.now().catch(() => envReadAt)
520    return refreshEnvNow($, isBaseline)
521  })
522  envQueue = run.catch(() => undefined)
523  return run.catch(() => undefined)
524}
525// The session start's reading is the baseline (a resumed session's spend is not
526// today's again); every later rise is new spend, from $0 too.
527async function refreshEnvNow($: EngineInterface, isBaseline: boolean): Promise<void> {
528  const before = (await read($, env)).costUsd
529  const [cwd, model, surface, usage] = await Promise.all([
530    $.session.cwd().catch(() => ''),
531    $.session.model().catch(() => ''),
532    $.session.surface().catch(() => null),
533    $.session.usage().catch(() => null),
534  ])
535  const ctx = usage?.context
536  const window = ctx?.window ?? 0
537  const tokens = ctx?.tokens ?? 0
538  const pct = ctx?.percent ?? (window ? (tokens / window) * 100 : 0)
539  // Each model request asks again, mostly for the same readings: an equal one is not written.
540  const fresh = (prev: Env): Env => ({
541    ...prev,
542    cwd,
543    model,
544    surface: surface ?? '',
545    contextPct: Math.max(0, Math.min(100, Math.round(pct))),
546    contextTokens: tokens,
547    contextWindow: window,
548    costUsd: usage?.cost?.usd ?? prev.costUsd,
549    // A response without readings keeps the last ones rather than blanking the bars.
550    limits: usage?.rateLimits?.length ? usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })) : prev.limits ?? [],
551  })
552  const was = await read($, env)
553  if (!isSame(fresh(was), was)) await update($, env, fresh)
554  const cost = usage?.cost?.usd
555  if (isBaseline || typeof cost !== 'number') return
556  // The ledger starts over at /clear: after a drop, the whole new total is new spend.
557  const spent = cost >= before ? cost - before : cost
558  if (spent > 0) await bumpToday($, { costUsd: spent })
559}
560
561// A window whose reset time has passed reads empty until the next reply says otherwise.
562const liveEnv = (info: Env, at: number): Env => {
563  const isPast = (l: Env['limits'][number]) => Boolean(at && l.resetsAt && Date.parse(l.resetsAt) <= at)
564  const limits = info.limits ?? []
565  return limits.some(isPast) ? { ...info, limits: limits.map(l => (isPast(l) ? { ...l, percentUsed: 0, resetsAt: undefined } : l)) } : info
566}
567
568// ---------------------------------------------------------------------------
569// Memory: answer-style rules kept in `$.store` across sessions and added to every
570// system prompt, so no session has to remember to look them up.
571
572const MEMORY_PANE = 'crab-memory'
573const MEMORY_KEY = 'prefs'
574const MAX_RULES = 30
575const MAX_RULE = 500
576const DEFAULT_RULES = [
577  'Keep every answer short and skimmable: lead with the answer, then at most about 5 bullets or a few short lines. No long explanations, background or recaps unless asked. The user has ADHD.',
578]
579
580const cleanRules = (v: unknown): string[] =>
581  Array.isArray(v) ? v.filter((r): r is string => typeof r === 'string' && r.trim() !== '').map(r => clip(r.trim(), MAX_RULE)).slice(0, MAX_RULES) : []
582
583// Never set before means first run: seed the default rule. An empty list stays empty.
584async function loadRules($: EngineInterface): Promise<string[]> {
585  const stored = await $.store.get(MEMORY_KEY).catch(() => DEFAULT_RULES)
586  if (stored === undefined) {
587    await $.store.set(MEMORY_KEY, DEFAULT_RULES)
588    return [...DEFAULT_RULES]
589  }
590  return cleanRules(stored)
591}
592
593async function saveRules($: EngineInterface, rules: string[]): Promise<string[]> {
594  const clean = cleanRules(rules)
595  await $.store.set(MEMORY_KEY, clean)
596  await update($, memory, () => clean)
597  return clean
598}
599
600const memoryText = (rules: string[]): string =>
601  ['# How this user wants answers', 'Follow these in every reply, over any default style:', ...rules.map(r => `- ${r}`)].join('\n')
602
603const listRules = (rules: string[]): string =>
604  rules.length ? rules.map((r, i) => `${i + 1}. ${r}`).join('\n') : 'No rules saved. Add one with /prefs add <rule>.'
605
606const TOOL = 'mcp__crab-progress__progress'
607const STEP_TOOL = 'mcp__crab-progress__step'
608// Added to the end of each Mission Control worker's brief. Their system prompt words step
609// reporting as a condition ("if the step tool appears among your tools") and they
610// skip it even with the tool listed; the same request in the brief is followed.
611const STEP_BRIEF = `
612
613Progress reporting: before any other work, call \`${STEP_TOOL}\` with \`total\` (your plan in 3-8 steps) and \`done: 0\`; call it again with \`done\` and a few-word \`note\` as each step finishes. It only draws a progress bar.`
614const PANE = 'crab-agents'
615const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
616const TIERS: readonly string[] = ['fable', 'heavy', 'careful', 'medium', 'light']
617// The tools' schemas are advice to the model, not a check: what a call may ask
618// the band and the panel to draw is bounded here.
619const MAX_TASKS = 200
620const MAX_AFTER = 20
621const MAX_TITLE = 120
622let ACCENT = '#8f8cf4'
623let DONE = '#5fbf8f'
624
625type ProgressInput = {
626  title?: string
627  total?: number
628  done?: number
629  phase?: Phase
630  finished?: boolean
631  tasks?: { title?: string; tier?: string; after?: number[]; minutes?: number }[]
632  minutes?: number
633}
634
635// ---------------------------------------------------------------------------
636// Skins: every colour, the font, the card shape and the overlay effects come from
637// the active skin. Built-ins live here; more are JSON files in the person's
638// "Custom Claude Skins" folder, read at session start and by /skin reload.
639
640type SkinColors = {
641  accent: string
642  accent2: string
643  done: string
644  warn: string
645  alert: string
646  meter: string
647  text: string
648  subtext: string
649  muted: string
650  tile: string
651  track: string
652  line: string
653  grid: string
654}
655type Skin = {
656  id: string
657  name: string
658  description: string
659  font: string
660  corners: 'round' | 'chamfer'
661  /** A dark-only skin draws its own colours whatever the system scheme. */
662  dark: boolean
663  colors: SkinColors
664  /** The boss frame's ring, five stops around the card. */
665  boss: string[]
666  /** Colours handed to roles named in a task ("Designer: ..."). */
667  roles: string[]
668  /** The whole pane's background: a fill (empty: see-through) and its pattern. */
669  backdrop: { fill: string; pattern: 'dots' | 'lines' | 'stars' | 'none'; ink: string }
670  effects: { scanlines: boolean; glitch: boolean }
671  /** Restyle the chat's tool rows and spinner; off leaves the app's own drawing. */
672  chat: boolean
673  /** Spinner words while a turn thinks or waits ("Jacking in"). */
674  words: string[]
675  /** A themed verb per tool row ("Read" becomes "Scan"). */
676  toolWords: Record<string, string>
677  /** The panes' tab titles: the app draws the tab, the skin names it. */
678  tabs: { mission: string; skins: string }
679  /** Characters in place of the crabs: a key of CASTS ("runners", "corpo", "netrun"). */
680  cast?: string
681  /** Left out of the picker and the list until it is worn: an easter egg. */
682  hidden?: boolean
683}
684
685const CRAB_SKIN: Skin = {
686  id: 'crab-classic',
687  name: 'Crab Classic',
688  description: 'The original: soft violet, gold boss, rounded cards.',
689  font: "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
690  corners: 'round',
691  dark: false,
692  colors: {
693    accent: '#8f8cf4',
694    accent2: '#5EC8D8',
695    done: '#5fbf8f',
696    warn: '#E9B44C',
697    alert: '#E5675E',
698    meter: '#3B82F6',
699    text: '#ececec',
700    subtext: '#a8a8a4',
701    muted: '#9A9A96',
702    tile: '#262625',
703    track: '#2c2c2b',
704    line: '#333331',
705    grid: '#3a3a38',
706  },
707  boss: ['#F5C542', '#E76F51', '#B58BE5', '#5EC8D8', '#F5C542'],
708  roles: ['#E58FB5', '#5EC8D8', '#E9B44C', '#9CCB6B', '#B58BE5', '#E5875E'],
709  effects: { scanlines: false, glitch: false },
710  chat: true,
711  words: ['Scuttling', 'Pinching through', 'Clawing along', 'Sidestepping'],
712  toolWords: {},
713  backdrop: { fill: '', pattern: 'dots', ink: '' },
714  tabs: { mission: '🦀 Mission Control', skins: 'Skins' },
715}
716
717// Night City HUD: neon yellow, hot red, electric cyan on near-black, cut corners,
718// scanlines and a glitch. Rajdhani is the game's HUD face; Bahnschrift stands in on Windows.
719const CYBER_SKIN: Skin = {
720  id: 'cyberpunk-2077',
721  cast: 'runners',
722  name: 'Cyberpunk 2077',
723  description: 'Night City HUD: neon yellow, hot red, electric cyan, cut corners, scanlines.',
724  font: "Rajdhani,Orbitron,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
725  corners: 'chamfer',
726  dark: true,
727  colors: {
728    accent: '#FCEE0A',
729    accent2: '#00F0FF',
730    done: '#00FF9F',
731    warn: '#FF8A00',
732    alert: '#FF003C',
733    meter: '#00F0FF',
734    text: '#F3EFE0',
735    subtext: '#9FB4BA',
736    muted: '#74808A',
737    tile: '#0B0C10',
738    track: '#1A1D24',
739    line: '#2B2F38',
740    grid: '#1C2129',
741  },
742  boss: ['#FCEE0A', '#FF003C', '#B000FF', '#00F0FF', '#FCEE0A'],
743  roles: ['#FCEE0A', '#00F0FF', '#FF003C', '#FF5CF0', '#00FF9F', '#FF8A00'],
744  effects: { scanlines: true, glitch: true },
745  chat: true,
746  words: ['Jacking in', 'Breaching ICE', 'Netrunning', 'Overclocking', 'Uploading quickhack', 'Scanning Night City'],
747  toolWords: { Read: 'Scan', Edit: 'Patch', MultiEdit: 'Patch', Write: 'Burn', Bash: 'Exec', PowerShell: 'Exec', Grep: 'Trace', Glob: 'Index', Agent: 'Deploy', WebFetch: 'Jack in', WebSearch: 'Netsearch', ToolSearch: 'Load chrome' },
748  backdrop: { fill: '#050608', pattern: 'lines', ink: 'rgba(0,240,255,.07)' },
749  tabs: { mission: '◢ Mission Control', skins: '◢ Skins' },
750}
751
752// Arasaka: the corporation's own look. Blood red on black, steel grey, hard edges, no noise.
753const ARASAKA_SKIN: Skin = {
754  id: 'arasaka',
755  cast: 'corpo',
756  name: 'Arasaka',
757  description: 'Corporate steel: blood red on black, hard edges, no noise.',
758  font: "Rajdhani,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
759  corners: 'chamfer',
760  dark: true,
761  colors: {
762    accent: '#E3001B',
763    accent2: '#C9CED6',
764    done: '#3DDC97',
765    warn: '#FFB000',
766    alert: '#FF1E3C',
767    meter: '#E3001B',
768    text: '#F2F2F2',
769    subtext: '#9A9EA6',
770    muted: '#7A7E86',
771    tile: '#0A0A0B',
772    track: '#1B1C1F',
773    line: '#2A2C31',
774    grid: '#18191C',
775  },
776  boss: ['#E3001B', '#FFFFFF', '#7A0010', '#C9CED6', '#E3001B'],
777  roles: ['#E3001B', '#C9CED6', '#FFFFFF', '#FF5A6E', '#8A9099', '#B3001A'],
778  effects: { scanlines: false, glitch: false },
779  chat: true,
780  words: ['Processing', 'Securing assets', 'Auditing', 'Executing protocol', 'Consulting the board'],
781  toolWords: { Read: 'Audit', Edit: 'Amend', MultiEdit: 'Amend', Write: 'File', Bash: 'Execute', PowerShell: 'Execute', Grep: 'Investigate', Glob: 'Catalogue', Agent: 'Dispatch', WebFetch: 'Retrieve', WebSearch: 'Surveil' },
782  backdrop: { fill: '#060607', pattern: 'lines', ink: 'rgba(227,0,27,.07)' },
783  tabs: { mission: 'Arasaka · Mission Control', skins: 'Arasaka · Skins' },
784}
785
786// Lucy (Edgerunners): the moonlit netrunner. Rose-pink and cyan on deep violet, soft panels.
787const LUCY_SKIN: Skin = {
788  id: 'lucy-edgerunners',
789  cast: 'netrun',
790  name: 'Lucy · Edgerunners',
791  description: 'Moonlit netrunner: rose-pink and cyan on deep violet, soft glow.',
792  font: "Rajdhani,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
793  corners: 'round',
794  dark: true,
795  colors: {
796    accent: '#FF5CF0',
797    accent2: '#5CE1FF',
798    done: '#7DFFCF',
799    warn: '#FFD166',
800    alert: '#FF3D7F',
801    meter: '#B79CFF',
802    text: '#F4ECFF',
803    subtext: '#B9A9D9',
804    muted: '#8A7DB0',
805    tile: '#100B1A',
806    track: '#1E1530',
807    line: '#2C2244',
808    grid: '#1A1428',
809  },
810  boss: ['#FF5CF0', '#5CE1FF', '#B79CFF', '#FFFFFF', '#FF5CF0'],
811  roles: ['#FF5CF0', '#5CE1FF', '#B79CFF', '#7DFFCF', '#FFD166', '#FF8AD8'],
812  effects: { scanlines: true, glitch: true },
813  chat: true,
814  words: ['Diving', 'Drifting through the Net', 'Moonwalking', 'Netrunning', 'Chasing the moon'],
815  toolWords: { Read: 'Dive', Edit: 'Rewrite', MultiEdit: 'Rewrite', Write: 'Leave a trace', Bash: 'Run', PowerShell: 'Run', Grep: 'Search', Glob: 'Map', Agent: 'Call in', WebFetch: 'Pull', WebSearch: 'Search the Net' },
816  backdrop: { fill: '#0A0712', pattern: 'stars', ink: 'rgba(244,236,255,.35)' },
817  tabs: { mission: '☾ Mission Control', skins: '☾ Skins' },
818}
819
820// Default: no skin at all. Neutral greys, the system font, rounded cards, no effects,
821// following the system's light or dark scheme.
822const DEFAULT_SKIN: Skin = {
823  id: 'default',
824  name: 'Default',
825  description: 'No skin: plain greys, system font, no effects.',
826  font: "-apple-system,BlinkMacSystemFont,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
827  corners: 'round',
828  dark: false,
829  colors: {
830    accent: '#9A9A96',
831    accent2: '#B4B4B0',
832    done: '#6FA287',
833    warn: '#C9A35A',
834    alert: '#C46A62',
835    meter: '#8C8C88',
836    text: '#ececec',
837    subtext: '#a8a8a4',
838    muted: '#9A9A96',
839    tile: '#262625',
840    track: '#2c2c2b',
841    line: '#333331',
842    grid: '#3a3a38',
843  },
844  boss: ['#B4B4B0', '#8C8C88', '#6E6E6A', '#B4B4B0', '#B4B4B0'],
845  roles: ['#A7A7A3', '#8E9AA6', '#A69A8E', '#8EA696', '#9A8EA6', '#A6A08E'],
846  effects: { scanlines: false, glitch: false },
847  chat: false,
848  words: [],
849  toolWords: {},
850  backdrop: { fill: '', pattern: 'dots', ink: '' },
851  tabs: { mission: 'Mission Control', skins: 'Skins' },
852}
853
854// SAMURAI: the secret skin. Johnny's band, red on black, a stage in the skyline.
855// Not in the picker or the list: `/skin samurai` finds it.
856const SAMURAI_SKIN: Skin = {
857  id: 'samurai',
858  cast: 'samurai',
859  hidden: true,
860  name: 'SAMURAI',
861  description: "Johnny's band: red on black, stage lights, never fade away.",
862  font: "Rajdhani,Orbitron,-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Bahnschrift,'Segoe UI',Ubuntu,Cantarell,'Noto Sans','Liberation Sans',sans-serif",
863  corners: 'chamfer',
864  dark: true,
865  colors: {
866    accent: '#E10600',
867    accent2: '#FFD400',
868    done: '#FFD400',
869    warn: '#FF8A00',
870    alert: '#FF2E3E',
871    meter: '#D9D9D9',
872    text: '#F4F4F4',
873    subtext: '#B3A5A5',
874    muted: '#8A7878',
875    tile: '#0E0909',
876    track: '#241515',
877    line: '#3A2020',
878    grid: '#1C1010',
879  },
880  boss: ['#E10600', '#FFD400', '#D9D9D9', '#E10600', '#FFD400'],
881  roles: ['#E10600', '#FFD400', '#D9D9D9', '#FF8A00', '#FF5C5C', '#B3A5A5'],
882  effects: { scanlines: true, glitch: true },
883  chat: true,
884  words: ['Never fading away', 'Shredding', 'Riffing'],
885  toolWords: {},
886  backdrop: { fill: '#080505', pattern: 'lines', ink: 'rgba(225,6,0,.25)' },
887  tabs: { mission: '♪ Mission Control', skins: '♪ Skins' },
888}
889
890const BUILT_IN_SKINS: Skin[] = [DEFAULT_SKIN, CRAB_SKIN, CYBER_SKIN, ARASAKA_SKIN, LUCY_SKIN, SAMURAI_SKIN]
891
892// Only plain colour and font values reach the drawings: a skin file cannot
893// smuggle markup into an Svg.
894const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\))$/i
895const cleanColor = (v: unknown, fallback: string): string => (typeof v === 'string' && COLOR_RE.test(v.trim()) ? v.trim() : fallback)
896// A JSON file's text, without the byte-order mark Windows editors may write.
897const parseJson = (text: unknown): unknown => JSON.parse(String(text).replace(/^\uFEFF/, ''))
898
899const cleanFont = (v: unknown, fallback: string): string =>
900  typeof v === 'string' && v.trim() && !/["<>&;{}]/.test(v) ? v.trim().slice(0, 200) : fallback
901
902const readSkin = (raw: unknown, fallbackId: string): Skin | null => {
903  if (!raw || typeof raw !== 'object') return null
904  const r = raw as Record<string, unknown>
905  const base = CRAB_SKIN
906  const colors = (r.colors ?? {}) as Record<string, unknown>
907  const effects = (r.effects ?? {}) as Record<string, unknown>
908  const id = typeof r.id === 'string' && /^[a-z0-9-]{1,40}$/.test(r.id) ? r.id : fallbackId
909  const list = (v: unknown, fb: string[], n: number) =>
910    Array.isArray(v) && v.length ? v.slice(0, n).map((c, i) => cleanColor(c, fb[i % fb.length] ?? '#888888')) : fb
911  return {
912    id,
913    name: typeof r.name === 'string' ? clip(r.name.trim(), 40) : id,
914    description: typeof r.description === 'string' ? clip(r.description.trim(), 120) : '',
915    font: cleanFont(r.font, base.font),
916    corners: r.corners === 'chamfer' ? 'chamfer' : 'round',
917    dark: r.dark === true,
918    colors: Object.fromEntries(Object.entries(base.colors).map(([k, v]) => [k, cleanColor(colors[k], v)])) as SkinColors,
919    boss: list(r.boss, base.boss, 5),
920    roles: list(r.roles, base.roles, 12),
921    effects: { scanlines: effects.scanlines === true, glitch: effects.glitch === true },
922    backdrop: (() => {
923      const bd = (r.backdrop ?? {}) as Record<string, unknown>
924      const pattern = bd.pattern === 'lines' || bd.pattern === 'stars' || bd.pattern === 'none' ? bd.pattern : 'dots'
925      return { fill: bd.fill ? cleanColor(bd.fill, '') : '', pattern, ink: bd.ink ? cleanColor(bd.ink, '') : '' } as Skin['backdrop']
926    })(),
927    tabs: (() => {
928      const tb = (r.tabs ?? {}) as Record<string, unknown>
929      const name = (v: unknown, fb: string) => (typeof v === 'string' && v.trim() ? clip(v.trim(), 40) : fb)
930      return { mission: name(tb.mission, 'Mission Control'), skins: name(tb.skins, 'Skins') }
931    })(),
932    cast: typeof r.cast === 'string' && Object.hasOwn(CASTS, r.cast) ? r.cast : undefined,
933    chat: r.chat !== false,
934    words: Array.isArray(r.words) ? r.words.filter((w): w is string => typeof w === 'string' && w.trim() !== '').map(w => clip(w.trim(), 32)).slice(0, 20) : base.words,
935    toolWords: Object.fromEntries(
936      Object.entries((r.toolWords ?? {}) as Record<string, unknown>)
937        .filter(([k, v]) => /^[A-Za-z_]{1,40}$/.test(k) && typeof v === 'string' && v.trim())
938        .map(([k, v]) => [k, clip(String(v).trim(), 20)]),
939    ),
940  }
941}
942
943const skin = atom({ plugin: 'crab-progress', key: 'skin' } as const, CRAB_SKIN.id)
944// Settings the pane's checkboxes change: kept in the store, mirrored here to redraw.
945const settings = atom({ plugin: 'crab-progress', key: 'settings' } as const, { openOnStart: true, crewMode: true, currency: 'USD', sounds: true })
946const cred = atom({ plugin: 'crab-progress', key: 'cred' } as const, BLANK_CRED)
947const timeline = atom({ plugin: 'crab-progress', key: 'timeline' } as const, [] as Beat[])
948const bdPick = atom({ plugin: 'crab-progress', key: 'bdPick' } as const, -1)
949const readSettings = async ($: EngineInterface) => {
950  const saved = (await $.store.get('currency').catch(() => undefined)) as { code?: string; rate?: number } | undefined
951  // Nothing chosen yet is US dollars. A store written by hand or by an older build may
952  // hold anything: a bad code is dollars too.
953  CURRENCY = currencyOf(typeof saved?.code === 'string' ? saved.code : 'USD', typeof saved?.rate === 'number' ? saved.rate : undefined)
954  return {
955    openOnStart: (await $.store.get('openOnStart').catch(() => undefined)) !== false,
956    crewMode: (await readAutoCrew($)).on,
957    currency: CURRENCY.code,
958    sounds: (await $.store.get('sounds').catch(() => undefined)) !== false,
959  }
960}
961
962// The pane's two folds, kept in the store so a reload or a new session opens the
963// list as the person left it: long by default.
964const PANE_VIEW = 'paneView'
965const readPaneView = async ($: EngineInterface): Promise<Pick<Panel, 'isCompact' | 'isDoneCollapsed'>> => {
966  const v = (await $.store.get(PANE_VIEW).catch(() => undefined)) as Partial<Panel> | undefined
967  return { isCompact: v?.isCompact === true, isDoneCollapsed: v?.isDoneCollapsed === true }
968}
969const setPaneView = async ($: EngineInterface, change: (p: Pick<Panel, 'isCompact' | 'isDoneCollapsed'>) => Partial<Panel>): Promise<void> => {
970  const prev = await read($, panel)
971  const next = { isCompact: prev.isCompact, isDoneCollapsed: prev.isDoneCollapsed, ...change(prev) }
972  await update($, panel, p => ({ ...p, ...next }))
973  await $.store.set(PANE_VIEW, next).catch(() => undefined)
974}
975
976// A sound, when the person has sounds on. Never in the way: a failure is silent.
977async function play($: EngineInterface, asset: string): Promise<void> {
978  if (!(await read($, settings)).sounds) return
979  await $.audio.play({ asset }).catch(() => undefined)
980}
981
982// Street Cred: change the counts, award what they earn, cheer a level or a badge.
983// The store's read-modify-write is not atomic, so the bumps run one at a time: five
984// agents spawned at once count five. `quiet` leaves the chime to the caller's own.
985// Resolves true when the bump earned a level or a badge.
986let credQueue: Promise<unknown> = Promise.resolve()
987function bumpCred($: EngineInterface, change: (c: Cred) => Cred, quiet = false): Promise<boolean> {
988  const run = credQueue.then(() => bumpCredNow($, change, quiet))
989  credQueue = run.catch(() => false)
990  return run
991}
992async function bumpCredNow($: EngineInterface, change: (c: Cred) => Cred, quiet: boolean): Promise<boolean> {
993  let saved: unknown
994  try {
995    saved = await $.store.get('cred')
996  } catch {
997    return false // An unreadable store is left alone rather than overwritten with a blank.
998  }
999  const before = cleanCred(saved)
1000  const { cred: next, fresh } = award(change(before))
1001  if (next === before) return false
1002  await $.store.set('cred', next).catch(() => undefined)
1003  await update($, cred, () => next)
1004  const was = levelOf(before.xp)
1005  const now = levelOf(next.xp)
1006  // Street Cred is a cyberpunk add-on: other skins keep counting quietly, with no cheer.
1007  if (!eggOf()) return false
1008  if (now > was) $.ui.toast(`${eggOf() === 'samurai' ? 'ROCKERBOY CRED' : 'STREET CRED'} ↑ ${now} · ${eggOf() === 'samurai' ? rockerRankOf(now) : rankOf(now)}`)
1009  for (const b of fresh) $.ui.toast(`${b.icon} Badge unlocked: ${b.name}`)
1010  const isCheer = now > was || fresh.length > 0
1011  if (!quiet && isCheer) void play($, `${ADDON}/assets/levelup.wav`)
1012  return isCheer
1013}
1014// The next currency in the list; a custom rate is kept only for the one it was set on.
1015const setCurrency = async ($: EngineInterface, c: Currency) => {
1016  CURRENCY = c
1017  await $.store.set('currency', { code: c.code, rate: c.rate }).catch(() => undefined)
1018  await update($, settings, prev => ({ ...prev, currency: c.code }))
1019}
1020const SKINS = new Map<string, Skin>(BUILT_IN_SKINS.map(k => [k.id, k]))
1021// The Cyberpunk Skins add-on: its skins (and their sounds) ship in addons/cyberpunk/.
1022// Without that folder Mission Control is the core edition: default and Crab Classic.
1023const ADDON = 'addons/cyberpunk'
1024const ADDON_URL = 'https://github.com/Chinchuan24/MIssonControl-Skins#-choose-your-edition'
1025const ADDON_CASTS = new Set(['runners', 'netrun', 'corpo', 'samurai'])
1026let HAS_CYBER = false
1027const checkAddon = async ($: EngineInterface): Promise<boolean> => {
1028  HAS_CYBER = await $.fs.exists(`${$.plugin.root}/${ADDON}/addon.json`).catch(() => false)
1029  return HAS_CYBER
1030}
1031const isAddonSkin = (k: Skin): boolean => ADDON_CASTS.has(String(k.cast ?? ''))
1032const canWear = (k: Skin): boolean => HAS_CYBER || !isAddonSkin(k)
1033const ADDON_PITCH = `Cyberpunk Skins add-on: Cyberpunk 2077, Lucy · Edgerunners and Arasaka. Get it: ${ADDON_URL}`
1034let SKIN: Skin = CRAB_SKIN
1035const SKINS_FOLDER = 'Custom Claude Skins'
1036
1037// The folder: remembered once found, else "Custom Claude Skins" in this session's
1038// folder, else the example skins shipped with the mod (never remembered, so a
1039// folder of your own found later still wins).
1040async function loadSkins($: EngineInterface): Promise<{ folder: string; loaded: number; skipped: string[] }> {
1041  const remembered = await $.store.get('skinsFolder').catch(() => undefined)
1042  const cwd = await $.session.cwd().catch(() => '')
1043  const bundled = `${$.plugin.root}/skins`
1044  const candidates = [typeof remembered === 'string' ? remembered : '', cwd ? `${cwd.replace(/[\\/]+$/, '')}/${SKINS_FOLDER}` : '', bundled].filter(Boolean)
1045  for (const folder of candidates) {
1046    if (!(await $.fs.exists(folder).catch(() => false))) continue
1047    if (folder !== remembered && folder !== bundled) await $.store.set('skinsFolder', folder).catch(() => undefined)
1048    let loaded = 0
1049    const skipped: string[] = []
1050    const files = await $.fs.list(folder).catch((err: unknown) => {
1051      skipped.push(`folder: ${String(err).slice(0, 80)}`)
1052      return []
1053    })
1054    for (const f of files) {
1055      // Dotfiles are skipped: macOS leaves `._name.json` beside files copied from other disks.
1056      if (f.kind !== 'file' || f.name.startsWith('.') || !f.name.toLowerCase().endsWith('.json')) continue
1057      try {
1058        const k = readSkin(parseJson(await $.fs.read(`${folder}/${f.name}`)), f.name.replace(/\.json$/i, '').toLowerCase())
1059        if (k) {
1060          SKINS.set(k.id, k)
1061          loaded += 1
1062        }
1063      } catch (err) {
1064        // A broken file is skipped, and /skin reload says why.
1065        skipped.push(`${f.name}: ${String(err).slice(0, 80)}`)
1066      }
1067    }
1068    return { folder, loaded, skipped }
1069  }
1070  return { folder: '', loaded: 0, skipped: [] }
1071}
1072
1073function applySkin(k: Skin): void {
1074  SKIN = k
1075  ACCENT = k.colors.accent
1076  DONE = k.colors.done
1077  FONT = k.font
1078  TIDE = k.colors.accent2
1079  KELP = k.colors.done
1080  ROLE_PALETTE = k.roles
1081  MOOD_COLOR = [k.colors.done, k.colors.warn, k.colors.alert]
1082  PANE_CSS = paneCss(k)
1083}
1084
1085// An open pane behind another tab is not raised by a second ui.open: close it, then open it.
1086async function showPane($: EngineInterface, id: string, title: string): ReturnType<EngineInterface['ui']['open']> {
1087  const all = await $.ui.panes().catch(() => [])
1088  const row = all.find(p => p.id === id)
1089  if (row && !row.isShown) await $.ui.close({ id }).catch(() => undefined)
1090  return $.ui.open({ id, title })
1091}
1092
1093// What the desktop app counts against its 262,144-character limit for one drawing:
1094// every text, an Svg's source and alt, each other element's prop names and string
1095// values. Past the limit it draws a blank pane; DRAW_MAX leaves room for its own count.
1096const DRAW_MAX = 220_000
1097const drawnChars = (n: unknown): number => {
1098  if (typeof n === 'string') return n.length
1099  if (Array.isArray(n)) return n.reduce((sum: number, c) => sum + drawnChars(c), 0)
1100  if (!n || typeof n !== 'object') return 0
1101  const el = n as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
1102  const props = el.props ?? {}
1103  const own =
1104    el.type === 'Svg'
1105      ? String(props.source ?? '').length + String(props.alt ?? '').length
1106      : Object.entries(props).reduce((sum, [k, v]) => (k === 'children' ? sum : sum + k.length + (typeof v === 'string' ? v.length : 0)), 0)
1107  return own + drawnChars(el.children ?? props.children)
1108}
1109
1110// After a switch, the open panes take the new skin's tab titles.
1111async function retitle($: EngineInterface): Promise<void> {
1112  const open = await $.ui.panes().catch(() => [])
1113  for (const p of open) {
1114    if (p.id === PANE) await $.ui.open({ id: PANE, title: SKIN.tabs.mission }).catch(() => undefined)
1115    if (p.id === SKINS_PANE) await $.ui.open({ id: SKINS_PANE, title: SKIN.tabs.skins }).catch(() => undefined)
1116  }
1117}
1118
1119// Every drawing calls this first: it follows the skin atom, so a switch redraws all.
1120async function useSkin($: EngineInterface): Promise<Skin> {
1121  const id = await read($, skin)
1122  if (id !== SKIN.id) applySkin(SKINS.get(id) ?? CRAB_SKIN)
1123  return SKIN
1124}
1125
1126// ---------------------------------------------------------------------------
1127// Language: the `language` option, else Claude Code's `language` setting, else the
1128// process locale; English when nothing says Hebrew or Russian.
1129
1130type Lang = 'en' | 'ru' | 'he'
1131
1132const STRINGS = {
1133  en: {
1134    pane: 'Mission Control',
1135    cost: 'Cost',
1136    tokens: 'Tokens',
1137    time: 'Time',
1138    collapse: 'Collapse',
1139    expand: 'Expand',
1140    running: 'Running',
1141    finished: 'Finished',
1142    planned: 'Planned',
1143    empty: 'No subagents yet.',
1144    round: 'round',
1145    failed: 'error',
1146    stopped: 'stopped',
1147    after: 'after',
1148    tokensWord: 'tokens',
1149    agentsCount: 'agents',
1150    isRunning: 'running',
1151    isFinished: 'finished',
1152    isPlanned: 'planned',
1153    opened: 'Mission Control opened.',
1154    closed: 'Mission Control closed.',
1155    done: 'Done',
1156    plan: 'Plan',
1157    design: 'Design',
1158    tasks: 'Tasks',
1159    review: 'Review',
1160    busy: 'running',
1161    idle: 'Idle',
1162    working: 'Working…',
1163    ctx: 'ctx',
1164    bgTitle: 'Background',
1165    bgKind: { shell: 'Shell', subagent: 'Agent', monitor: 'Monitor', workflow: 'Workflow', scheduled: 'Scheduled' },
1166    bgStatus: { running: 'running', pending: 'pending', repeats: 'repeats', once: 'once', released: 'released' },
1167    bgMore: 'more',
1168    bgBand: 'in the background',
1169    bgWait: 'Waiting',
1170    bgWaiting: (n: number): string => `Waiting on ${n} background ${n === 1 ? 'task' : 'tasks'}`,
1171    bgAgo: (min: number): string => (min < 1 ? 'updated just now' : min < 60 ? `updated ${min} min ago` : `updated ${Math.floor(min / 60)} h ago`),
1172    tier: { light: 'light', medium: 'medium', careful: 'careful', heavy: 'heavy', fable: 'fable', other: 'other' },
1173    effort: { low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' },
1174  },
1175  ru: {
1176    pane: 'Центр управления',
1177    cost: 'Стоимость',
1178    tokens: 'Токены',
1179    time: 'Время',
1180    collapse: 'Свернуть',
1181    expand: 'Развернуть',
1182    running: 'Работают',
1183    finished: 'Завершены',
1184    planned: 'Запланированы',
1185    empty: 'Субагентов пока нет.',
1186    round: 'раунд',
1187    failed: 'ошибка',
1188    stopped: 'остановлен',
1189    after: 'после',
1190    tokensWord: 'токенов',
1191    agentsCount: 'агентов',
1192    isRunning: 'работает',
1193    isFinished: 'завершён',
1194    isPlanned: 'запланирована',
1195    opened: 'Центр управления открыт.',
1196    closed: 'Центр управления закрыт.',
1197    done: 'Готово',
1198    plan: 'План',
1199    design: 'Дизайн',
1200    tasks: 'Задачи',
hooks/cast.ts 1208 lines
1// Casts: pixel characters a skin can put in place of the crabs.
2//
3// Drawn in one style: no black outline, soft colour blocks, a small faceless head,
4// realistic proportions about 46 pixels tall. Generated as pixel-art sheets, then
5// cut out, shrunk to the grid and quantised (scripts kept outside the mod).
6// The street crew and the corporation are fan homages to Cyberpunk: Edgerunners
7// and Cyberpunk 2077 (CD PROJEKT RED / Studio Trigger). No logos are drawn.
8//
9// A sprite is rows of letters, one per pixel; `.` is empty. `t` takes the tint it
10// is drawn with (its role's colour). Letters named in `cls` move on their own:
11// `hair` sways, `vis` (a visor, a screen, a cyber-eye) flickers, `led` pulses.
12// The rows under the gap between the legs step left and right while it works.
13
14export type Sprite = {
15  rows: string[]
16  pal: Record<string, string>
17  cls?: Record<string, 'hair' | 'vis' | 'led'>
18  /** Hovers instead of walking. */
19  float?: boolean
20}
21
22export type Cast = {
23  name: string
24  /** What the band calls the boss in place of "Claude". */
25  bossName: string
26  boss: Sprite
27  /** The main characters, by the crab costume a role would have worn. */
28  roles: Record<string, Sprite>
29  /** The workers: one is picked per role, so a role's agents look alike. */
30  crew: Sprite[]
31}
32
33const AGENT: Sprite = {
34  rows: [
35    "......vqq.......",
36    ".....qqqqq......",
37    ".....qququq.....",
38    ".....qqqqut.....",
39    ".....qaacl......",
40    ".....eaaak......",
41    "....uvaaavq.....",
42    "....ulqqqluq....",
43    "....quqqqquq....",
44    "..qhquuquqqqq...",
45    "..uuuudddquqqu..",
46    ".qjqhqddduhqqj..",
47    ".juqqqsdsuhquj..",
48    ".huqquuhhhqquu..",
49    ".huvqqsssuqtuh..",
50    "uqqvqqpppqutqu..",
51    "uhhqqqq..qqqquu.",
52    ".qhhqqq..qqvhhq.",
53    "..qqqqqqqqqqhh..",
54    "...tqqqqqvqquq..",
55    "...vvuqttsvuq...",
56    "...qqvqqqqtqu...",
57    "...uuuqqqqsqt...",
58    "..qhuuuquroqq...",
59    "..qhqquvqqidq...",
60    "..uhqquthqugq...",
61    "..uuqqqvqqqqqq..",
62    "..ufqqqvvvqqvq..",
63    ".uuq..qvq..qqs..",
64    ".uuq..vqv..qqqd.",
65    ".uuq..vqv..ufqq.",
66    ".unqqqvqvquqtutd",
67    ".unnqqvqqqqqtuqt",
68    "uhthqqqqqqquthqq",
69    "uhqqqqqqqquqvhq.",
70    ".uquuqqhvquuq...",
71    "..uuu....qhuu...",
72    "..quu.....uuu...",
73    "..qqq.....qqq...",
74    "..tqt.....ttt...",
75    "..qqt.....qqv...",
76    "..qq.......qt...",
77    "..qq.......qu...",
78    ".tqq.......qt...",
79    ".uuv.......quuq.",
80    "qqqt.......mqqb.",
81  ],
82  pal: { a: '#FECDAF', b: '#0F0F15', c: '#E51C28', d: '#52505D', e: '#D38971', f: '#981A28', g: '#51212C', h: '#272731', i: '#683243', j: '#C4232E', k: '#4D3C43', l: '#25141C', m: '#58121D', n: '#AF1F2D', o: '#3A212A', p: '#45444F', q: '#1E1E27', r: '#553443', s: '#4B4B56', t: '#14141C', u: '#25212B', v: '#1A1922' },
83  cls: { c: 'vis' },
84}
85
86const CHOOM: Sprite = {
87  rows: [
88    ".............",
89    ".............",
90    ".............",
91    ".............",
92    "...kkkkkvvv..",
93    "...vbrrrrbv..",
94    "..vvrarrrbvt.",
95    "..vvrarrabvd.",
96    "..vvrrrrrbkc.",
97    "...vbrrrrbv..",
98    "...vvvvuvv...",
99    "...lsliise...",
100    "..hlffsllst..",
101    ".thlffnffitt.",
102    ".gclffffflhq.",
103    ".dqlfffffetd.",
104    "ccqlfffflechj",
105    "ddcsiffliecdh",
106    "cj..ullpuuukc",
107    ".....uuuu....",
108    ".............",
109    ".............",
110    ".............",
111    "...mu...hh...",
112    "...vv...vv...",
113    "...uu...uu...",
114    "..ooo...ogo..",
115    "..hmc...cmh..",
116  ],
117  pal: { a: '#43F8FA', b: '#1B2038', c: '#7F8298', d: '#B8BCC8', e: '#2B5999', f: '#4A91DC', g: '#3F445B', h: '#9A9EB3', i: '#3B73B6', j: '#656B84', k: '#515672', l: '#4382C9', m: '#A8ADBC', n: '#34EBF6', o: '#434B69', p: '#3261A3', q: '#878BA0', r: '#1C2544', s: '#336BB2', t: '#B0B3C2', u: '#23283A', v: '#4B506C' },
118  cls: { a: 'vis', n: 'vis' },
119}
120
121const CYBORG: Sprite = {
122  rows: [
123    "......ppp.......",
124    ".....ppppi......",
125    ".....ppppui.....",
126    ".....bfmilq.....",
127    ".....vffluq.....",
128    ".....ffffi......",
129    ".....pbffp......",
130    ".....jbbjbp.....",
131    ".....pppppppnu..",
132    ".....pp....ikkl.",
133    "......p....plkt.",
134    "......ppp..ppts.",
135    "......ppp..puak.",
136    ".....pjpppppiuu.",
137    "..ppppppppjppio.",
138    "..pppppjjjjjjqpu",
139    "ffffppppjppppsnk",
140    "ffffpppddppppinu",
141    "fffpppppdddrjsno",
142    "jffppppppppbppns",
143    "pp..ppj..ppppppq",
144    "pj..jjj......nku",
145    "pj....ppp....nni",
146    "pbbp..ppppppppp.",
147    ".fppjjjpppkiiig.",
148    "..ppjpp..uuku...",
149    "..jpppp..siul...",
150    "..jppjp..iiis...",
151    "..ppjjj..qiis...",
152    "..ppppp..olnl...",
153    ".....pp...unk...",
154    ".....p....qtt...",
155    "...pjp....stc...",
156    "...ppp....skcu..",
157    "....jj....skks..",
158    "....pp....tkk...",
159    "..pjjp.....kk...",
160    "..pjjj.....ou...",
161    "...ppp.....lk...",
162    "...pp......sk...",
163    "...pp......ppe..",
164    "...pp......ckk..",
165    "..pjp......ppp..",
166    ".jjpp......pjjp.",
167    "pojp.......pjhp.",
168  ],
169  pal: { a: '#F8F7F7', b: '#0C0A0E', c: '#807D8B', d: '#F5C642', e: '#9E0F1C', f: '#FCC3A4', g: '#CF7F6A', h: '#4D4E59', i: '#B0AEB7', j: '#282932', k: '#D1CFD3', l: '#9995A3', m: '#DB9B83', n: '#E4E3E5', o: '#6D697D', p: '#1A1A20', q: '#5A576D', r: '#E8B53B', s: '#8B8897', t: '#A3A1AC', u: '#C7C4CB', v: '#F6B79A' },
170  cls: { d: 'vis', r: 'vis' },
171}
172
173const DAVID: Sprite = {
174  rows: [
175    "...............",
176    ".....pppppp....",
177    ".....pppppp....",
178    ".....epppe.....",
179    ".....efpfe.....",
180    ".....fffff.....",
181    ".....fffff.....",
182    "....kddffdk....",
183    "...nllsosllu...",
184    "...nnlbfblnv...",
185    "...nibbbbbvnn..",
186    "..nninbbbninn..",
187    "..cttcbbbcttjn.",
188    ".hjtttbbbtttjn.",
189    ".jntttbbbtttrt.",
190    "ntjitnbbbntijtn",
191    "njiinhbbbjrvirr",
192    "nncvnnbbbnnvnnn",
193    "nnnvunbbbnnvnnn",
194    ".nnvttbbbttvnn.",
195    ".nnttnbbbnttcn.",
196    "..vtrnbbbnntm..",
197    "..jnnnbbbnnur..",
198    "..nccnqqqnccc..",
199    "....qgggggg....",
200    "....ggggggg....",
201    "....gggqggg....",
202    "...gggggggg....",
203    "...gggqqqggg...",
204    "..gggggqgggg...",
205    "..gggggggggq...",
206    "..gggg..ggg....",
207    "...ggg..ggg....",
208    "...ggg..ggg....",
209    "...ggg..gggg...",
210    "..gggg...gggg..",
211    "..ggg....gggg..",
212    "..ggq...ggggg..",
213    ".gggg...ggggq..",
214    ".gggg...gqgg...",
215    "..gggg..qggq...",
216    "..gggg...ggg...",
217    "..grrr...grr...",
218    "..aari...rraj..",
219    "..aarj...jraa..",
220    "..jij.....jij..",
221  ],
222  pal: { a: '#FAFAFA', b: '#1C1C21', c: '#E6B20C', d: '#4492DA', e: '#9C8174', f: '#F3BB99', g: '#35466A', h: '#F6D954', i: '#AEACAE', j: '#D4D4D4', k: '#C8B66C', l: '#5BBEF6', m: '#C48C24', n: '#FBD427', o: '#C88369', p: '#452619', q: '#202C45', r: '#CBC5B5', s: '#DB9B7B', t: '#E8E7E7', u: '#D0C388', v: '#D49B0F' },
223  cls: { d: 'vis', l: 'vis', v: 'vis', c: 'vis', h: 'vis' },
224}
225
226const DORIO: Sprite = {
227  rows: [
228    "..........jj........",
229    "........jjjjj.......",
230    "........jjjjj.......",
231    "........jhhhj.......",
232    "........jhhhj.......",
233    "........jhhhi.......",
234    ".......cuddkiq......",
235    ".......qosssqc......",
236    ".......chhhhhv......",
237    ".......chhhhhcv.....",
238    ".......chhhhhcv.....",
239    "........hhhhh.......",
240    "........sshso.......",
241    "........dhshs.......",
242    "..qq....sssss.......",
243    ".btr....sssss...rnl.",
244    ".lee....shsss...egl.",
245    ".brr....agmaa...rlbl",
246    "rlrt............trlr",
247    "rlr..............rrr",
248    "llt.....avva.....frr",
249    "rl......vaaa......rl",
250    "rl..hhhhaaashhhh..lr",
251    "rlr.hhhhs..shhhh.llr",
252    "rer.srrrs..ssrrh.rmr",
253    ".ll.frrro..sorrf.ll.",
254    ".rm.frrro..orrrf.er.",
255    "....frrr....rrrf....",
256    "....rfrr....rrrr....",
257    "....pppn....nree....",
258    "....gfg......gfg....",
259    "....gmg......mgg....",
260    "....rme......egr....",
261    "...rrpf......terr...",
262    "...rrff......frrf...",
263    "...trrf......frrf...",
264    "...tftn......ttft...",
265    "...tft........tft...",
266    "...tft........tft...",
267    "...ggm........ggm...",
268    "...gg.........ggm...",
269    "...rr.........ffe...",
270    "...ntg........ftt...",
271    "...ffm........nff...",
272    "..ftfm........mff...",
273    "..ffte........fftt..",
274    "..gnne........nrpg..",
275    "..gnga........vnmg..",
276  ],
277  pal: { a: '#1C1C24', b: '#F8F8F7', c: '#4D8CC5', d: '#D1826D', e: '#565867', f: '#BCBABC', g: '#888790', h: '#F4BA93', i: '#265B99', j: '#F7E4BC', k: '#1A3259', l: '#DCDCDC', m: '#6F6E7D', n: '#A09FA5', o: '#CD9E88', p: '#404252', q: '#3C75AD', r: '#CDCCCB', s: '#E5A988', t: '#ADADB1', u: '#193B6D', v: '#272732' },
278}
279
280const DRONE: Sprite = {
281  rows: [
282    ".eips................ffll",
283    ".bb..................bb..",
284    ".ku.......ddv........kq..",
285    ".uu.......ooj........uuu.",
286    "gsuugqg...ddo....usuugsq.",
287    "kkqqkqg..ouuuu...qqqqkk..",
288    ".qq..ug..qmnnu...u..qq...",
289    ".....qg.kgpathq..q.......",
290    ".....qu.kgprthq..q.......",
291    ".....qu..ucccuqquu.......",
292    ".........qqqqqqqu........",
293    ".........ss..smgu........",
294    ".........um..uuu.........",
295    ".........kuuukk..........",
296    ".........quuuk...........",
297    "..........ukqk...........",
298  ],
299  pal: { a: '#FDCEC8', b: '#0C0C0F', c: '#FB1F21', d: '#5A5869', e: '#FA887F', f: '#42131E', g: '#21212C', h: '#DC0E13', i: '#FB383A', j: '#484655', k: '#1B1A20', l: '#3A1C26', m: '#2A2931', n: '#E60E13', o: '#4C4A5B', p: '#F62326', q: '#201C26', r: '#FE8985', s: '#28252C', t: '#F51E20', u: '#212127', v: '#585566' },
300  cls: { i: 'vis', p: 'vis', n: 'vis', t: 'vis', h: 'vis', c: 'vis' },
301  float: true,
302}
303
304const FALCO: Sprite = {
305  rows: [
306    "......ssss.....",
307    ".....ssssss....",
308    ".....ssrrss....",
309    ".....sggggs....",
310    ".....bgggrs....",
311    ".....sbrrbs....",
312    "......pttpp....",
313    "......ukkus....",
314    "..aqjjukkuskkk.",
315    "..afjsuuuusukk.",
316    "..qqujuuuuuukk.",
317    "..qnujuuuujukk.",
318    ".qqnuuuuuujjkkk",
319    ".qqnjuuuuuujlkk",
320    ".hhhsjuuuuuskkk",
321    ".qfdjuuuuuuulkk",
322    "qqffcjuuuuujvkk",
323    "fqffbcccccssskk",
324    "fqd..ssbsjsbbkk",
325    "nq...sssssbbbrg",
326    "fd..sssssssbbrg",
327    "qqf.sss....bbrr",
328    "fmq.sss....bbrr",
329    "qqq.ssssssssbrr",
330    "qq..sss..sss...",
331    "....sss..sss...",
332    "....sss..sss...",
333    "....sss..sss...",
334    "....sss..sss...",
335    "....sss..sss...",
336    "....ss....ss...",
337    "....ss....ss...",
338    "....ss....sss..",
339    "...sss....sss..",
340    "...sss....sss..",
341    "...ss......ss..",
342    "...........sss.",
343    "...........sss.",
344    "..sss......sss.",
345    "..sss......sss.",
346    "..ses......ses.",
347    "..ooes....eeoe.",
348    ".oiooe....eeooo",
349    ".ooob......boos",
350  ],
351  pal: { a: '#F4F4F5', b: '#291B1B', c: '#E2A32B', d: '#95979E', e: '#7F5A42', f: '#C6C6C8', g: '#D89B74', h: '#414252', i: '#AE7F5A', j: '#533926', k: '#EDDFCA', l: '#C8B6A1', m: '#595867', n: '#B0B2B7', o: '#976C4E', p: '#DFCEB8', q: '#E2E3E3', r: '#CC8A64', s: '#3D2825', t: '#B16D4C', u: '#5D4038', v: '#D3C3AC' },
352  cls: { c: 'vis' },
353}
354
355const GANGER: Sprite = {
356  rows: [
357    "............",
358    "....fff.....",
359    "...ffffff...",
360    "...fffffff..",
361    "..qfffffft..",
362    "..qqtlflit..",
363    "...irdddg...",
364    "...oooooo...",
365    "...hhbboh...",
366    "...hhbbhh...",
367    "..hhhocchh..",
368    ".jhhhjjhhhj.",
369    ".jhhhhhhhhh.",
370    ".hhhhhhhhhh.",
371    "jhhhhjjhhhhj",
372    "jhhhhjjhhhhj",
373    "...hhjjhho..",
374    "...hhcccho..",
375    "....oooooo..",
376    "....sssss...",
377    "...ssooss...",
378    "..ossooss...",
379    "..sssooss...",
380    "..oss..sso..",
381    "..sss...ss..",
382    "............",
383    "............",
384    "............",
385    "..kk....pm..",
386    ".eaun...vaa.",
387    ".aaam...aaa.",
388  ],
389  pal: { a: '#FCFCFC', b: '#1B1C24', c: '#E29D1D', d: '#59F8F9', e: '#868B99', f: '#FB3E8B', g: '#F4B79C', h: '#53406C', i: '#B72169', j: '#F9CC41', k: '#E4DCCF', l: '#233C59', m: '#6C6F7E', n: '#E5B339', o: '#2B203E', p: '#F5E1B9', q: '#E43580', r: '#25506A', s: '#3C4563', t: '#CC2C71', u: '#545868', v: '#5D6272' },
390  cls: { q: 'vis', d: 'vis', c: 'vis', n: 'vis' },
391}
392
393const GUARD: Sprite = {
394  rows: [
395    ".......................",
396    ".............es........",
397    ".........meeeem........",
398    ".........msaajm........",
399    ".........ussssm........",
400    ".........tmsssm........",
401    ".......cctbssssmn......",
402    "...eioscclsbbsmccsii...",
403    "...ddqqlimmssmsnvvvi...",
404    "..mqqsmsss....ssmmq....",
405    "..lmustttt......mms....",
406    "..iselsllq......muuss..",
407    ".ssessssssssssesmtsso..",
408    ".svvtsqsssumssmsmtssii.",
409    "ssrooqbtsmmfsmssmvsssm.",
410    "mmseeeqsqssqstsmllvskmm",
411    "vummsmseqqssppmtvmsssss",
412    "..vvvvtqemtt...bbmeesmh",
413    "......vvutut...ggstsml.",
414    "......musuqq..mtqqftt..",
415    "......vvultttttmmmmmm..",
416    "......msssmtbmmvmumss..",
417    "......vssesvssssumtls..",
418    "......msesmsessesvv....",
419    ".....mmsesshhseessm....",
420    ".....qvseesvmsesesq....",
421    ".....mmseesvvssssmm....",
422    ".....ssseessvssemmm....",
423    ".....qmsesm..usessq....",
424    ".....vssssh..vssssv....",
425    ".....msmseu..sssvsm....",
426    "......msmm....hssm.....",
427    "......mmms....mmsm.....",
428    ".....smumm....mmmms....",
429    ".....esuss....sshse....",
430    ".....eeses....meses....",
431    ".....meesv....vsesh....",
432    ".....seemh....hsees....",
433    ".....sseeu....hesss....",
434    ".....mqus......mmmv....",
435    ".....misu......sqqm....",
436    ".....vsu.......vmmm....",
437    ".....msm.......vmss....",
438    "....mmssl......vmss....",
439    "....sssml......lmsss...",
440    "....ssmt.......ssses...",
441  ],
442  pal: { a: '#FB1B1D', b: '#09090D', c: '#5C5B6A', d: '#543236', e: '#24242D', f: '#434451', g: '#E6080A', h: '#16161E', i: '#524E5C', j: '#F70507', k: '#DD1215', l: '#101015', m: '#1D1C26', n: '#565564', o: '#4B4A56', p: '#E51212', q: '#282833', r: '#E10D11', s: '#212029', t: '#0C0D11', u: '#1A1A21', v: '#131419' },
443  cls: { a: 'vis', j: 'vis', r: 'vis', k: 'vis', p: 'vis', g: 'vis' },
444}
445
446const HANDLER: Sprite = {
447  rows: [
448    ".....nnn.....",
449    "....nnvin....",
450    "....snnnn....",
451    "....pffff....",
452    "....cffff....",
453    ".....ffff....",
454    ".....ffff....",
455    ".....utjv....",
456    ".....aku.....",
457    ".....ukj.....",
458    ".....jqj.....",
459    "......kqerrre",
460    "......kvsssom",
461    ".....lnvvvssn",
462    "...nffvvvvssv",
463    "...afvvvvvfff",
464    "...nvnvvvvlhi",
465    "...niiniinbnn",
466    "......n..vn..",
467    "......b...n..",
468    "......nn..v..",
469    "......vv..i..",
470    "..n...vvnnn..",
471    "......nn.....",
472    ".......v.....",
473    ".......n.....",
474    ".............",
475    ".............",
476    ".............",
477    ".............",
478    ".............",
479    ".............",
480    "..vvv....vv..",
481    "..vvv....vv..",
482    ".............",
483    ".............",
484    ".............",
485    ".............",
486    ".............",
487    "..vv.........",
488    "..bv......nn.",
489    "..nn......nn.",
490    ".nvb.....bnn.",
491    ".vnb.....bnnn",
492    "bdng......bnb",
493  ],
494  pal: { a: '#F8F7F6', b: '#12121A', c: '#FA826F', d: '#A1212A', e: '#4F3C4A', f: '#FDCCAD', g: '#52121C', h: '#E29F83', i: '#292833', j: '#E4E1E5', k: '#83202A', l: '#F6B99B', m: '#37131D', n: '#1E1D26', o: '#521E2A', p: '#3B1F29', q: '#92202A', r: '#5B1623', s: '#2A1F2B', t: '#DE977D', u: '#E8E7EA', v: '#22222C' },
495  cls: { c: 'vis' },
496}
497
498const JOHNNY: Sprite = {
499  rows: [
500    ".....ttttt.....",
501    "....ttttttt....",
502    "....ttdmdtt....",
503    "....ttlmttt....",
504    "....tttdttt....",
505    "....tbdmdtt....",
506    "....tbnnnbt....",
507    "....lbdndbt....",
508    "....ttdddlthh..",
509    "..mdlldlltttqa.",
510    ".mmm...t..lttf.",
511    ".ket...s..lttt.",
512    ".vjtttlfflttae.",
513    ".mmtttttttttaq.",
514    ".kdtltttlttttc.",
515    ".mddttltltllttt",
516    "dmppttttltttttt",
517    "ddddtlltllttrat",
518    "ggddttttttttrao",
519    "dddtttttttt..ac",
520    "kddttltttlt..ic",
521    "nmmtttltllttttt",
522    "dmmteltttlttoto",
523    "ppmteltttllthto",
524    "pmmtlttttttttfu",
525    "...tltttttlt...",
526    "...tltttttlt...",
527    "...tlt..ttlt...",
528    "...ttt..ttlt...",
529    "...ttt..ttlt...",
530    "...tlt...tlt...",
531    "..tttt...tlt...",
532    "..tttt...ttt...",
533    "..tltt...tttt..",
534    "..tttt...tttt..",
535    "..ttt....tttt..",
536    "..ttt....tttt..",
537    "..ttt....tttt..",
538    "..ttt....tttt..",
539    "..bbt.....ttb..",
540    "..btt.....ttb..",
541    "..ttb.....ttt..",
542    "..ttb.....ttt..",
543    ".tttb.....ttt..",
544    "ttttt.....tttt.",
545    "tttt.......ttt.",
546  ],
547  pal: { a: '#F9FAFB', b: '#13141A', c: '#71869E', d: '#EAB18C', e: '#4C4C5B', f: '#BBC4D2', g: '#9E8071', h: '#9CA2B1', i: '#576981', j: '#C89980', k: '#7E6862', l: '#242835', m: '#F7C39D', n: '#6B5955', o: '#8992A3', p: '#E0A17B', q: '#859FB8', r: '#4E586E', s: '#B4B8C5', t: '#1C1D28', u: '#686D80', v: '#6C6263' },
548}
549
550const KIWI: Sprite = {
551  rows: [
552    "....aaaa....",
553    "....aaaaa...",
554    "...aaaaaa...",
555    "...aarrra...",
556    "...arjjrl...",
557    "...lfkjfe...",
558    "...apfjpo...",
559    "...uppqsh...",
560    "...hd..sh...",
561    "..moo..ohmm.",
562    "..hhh..ooum.",
563    ".mhco..ochm.",
564    ".mhhs..dhhh.",
565    ".uuhd..qosm.",
566    ".ushh..uhuu.",
567    ".hsdh..hvdh.",
568    ".mvuh..hhdm.",
569    ".mvhm..uudm.",
570    ".mduu..uudu.",
571    "huouu..puhmu",
572    "huou....uhsh",
573    "udhu....uhdm",
574    "rruuhqq.uuii",
575    "rrhuuqq.uurr",
576    "rshuuqt.uudr",
577    ".duuutt.uud.",
578    ".duuutt.uuv.",
579    ".huuutt.uuh.",
580    ".ouuutt.uuh.",
581    ".vuuhtttuuo.",
582    ".vuuhtttuuo.",
583    ".ouuhttqhuh.",
584    ".ouohttqhuh.",
585    ".vuoqttqouuo",
586    "ohuoogtthuuh",
587    "ouuhhgggouuh",
588    "duuhgggtouuu",
589    "huuoggggouuh",
590    "hhuoggggohuh",
591    "huvvggggqohh",
592    ".uvb....bvh.",
593    "..bb....bb..",
594    "..bq....qb..",
595    "..bq....qb..",
596    "..qqb...qq..",
597    "..qqb...qq..",
598    "..qqb...qq..",
599    "..qb....qqn.",
600  ],
601  pal: { a: '#F5E0B8', b: '#1A1D28', c: '#C43440', d: '#721D2B', e: '#D3B788', f: '#9C2257', g: '#451524', h: '#9B2131', i: '#F9C2A4', j: '#B72A65', k: '#501E3C', l: '#E1C797', m: '#B02A38', n: '#642B36', o: '#862232', p: '#581B29', q: '#282732', r: '#FCCEB2', s: '#661929', t: '#391521', u: '#A42836', v: '#7D1D2D' },
602}
603
604const LUCY: Sprite = {
605  rows: [
606    ".....jjjj....",
607    "....jjjjj....",
608    "....jjjjbj...",
609    "...jjhhhjj...",
610    "...jjhhhju...",
611    "...ujhhhjl...",
612    ".....gggj....",
613    ".....nmpp....",
614    "...hapddahh..",
615    "..hh.....hh..",
616    ".vmm.....omm.",
617    ".oooommmoooo.",
618    ".oooommvbooo.",
619    ".ooeommmoeoo.",
620    "oqoovmmmvvooo",
621    "oooqhmmmhhqoo",
622    "ooehhmmmhheeo",
623    "ooqhcmmmchooo",
624    "seecccvcrccee",
625    ".oocccccccco.",
626    ".hrcckkcccrh.",
627    ".hccccfccchh.",
628    "...ccctcccch.",
629    "...ccccccc...",
630    "...kccccck...",
631    "...ccccccc...",
632    "...ccccccc...",
633    "...ccccccc...",
634    "...cccckcc...",
635    "...ccc..cc...",
636    "...ccr..cc...",
637    "..cccc..cc...",
638    "..cccc..cc...",
639    "...cc...cc...",
640    "...cc...cc...",
641    "...ct...kk...",
642    "...dd...ddd..",
643    "...aa...aia..",
644    "...ia...vi...",
645    "...iv...vi...",
646    "...av...av...",
647    "...vv...vvv..",
648    "..avv...ava..",
649    "..ddd...ivd..",
650  ],
651  pal: { a: '#1A1B28', b: '#FAFAFB', c: '#897E9F', d: '#9E2637', e: '#C3B8D0', f: '#4D4366', g: '#D48B73', h: '#FDCFB4', i: '#541E2A', j: '#E0D3EC', k: '#655981', l: '#B594D5', m: '#25283E', n: '#832535', o: '#EBEBEB', p: '#642738', q: '#D1C6DA', r: '#7B7196', s: '#E0DFE3', t: '#554A70', u: '#BD9EDB', v: '#1E2134' },
652}
653
654const MAINE: Sprite = {
655  rows: [
656    "............gjj.............",
657    "............gjjj............",
658    "............ggjj............",
659    "............urrr............",
660    "............qqqq............",
661    "..........hikmrroo..........",
662    "........hkooqmrooooo........",
663    ".....ffqqokoooooooooiff.....",
664    "....fffpiqokoookoooiifff....",
665    "...cffcpppqokkkooopppcfff...",
666    "...cfccpippqoooooppppccfff..",
667    "..cffciqppppqoooppppqiqcfc..",
668    "..cfqqqqppppqqoqippqqqqqfc..",
669    "...qdaaqqqppqqqqiiqqqaadqq..",
670    "...daaaqqqppqqqqpiqqqaaavqj.",
671    ".svvaallqpppqqqqpppqqlaavvj.",
672    "sttvqq..qpppqqqqppiq..qqvats",
673    "dttadd..qqiiqqqqppiq..ddqadd",
674    ".dtaaa...qqqmmmqqqi...iaatdd",
675    ".dtaan...iqqqmmqpqq...naatvv",
676    "ddaann...pqqqqqqqqp...nnjadd",
677    "ddaaa....pppqqqqpppp...dtadd",
678    "ldqqd...eppppqqpppqp...vqavd",
679    "jjaaq...ejjppqqpppps...paaj.",
680    ".jaaa..eej..q..q..jas..paaj.",
681    ".jaqb..ejj........jjs..bqjj.",
682    ".sddd..ejj........jje..dvqj.",
683    "..vv...djjjq....qjjjd.......",
684    ".......vvqjq....qjjvv.......",
685    "......jvvqv......dqvvj......",
686    "......jjvvd......dq..j......",
687    "......jjjqd......dq..j......",
688    ".....ejeeje......njeejj.....",
689    ".....sjejj........ttejj.....",
690    ".....ijjjq........qjjj......",
691    ".....ijjjiq......qijjj......",
692    "......jjjdd......dijjj......",
693    "......jjjd........djjj......",
694    "......vvjd........djvv......",
695    ".....vvvje........sjvvj.....",
696    ".....bbqje........ejbbb.....",
697    ".....qqdbs........ebbqq.....",
698    "....qpppq.........qqqdd.....",
699    "....vvqqq.........qqqqv.....",
700    ".....qqiqq........qqqq......",
701    ".....qqiiq........qi........",
702  ],
703  pal: { a: '#D5D0CA', b: '#10131A', c: '#A46B41', d: '#4A4A56', e: '#999797', f: '#D19258', g: '#807A6A', h: '#7E443B', i: '#34322D', j: '#B8B4AF', k: '#5C3435', l: '#63626E', m: '#6A5A50', n: '#888587', o: '#99503D', p: '#44423D', q: '#202228', r: '#966A55', s: '#A9A5A3', t: '#C7C2BC', u: '#624945', v: '#575661' },
704  cls: { f: 'vis' },
705}
706
707const NEON: Sprite = {
708  rows: [
709    ".....kkkk....",
710    "....kskdkk...",
711    "...kkkkkss...",
712    "...kkrkrkk...",
713    "...dkuuukps..",
714    "..spkuuuups..",
715    "..spkeammps..",
716    "...vqvhmqv...",
717    "...qqbbbvq...",
718    "..fqqqqqqqi..",
719    "..qqqbbbqqq..",
720    ".rqqfvubvqqi.",
721    ".qqqmuuumvqq.",
722    "iqqvbbgbbvqqr",
723    "qqqbbbbbbvqqq",
724    "vqvb....bbmqv",
725    ".fmm....bbbf.",
726    ".abln...noua.",
727    "...uuh.nuoou.",
728    "...uaa.nuu...",
729    "...uau.nuu...",
730    "...nu...uu...",
731    "...nu...hu...",
732    "...un...nu...",
733    "...au...uu...",
734    "..bjb...btb..",
735    "..bvb...bbb..",
736    "...mb...bb...",
737    "...qb...bq...",
738    "...bb...bb...",
739    "..bbb...vcb..",
740  ],
741  pal: { a: '#F7C7AC', b: '#1C1F2D', c: '#E7345A', d: '#F4AF26', e: '#AB6312', f: '#821B3D', g: '#4C5068', h: '#D18370', i: '#FA3BAB', j: '#FA7FBA', k: '#FEDA55', l: '#9F6D65', m: '#520F2A', n: '#E69F86', o: '#672A52', p: '#E3941A', q: '#A32143', r: '#E62A9B', s: '#FBC43A', t: '#F79CB7', u: '#F4AF98', v: '#6A1534' },
742  cls: { s: 'vis', d: 'vis', r: 'vis', p: 'vis', i: 'vis', c: 'vis' },
743}
744
745const NETRUNNER: Sprite = {
746  rows: [
747    "..............",
748    "..............",
749    "......oo......",
750    ".....bskb.....",
751    "....bnllno....",
752    "....ooaabk....",
753    "....mbeebm....",
754    "...umbsmoms...",
755    "...mpsmmoqm...",
756    "....pgmmmgj...",
757    "....ffmmmct...",
758    "....iimmmpt...",
759    "....ggmmmtvk..",
760    "....nnmmmppk..",
761    "....ccsmmpps..",
762    "....ccsssppm..",
763    "......msshhm..",
764    "......mso.....",
765    "......sso.....",
766    ".....ommo.....",
767    "..mmmommkkmm..",
768    "..cmsm..mmkv..",
769    "..sm......mh..",
770    "......mm......",
771    "......mm......",
772    "......ss......",
773    "......mm......",
774    "..............",
775    "..............",
776    "..............",
777    "..............",
778    "..............",
779    "..............",
780    "..............",
781    "..............",
782    "..............",
783    "..............",
784    "..............",
785    "..mmk....km...",
786    "..mmk....mm...",
787    "..mm......mk..",
788    "..mg......gm..",
789    "mmmm......mmmm",
790    "rrsm......mmrd",
791  ],
792  pal: { a: '#FCCBAA', b: '#08080B', c: '#02E6F3', d: '#484957', e: '#CB7B6D', f: '#20B2CC', g: '#252832', h: '#07C7E0', i: '#21F5F8', j: '#03B0CA', k: '#15151D', l: '#08DDE6', m: '#1C2027', n: '#0CD1E6', o: '#0F0F14', p: '#0EE6F5', q: '#0CB0C8', r: '#444451', s: '#191A22', t: '#04DEED', u: '#1E2935', v: '#05CFE5' },
793  cls: { n: 'vis', l: 'vis', p: 'vis', c: 'vis', t: 'vis', i: 'vis', v: 'vis', h: 'vis' },
794}
795
796const PILAR: Sprite = {
797  rows: [
798    ".................",
799    ".......ppb.......",
800    ".......bbb.......",
801    ".......bbb.......",
802    ".......hhh.......",
803    "......edtdq......",
804    "......rmmmr......",
805    ".......lull......",
806    ".......blbp......",
807    "......bbgrb......",
808    "......ppmpb......",
809    "...hh.......hh...",
810    "...hr.......mh...",
811    "...hh.......pm...",
812    "...hhp......bh...",
813    "...hhp.......h...",
814    "..hhhb.......hm..",
815    "..hrrb.......rh..",
816    "..hmmp.......mh..",
817    ".............kkk.",
818    ".mnl..bojjpp.lur.",
819    ".mac.ffvvffv.rar.",
820    "munl.vvfffff.lumr",
821    "mauuuffvfffffuuur",
822    "runnnffjjffffuuuk",
823    "rnuujfffffffjamul",
824    "mnnnjffvvfffjmmur",
825    "iuulfffvvvfffrrur",
826    ".uuuffffffffffsl.",
827    "....ffff..ffffll.",
828    "....fffj..fff....",
829    "....fff...fff....",
830    "....fff...fff....",
831    "....ffj...off....",
832    "....fff...jvfo...",
833    "...ffff...fffj...",
834    "...fffj...jfff...",
835    "...fffj...jfff...",
836    "...ffjj...jfff...",
837    "...fjjj...ffvf...",
838    "...ffjj...fjfv...",
839    "...jffj...jfvj...",
840    "...ffvj...jvffj..",
841    "...kkv....jvrrv..",
842    "...ruk.....ruuu..",
843    "..nnlr.....rrnn..",
844    "..lllm.....mlll..",
845  ],
846  pal: { a: '#F8F8F8', b: '#1C1D27', c: '#898B92', d: '#F6C665', e: '#BA7C24', f: '#3B5076', g: '#C1BDC1', h: '#DCDCDB', i: '#6D707B', j: '#2A3855', k: '#ACACAD', l: '#9A9BA0', m: '#CFCECC', n: '#EBEAEA', o: '#334264', p: '#282731', q: '#B47C34', r: '#B6B6B7', s: '#7F8388', t: '#F1BC5A', u: '#E3E3E3', v: '#384A6C' },
847  cls: { d: 'vis', t: 'vis' },
848}
849
850const REBECCA: Sprite = {
851  rows: [
852    "...............",
853    "....ubbbbeuu...",
854    "...ucbcccbkcu..",
855    "...cckuccuucu..",
856    "...ukraaackku..",
857    "...ukkaaauuuu..",
858    "......bbbb.....",
859    "......bbbb.....",
860    "......bbb......",
861    "...eb..e.......",
862    "...eb..p..b....",
863    "....b..b..b....",
864    "....b..b..b....",
865    "....b..e..bb...",
866    ".bbbb..b..bbbb.",
867    "dddbb..e..ebbit",
868    "bvqhb..b..bbmtf",
869    "ddqhbbbbbbbfmmt",
870    "ddbbbllellbbbtt",
871    "vdqqaaaasaaamij",
872    "dbbqaaaaaaammbt",
873    ".dnnaaaaaaaattm",
874    ".bnnaaa..aaatf.",
875    "....aa...aa....",
876    "....aa...ss....",
877    "....as...sa....",
878    "...aaa...saa...",
879    "...aaa...aaa...",
880    "....a.....aa...",
881    "....a..........",
882    "....b..........",
883    "....bb.........",
884    "...bbb....bb...",
885    "...bbb....bb...",
886    "...bbb.........",
887    "..oaag...gaao..",
888  ],
889  pal: { a: '#FDDECF', b: '#191B29', c: '#0FE2B8', d: '#E12641', e: '#94E152', f: '#206A9D', g: '#AFABA3', h: '#840F26', i: '#4EB5E8', j: '#10416E', k: '#03A19F', l: '#EAAEA2', m: '#218ACA', n: '#511B28', o: '#D0CCC3', p: '#B6F26D', q: '#B20F2B', r: '#0A8488', s: '#F4C5BA', t: '#37A0DB', u: '#04C3B1', v: '#690D25' },
890  cls: { e: 'vis', c: 'vis', d: 'vis', i: 'vis', t: 'vis' },
891}
892
893const SABURO: Sprite = {
894  rows: [
895    ".....iii.....",
896    "....shhhh....",
897    "....hhhhh....",
898    "....mohohe...",
899    "....hhhhhe...",
900    "....hhhhh....",
901    "....hhhhh....",
902    "....lqkcl....",
903    "..fflgcqlrr..",
904    ".ffrlqgglrrr.",
905    ".frrlggglrrr.",
906    ".ftrlggglrlf.",
907    "fftrrqqglrtf.",
908    "frrrrqgglrrrr",
909    "rrttlguglttfr",
910    "rrrttpvqltlrr",
911    "rrrrtfgjtffrr",
912    "rrrrrrqlrrrrr",
913    "rrrrrrprrrrrr",
914    ".rrrrhhhrrrr.",
915    ".rrbbhhhtrrr.",
916    ".trbtfffttrt.",
917    ".ttrtjfplrtt.",
918    ".lrrtqjqlrrl.",
919    ".rrrlqqqlrrr.",
920    ".rrrlqqqlrrr.",
921    ".rrrrqqjrrrr.",
922    "..ttjqqjptt..",
923    "..lljjqqjll..",
924    "..lqqqqqqql..",
925    "..qqqqqqqqq..",
926    "..qqqqqqjqq..",
927    "..jqqqqqjqq..",
928    "..pqqqqqjjq..",
929    "..jqqqqqqpq..",
930    "..qqpqqqqjj..",
931    "..qqpqqqqqj..",
932    "..qqjjqqqqq..",
933    "...qjjjqqqj..",
934    "...aannnaa...",
935    "...aaaabaa...",
936    "..ddddddddd..",
937  ],
938  pal: { a: '#F3E6C8', b: '#39281F', c: '#B39572', d: '#855B35', e: '#EAB086', f: '#57413B', g: '#846863', h: '#F9C8A2', i: '#C7A98A', j: '#6D554F', k: '#DC9A6F', l: '#45352E', m: '#81644E', n: '#CCB796', o: '#453A40', p: '#604C46', q: '#785E58', r: '#4F3B33', s: '#BA987C', t: '#3C2E28', u: '#614A3B', v: '#896C5A' },
939}
940
941const SALARYMAN: Sprite = {
942  rows: [
943    "....lll....",
944    "....llqq...",
945    "....bqjq...",
946    "....ufff...",
947    "....fuu....",
948    "....add....",
949    "..reraaer..",
950    "..rrepprrr.",
951    "..rercrrrr.",
952    ".rrrrrrrrr.",
953    ".rrrmrrrrrr",
954    ".rrrrrrrror",
955    ".rhrrrrrrmr",
956    ".rrrrrrrrmm",
957    ".rrrrrmmmrr",
958    ".rorrrrrrrr",
959    ".nurrorrrun",
960    ".nnmmhomrjf",
961    ".gommmmmm..",
962    "gkirmmmmm..",
963    "ggirrrrmm..",
964    "tiirr..mm..",
965    "ttirr..mm..",
966    "vtioo..mo..",
967    "..irm..mm..",
968    "..mmm..mm..",
969    "..mmh..mm..",
970    "..hoo..so..",
971    "..oo...oo..",
972    "..ll...qll.",
973    ".qqq....lq.",
974  ],
975  pal: { a: '#FBFBFA', b: '#151419', c: '#DD1B21', d: '#E8997D', e: '#625C6B', f: '#FCCDAE', g: '#543631', h: '#4A4554', i: '#392423', j: '#FBBB92', k: '#6D433A', l: '#22212A', m: '#565160', n: '#F8C3A1', o: '#514B59', p: '#F4F5F5', q: '#1E1B23', r: '#5C5666', s: '#45414E', t: '#4D322E', u: '#FDC8A5', v: '#3D2828' },
976  cls: { c: 'vis' },
977}
978
979const SMASHER: Sprite = {
980  rows: [
981    "...................................",
982    "..............jjjj.....cbqo........",
983    "..............aajaqqbbbqqqq........",
984    ".............qaaaiqjbbbcqqn........",
985    "........qqqqqbqrrqqbjqbbbbb........",
986    ".......uqqqmmbqjjqqqgjqbbdd........",
987    ".....usssbqvvqqbbqqqgjqkssdss......",
988    "....ddsssqjvkqqqqqddvdqssssss......",
989    "....ssssqbdddssdqdssddsqqssss......",
990    "....ssjqqssddsssvksdddsqqqqqd......",
991    "....qqqbbqdkdkdsedkdddqqbbqtq......",
992    "...dlntqbbqqsksqqskssqqbbqjnlds....",
993    "..sdqjjqqjqqqkqqqqqqvqqbqjjjhdd....",
994    ".ddsqqqqqqjjvqqjjjqqvqqqqqjqqdsd...",
995    "ddjsqqqqqqqjqqqqqqqqqqqqqqqqqdks...",
996    "ddddddqbqqqqqqqqqqqqqq...qqqqdqj...",
997    "ddssddqqqqqqqqqqqbjqqq....bqspjj...",
998    ".qqsssddqqbbqqqqqqqqjqj...qddjus...",
999    "..qqssssqkkkbqqqqqqqqqq..qqdfkus...",
1000    ".....kqqqqvvqqqqqqqqqqqqqqqskkss...",
1001    "......qbqccqqqqbqqqqqqqqqqjqkssj...",
1002    "........qqqqqqbbbqqqqvvqqqqjqsj....",
1003    ".........qqqqbjjskbqqqqqqqqqqqj....",
1004    ".........qqbbbqq..bqqqqbbqqqqqq....",
1005    ".........qqqnqbj..bbnqqqqqbqqqqqb..",
1006    ".........qqqvqbq..bqqqqqqqqqbqqqb..",
1007    ".........qsqqqbqqqbqqjsqqqqqqbqbqqb",
1008    "........ssssqqbbqqbqjqsdqqqbqqqbqqb",
1009    "........ddssqqqbbbbqqdsdds....bbqb.",
1010    ".......ddkssqqb...bbqsdkks.........",
1011    ".......dkkdssqq...bq..dkkdd........",
1012    ".......dsss..qq.......dskdd........",
1013    ".......dtjq...........qqjv.........",
1014    ".......vjqq...........qqjq.........",
1015    ".......jjqqq.........qqqjjj........",
1016    "......qjjqqqq.......bbqqqjq........",
1017    "......qjqqsqq.......qqsqqjqs.......",
1018    ".....dqqqsffq.......qqvsqqqs.......",
1019    ".....sqqqsffq.......q..vsqqds......",
1020    ".....sqqss..........q..vsqqdd......",
1021    ".....dsqds..........j..dsqqdd......",
1022    ".....sjjqs.............ssqjkd......",
1023    "......pjjk..............jjjj.......",
1024    "......ffqq..............qqvs.......",
1025    ".....kssqbj............qqqfs.......",
1026    ".....svjqqb...........qbqqvs.......",
1027    "....ssjjqqb...........bqqqqss......",
1028    "....sqqqstq...........qqdqqqs......",
1029    "....qqqqktq...........qqvsbqb......",
1030    "......qsqqq...........qqqskks......",
1031    "......ssqqqc..........qqqqs........",
1032    "......qqqbqq.........qqbbqq........",
1033    "..qjjjqqqbqq.........qqbbqjjjjq....",
1034    "..jjoojqqbqq.........qqbqqjjojq....",
1035  ],
1036  pal: { a: '#B09B9A', b: '#14131D', c: '#C90C29', d: '#5D5363', e: '#701023', f: '#E23236', g: '#4C2736', h: '#9D0B24', i: '#9A8384', j: '#292731', k: '#49414E', l: '#3D1324', m: '#6D2334', n: '#DC1D2D', o: '#B20527', p: '#CD2836', q: '#261924', r: '#A39293', s: '#524B59', t: '#361E2A', u: '#655A68', v: '#E42733' },
1037  cls: { n: 'vis', v: 'vis', p: 'vis', f: 'vis' },
1038}
1039
1040const TROOPER: Sprite = {
1041  rows: [
1042    ".............",
1043    ".......os....",
1044    "....ncvou....",
1045    "....hrern....",
1046    "....hofok....",
1047    "....ubhbu....",
1048    "....nnunss...",
1049    "...nnvnnnnna.",
1050    ".nsjjjvvvsnn.",
1051    ".llnjjjsjsst.",
1052    "dunnljnvvnss.",
1053    "gnnjnniunuunq",
1054    ".....ssuuuumn",
1055    ".....usstnvs.",
1056    "...uuugkvnu..",
1057    "...vjjpnun...",
1058    ".............",
1059    ".............",
1060    ".............",
1061    ".............",
1062    "...jj....nj..",
1063    "...dn...ndp..",
1064    "...gg...ugg..",
1065    "..nju...nkn..",
1066    "..jnj...jss..",
1067    "........jm...",
1068    ".............",
1069    ".............",
1070    "..nn.....sn..",
1071    "..ju.....ujn.",
1072    ".nnu.....hnn.",
1073  ],
1074  pal: { a: '#FD887E', b: '#0E0D12', c: '#CB0B12', d: '#5C5A6A', e: '#FA2029', f: '#292637', g: '#444250', h: '#1C1A24', i: '#4E4D5B', j: '#24232A', k: '#15141A', l: '#565564', m: '#2A2931', n: '#201F26', o: '#262432', p: '#4B4A58', q: '#595768', r: '#F72026', s: '#211F2A', t: '#4F4A58', u: '#1E1D24', v: '#282732' },
1075  cls: { r: 'vis', e: 'vis' },
1076}
1077
1078// The crew's roles, by the costume a crab would have worn for them.
1079const CREW_ROLES: Record<string, Sprite> = {
1080  explore: KIWI,
1081  fable: LUCY,
1082  heavy: MAINE,
1083  careful: PILAR,
1084  medium: DORIO,
1085  light: REBECCA,
1086  artist: JOHNNY,
1087  writer: FALCO,
1088  banker: FALCO,
1089  other: JOHNNY,
1090}
1091
1092export const CASTS: Record<string, Cast> = {
1093  // David leads; Lucy, Maine, Rebecca, Kiwi, Dorio, Pilar, Falco and Johnny take the roles.
1094  runners: { name: 'Edgerunners', bossName: 'David', boss: DAVID, roles: CREW_ROLES, crew: [CHOOM, GANGER, NEON] },
1095  // Lucy leads; David rides along as one of the crew.
1096  netrun: { name: 'Edgerunners', bossName: 'Lucy', boss: LUCY, roles: { ...CREW_ROLES, fable: DAVID }, crew: [CHOOM, NEON, GANGER] },
1097  // SAMURAI, the secret skin: Johnny fronts the band, the crew backs him up.
1098  samurai: {
1099    name: 'SAMURAI',
1100    bossName: 'Johnny',
1101    boss: JOHNNY,
1102    roles: { ...CREW_ROLES, artist: LUCY, other: DORIO },
1103    crew: [GANGER, NEON, CHOOM],
1104  },
1105  // The old man at the top; Smasher, his officers and a captive rockerboy under him;
1106  // the guards and staff on the floor.
1107  corpo: {
1108    name: 'Arasaka',
1109    bossName: 'Saburo',
1110    boss: SABURO,
1111    roles: {
1112      explore: NETRUNNER,
1113      fable: HANDLER,
1114      heavy: SMASHER,
1115      careful: CYBORG,
1116      medium: AGENT,
1117      light: GUARD,
1118      artist: JOHNNY,
1119      writer: HANDLER,
1120      banker: HANDLER,
1121      other: AGENT,
1122    },
1123    crew: [TROOPER, SALARYMAN, DRONE, TROOPER, SALARYMAN],
1124  },
1125}
1126
1127// A stable pick from a word, so every agent of a role gets the same worker.
1128export const hashOf = (word: string): number => [...word].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7)
1129
1130// Sprite pixels to the crab's 30×28 box: the tallest of the crew (48 rows) stands
1131// 27 high; Smasher towers over the box.
1132const PIXEL = 0.5625
1133// A tone just off the dark backgrounds, around the silhouette: no hard outline,
1134// but the dark clothes still read against the band.
1135const RIM = '#2A2B3A'
1136
1137// The sprite as one path per colour and motion group: small enough that a crowd
1138// of them stays inside the drawing's size limit. Centred in the box, standing on
1139// its floor; the rows under the gap between the legs are the legs.
1140export const spriteMarkup = (sp: Sprite, tint: string, box = 30, floor = 27): string => {
1141  const width = Math.max(...sp.rows.map(r => r.length))
1142  const height = sp.rows.length
1143  const mid = Math.floor(width / 2)
1144  let legFrom = height
1145  while (legFrom > 0 && (sp.rows[legFrom - 1]?.[mid] ?? '.') === '.' && !sp.float) legFrom--
1146  if (height - legFrom < 2) legFrom = height
1147  const colorOf = (ch: string | undefined) => (ch === undefined || ch === '.' ? undefined : ch === 't' && !sp.pal.t ? tint : sp.pal[ch])
1148  const isFilled = (x: number, y: number) => colorOf(sp.rows[y]?.[x]) !== undefined
1149  const paths = new Map<string, Map<string, string[]>>()
1150  const add = (group: string, color: string, x: number, y: number, w: number) => {
1151    if (!paths.has(group)) paths.set(group, new Map())
1152    const byColor = paths.get(group) as Map<string, string[]>
1153    if (!byColor.has(color)) byColor.set(color, [])
1154    byColor.get(color)?.push(`M${x} ${y}h${w}v1h${-w}z`)
1155  }
1156  const side = (x: number) => (x < width / 2 ? 'la' : 'lb')
1157  // The head: the top sixth, which nods a beat after the breath.
1158  const headTo = sp.float ? 0 : Math.round(height / 6)
1159  for (let y = -1; y <= height; y++) {
1160    const row = sp.rows[y] ?? ''
1161    for (let x = -1; x <= width; x++) {
1162      const color = colorOf(row[x])
1163      if (color === undefined) {
1164        // The rim: an empty pixel touching the silhouette.
1165        if (isFilled(x + 1, y) || isFilled(x - 1, y) || isFilled(x, y + 1) || isFilled(x, y - 1)) {
1166          add(y >= legFrom ? side(x) : y < headTo ? 'hdrim' : 'rim', RIM, x, y, 1)
1167        }
1168        continue
1169      }
1170      // A run of one letter, split at the middle on the legs so each steps alone.
1171      const ch = row[x] as string
1172      let n = 1
1173      while (row[x + n] === ch && (y < legFrom || side(x + n) === side(x))) n++
1174      add(y >= legFrom ? side(x) : (sp.cls?.[ch] ?? (y < headTo ? 'hd' : 'bd')), color, x, y, n)
1175      x += n - 1
1176    }
1177  }
1178  const draw = (group: string) =>
1179    [...(paths.get(group) ?? new Map<string, string[]>()).entries()].map(([color, d]) => `<path d="${d.join('')}" fill="${color}"/>`).join('')
1180  const inner = ['hair', 'vis', 'led'].map(g => (paths.has(g) ? `<g class="${g}">${draw(g)}</g>` : '')).join('')
1181  // The tallest (Smasher) is shrunk to fit the box rather than poke out of it.
1182  const pixel = Math.min(PIXEL, floor / height)
1183  const x0 = +((box - width * pixel) / 2).toFixed(2)
1184  const y0 = +(floor - height * pixel).toFixed(2)
1185  return `<g transform="translate(${x0},${y0}) scale(${+pixel.toFixed(4)})"><g class="bd">${draw('rim')}${draw('bd')}<g class="hd">${draw('hdrim')}${draw('hd')}</g>${inner}</g><g class="la">${draw('la')}</g><g class="lb">${draw('lb')}</g></g>`
1186}
1187
1188// How the cast moves: a breath at rest, hair that sways, visors that flicker,
1189// lights that pulse; a brisker step and flicker while it works.
1190export const CAST_CSS = `.ch g{transform-box:fill-box}
1191.ch .bd{animation:cbreathe 2.6s steps(1) infinite}
1192@keyframes cbreathe{50%{transform:translateY(1px)}}
1193.ch.run .bd{animation:bob .5s steps(1) infinite -.125s}
1194.ch .hd{animation:cnod 2.6s steps(1) infinite -.65s}
1195@keyframes cnod{50%{transform:translateY(.6px)}}
1196.ch.run .hd{animation:cnod .5s steps(1) infinite -.25s}
1197.ch .hair{animation:csway 1.8s steps(1) infinite}
1198@keyframes csway{50%{transform:translateX(1px)}}
1199.ch .vis{animation:cvis 2.2s ease-in-out infinite}
1200@keyframes cvis{50%{opacity:.5}}
hooks/eggs.ts 280 lines
1// Easter eggs for the cyberpunk skins: small references a Cyberpunk 2077 or
2// Edgerunners fan will catch. Pure SVG strings and CSS, no state: the hooks module
3// decides when each shows. Only short words and names are used, never lyrics.
4//
5// Three flavours, one per cast: `runners` (Night City yellow), `netrun` (Lucy's
6// pink and the moon), `corpo` (Arasaka red).
7
8import { inkOn, textW } from './extras'
9
10export type Egg = 'runners' | 'netrun' | 'corpo' | 'samurai'
11
12// `bg` is the card ground: each skin's tile colour.
13export type EggColors = { a: string; b: string; c: string; text: string; dim: string; bg: string }
14
15const COLORS: Record<Egg, EggColors> = {
16  runners: { a: '#FCEE0A', b: '#00F0FF', c: '#FF003C', text: '#F4F4F4', dim: '#7E8696', bg: '#0B0C10' },
17  netrun: { a: '#FF5CF0', b: '#5CE1FF', c: '#B8F2E0', text: '#F4F0FF', dim: '#8A7EA0', bg: '#100B1A' },
18  corpo: { a: '#E3001B', b: '#F2F2F2', c: '#8A0A24', text: '#F2F2F2', dim: '#7E7F86', bg: '#0A0A0B' },
19  samurai: { a: '#E10600', b: '#EDEDED', c: '#FFD400', text: '#F4F4F4', dim: '#8A7A7A', bg: '#0E0909' },
20}
21export const eggColors = (egg: Egg): EggColors => COLORS[egg]
22
23const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
24let uid = 0
25const id = (p: string) => `${p}${(uid = (uid + 1) % 100000)}`
26
27// --- motion: the glitch bursts, the breach band, the heat flash, the trails ---
28export const EGG_CSS = `.rg{animation:rgx 4.2s steps(1) infinite}.rg2{animation:rgx 4.2s steps(1) infinite -.06s}
29@keyframes rgx{0%,90%,100%{transform:translate(0,0);opacity:0}91%{transform:translate(-2px,0);opacity:.9}92%{transform:translate(2px,1px);opacity:.8}94%{transform:translate(-1px,-1px);opacity:.9}95%{transform:translate(0,0);opacity:0}}
30.rgs{animation:rgs 4.2s steps(1) infinite}@keyframes rgs{0%,90%,96%,100%{transform:translateX(0)}91%{transform:translateX(3px)}93%{transform:translateX(-2px)}}
31.bx{animation:bx 3s steps(6) infinite}@keyframes bx{to{transform:translateX(var(--bw))}}
32.bslot{animation:cur 1s steps(1) infinite}
33.heat{animation:heat .5s steps(1) infinite}@keyframes heat{50%{fill:#2F6BFF}}
34.psy{animation:psy 1.3s steps(1) infinite}@keyframes psy{0%,100%{opacity:1;transform:translateX(0)}20%{opacity:.4;transform:translateX(1px)}24%{opacity:1;transform:translateX(-1px)}60%{opacity:.85}}
35.sandy{animation:sandy 1.2s ease-in-out infinite}@keyframes sandy{50%{opacity:.08}}
36.win{animation:win 3.4s steps(1) infinite}@keyframes win{50%{opacity:.25}}
37.av{animation:av 14s linear infinite}@keyframes av{from{transform:translateX(-60px)}to{transform:translateX(var(--sw))}}
38.beacon{animation:led 1.4s steps(1) infinite}@keyframes led{50%{opacity:.2}}
39.moon{animation:moon 6s ease-in-out infinite}@keyframes moon{50%{transform:translateY(-2px)}}
40.rise{animation:rise 2.4s cubic-bezier(.2,.8,.2,1) both}@keyframes rise{from{transform:translateY(40px);opacity:0}}
41.flash{animation:flash .9s steps(1) 3}@keyframes flash{33%{opacity:.15}66%{opacity:.9}}
42.tick{animation:tick 1.6s ease-in-out infinite}
43@media (prefers-reduced-motion: reduce){.rg,.rg2,.rgs,.bx,.bslot,.heat,.psy,.sandy,.win,.av,.beacon,.moon,.rise,.flash,.tick{animation:none}}`
44
45// The Relic's malfunction on a line of text: a red and a cyan copy that tear
46// sideways for a moment every few seconds, over a white core that jumps a slice.
47export const relicText = (x: number, y: number, text: string, attrs: string, egg: Egg): string => {
48  const c = COLORS[egg]
49  const t = esc(text)
50  return `<g class="rgs"><text x="${x}" y="${y}" ${attrs}>${t}</text></g>
51<text class="rg" opacity="0" x="${x}" y="${y}" ${attrs.replace(/fill="[^"]*"/, '')} fill="${c.c}" style="mix-blend-mode:screen">${t}</text>
52<text class="rg2" opacity="0" x="${x}" y="${y}" ${attrs.replace(/fill="[^"]*"/, '')} fill="${c.b}" style="mix-blend-mode:screen">${t}</text>`
53}
54
55// Breach Protocol: a row of hex codes with the lit band snapping cell to cell while
56// the job runs, and a buffer of slots filled as steps are done.
57const CODES = ['1C', 'BD', '55', 'E9', '7A', 'FF']
58export const breachTicker = (x: number, y: number, isWorking: boolean, done: number, total: number, egg: Egg, font: string): string => {
59  const c = COLORS[egg]
60  const cell = 17
61  const cells = CODES.map((code, i) => `<text x="${x + 44 + i * cell + cell / 2}" y="${y}" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="700" fill="${isWorking ? c.a : c.dim}" fill-opacity="${isWorking ? 0.9 : 0.6}">${code}</text>`).join('')
62  const band = isWorking
63    ? `<rect class="bx" style="--bw:${cell * 6}px" x="${x + 44}" y="${y - 9}" width="${cell}" height="12" fill="${c.a}" fill-opacity=".18" stroke="${c.a}" stroke-opacity=".7" stroke-width=".8"/>`
64    : ''
65  const slots = Math.max(1, Math.min(6, total || 6))
66  const filled = total ? Math.min(slots, Math.round((done / total) * slots)) : 0
67  const buf = Array.from({ length: slots }, (_, i) => {
68    const bx = x + 44 + cell * 6 + 10 + i * 7
69    return i < filled
70      ? `<rect x="${bx}" y="${y - 7}" width="5" height="8" fill="${c.a}"/>`
71      : `<rect${i === filled && isWorking ? ' class="bslot"' : ''} x="${bx + 0.5}" y="${y - 6.5}" width="4" height="7" fill="none" stroke="${c.a}" stroke-opacity=".6"/>`
72  }).join('')
73  return `<text x="${x}" y="${y}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".6" fill="${c.dim}">BREACH</text>${band}${cells}${buf}`
74}
75export const BREACH_W = 44 + 17 * 6 + 10 + 6 * 7
76
77// NCPD heat: up to five stars, lit by how hot the run is; five flash red and blue.
78export const heatStars = (x: number, y: number, level: number, egg: Egg): string => {
79  const c = COLORS[egg]
80  const lit = Math.max(0, Math.min(5, Math.round(level)))
81  return Array.from({ length: 5 }, (_, i) => {
82    const on = i < lit
83    return `<text${on && lit === 5 ? ' class="heat"' : ''} x="${x + i * 9}" y="${y}" font-size="9" fill="${on ? c.c : c.dim}" fill-opacity="${on ? 1 : 0.5}">★</text>`
84  }).join('')
85}
86
87// Cyberpsychosis: past 85% context the chrome starts to bite; past 95% MaxTac rolls.
88export const psychoChip = (x: number, y: number, ctxPct: number, font: string): { svg: string; w: number } => {
89  if (ctxPct < 85) return { svg: '', w: 0 }
90  const label = ctxPct >= 95 ? 'MAXTAC INBOUND' : 'CYBERPSYCHOSIS RISK'
91  const w = textW(label, 9.5, 0.5) + 14
92  return {
93    w,
94    svg: `<g class="psy"><rect x="${x}" y="${y - 10}" width="${w}" height="13" rx="1" fill="#FF003C"/><text x="${x + w / 2}" y="${y - 0.5}" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="800" letter-spacing=".5" fill="${inkOn('#FF003C')}">${label}</text></g>`,
95  }
96}
97
98// Sandevistan: three fading afterimages trailing the figure while it works, tinted
99// like David's time-slow trails.
100export const sandevistan = (figure: string, egg: Egg): { defs: string; svg: string } => {
101  const f = id('sv')
102  const tint = egg === 'corpo' ? '0.9 0 0 0 0.25  0 0.1 0 0 0  0 0.1 0 0 0' : egg === 'netrun' ? '0.6 0 0 0 0.3  0 0.2 0 0 0.2  0 0 0.8 0 0.4' : '0 0 0 0 0  0 0.9 0 0 0.35  0 0 0.6 0 0.45'
103  return {
104    defs: `<filter id="${f}"><feColorMatrix type="matrix" values="${tint}  0 0 0 1 0"/></filter>`,
105    svg: [9, 6, 3]
106      .map((dx, i) => `<g class="sandy" style="animation-delay:${-i * 0.2}s" opacity="${(0.12 + i * 0.1).toFixed(2)}" filter="url(#${f})" transform="translate(${-dx} 0)">${figure}</g>`)
107      .join(''),
108  }
109}
110
111// Night City along the bottom of a card: towers with blinking windows and an AV
112// crossing; Arasaka's tower over the corpo skin, Lucy's moon over hers.
113export const skyline = (W: number, y0: number, h: number, egg: Egg): string => {
114  const c = COLORS[egg]
115  let seed = 7
116  const rnd = () => ((seed = (seed * 9301 + 49297) % 233280) / 233280)
117  const towers: string[] = []
118  const windows: string[] = []
119  let x = 0
120  while (x < W) {
121    const w = 10 + Math.floor(rnd() * 22)
122    const th = 8 + Math.floor(rnd() * (h - 10))
123    towers.push(`<rect x="${x}" y="${y0 + h - th}" width="${w - 2}" height="${th}"/>`)
124    for (let k = 0; k < 2; k++) {
125      if (rnd() < 0.6) windows.push(`<rect class="win" style="animation-delay:${(rnd() * -3).toFixed(1)}s" x="${x + 2 + Math.floor(rnd() * (w - 6))}" y="${y0 + h - th + 3 + Math.floor(rnd() * Math.max(1, th - 6))}" width="2" height="2" fill="${rnd() < 0.5 ? c.a : c.b}"/>`)
126    }
127    x += w
128  }
129  let extra = ''
130  if (egg === 'corpo') {
131    // The tower: tall, stepped, a red beacon on top.
132    const tx = Math.round(W * 0.72)
133    extra = `<path d="M${tx} ${y0 + h}V${y0 + 6}l6 -6l6 6V${y0 + h}Z"/><rect class="beacon" x="${tx + 5}" y="${y0 - 2}" width="2" height="2" fill="#FF1E3C"/>`
134  } else if (egg === 'samurai') {
135    // A stage: two spotlights sweeping down on the band.
136    const sx = Math.round(W * 0.7)
137    extra = `<path d="M${sx} ${y0 - 4}l-26 ${h + 4}h20z" fill="#FFD400" fill-opacity=".18"/><path d="M${sx + 40} ${y0 - 4}l26 ${h + 4}h-20z" fill="#E10600" fill-opacity=".22"/><rect x="${sx - 30}" y="${y0 + h - 4}" width="100" height="4" fill="#2A0A0A"/>`
138  } else if (egg === 'netrun') {
139    // Clear of the CACHE readout on the right, and small enough for a 14px band.
140    const mx = Math.round(W * 0.55)
141    const r = Math.min(h * 0.36, 8)
142    const my = y0 + r + 1
143    extra = `<g class="moon"><circle cx="${mx}" cy="${my.toFixed(1)}" r="${r.toFixed(1)}" fill="#FFD6F2" fill-opacity=".55"/><circle cx="${(mx - r * 0.3).toFixed(1)}" cy="${(my - r * 0.25).toFixed(1)}" r="${(r * 0.3).toFixed(1)}" fill="#E6A8D8" fill-opacity=".5"/></g>`
144  }
145  const av = `<g class="av" style="--sw:${W + 60}px"><rect x="0" y="${y0 + 3}" width="9" height="2" rx="1" fill="${c.dim}"/><rect x="8" y="${y0 + 3}" width="2" height="2" fill="${c.c}"/></g>`
146  return `<g opacity=".22" fill="${egg === 'corpo' ? '#2A0A0F' : egg === 'samurai' ? '#2A0C0C' : egg === 'netrun' ? '#2A1A3A' : '#1A2230'}">${extra}${towers.join('')}</g><g opacity=".5">${windows.join('')}</g>${av}`
147}
148
149// The cheer when a job is done: Night City's "preem", the moon, the corporation's stamp.
150export const celebration = (W: number, H: number, egg: Egg, font: string): string => {
151  const c = COLORS[egg]
152  if (egg === 'netrun') {
153    return `<g class="rise"><circle cx="${W - 70}" cy="${H / 2}" r="${H * 0.36}" fill="#FFD6F2"/><circle cx="${W - 78}" cy="${H / 2 - 6}" r="${H * 0.08}" fill="#E6A8D8"/></g>
154<text class="rise" x="${W - 130}" y="${H / 2 + 5}" text-anchor="end" font-family="${font}" font-size="15" font-weight="800" letter-spacing="1.5" fill="${c.a}">TO THE MOON ☾</text>`
155  }
156  const label = egg === 'corpo' ? 'PROTOCOL COMPLETE · 任務完了' : egg === 'samurai' ? 'NEVER FADE AWAY' : 'PREEM. JOB DONE, CHOOM'
157  const w = textW(label, 15, 1.5) + 30
158  if (egg === 'corpo') font = `${font.replace(/,?sans-serif$/, '')},${CJK_FONT}`
159  return `<g class="flash"><rect x="${(W - w) / 2}" y="${H / 2 - 13}" width="${w}" height="24" fill="${c.a}" transform="rotate(-2 ${W / 2} ${H / 2})"/>
160<text x="${W / 2}" y="${H / 2 + 5}" text-anchor="middle" font-family="${font}" font-size="15" font-weight="900" letter-spacing="1.5" fill="${inkOn(c.a)}" transform="rotate(-2 ${W / 2} ${H / 2})">${esc(label)}</text></g>`
161}
162
163// A tiny code tag for a corner: SYS.2077 // 0x3F, and a katakana line.
164export const sysTag = (x: number, y: number, egg: Egg, font: string, anchor = 'end'): string => {
165  const c = COLORS[egg]
166  const code = egg === 'corpo' ? 'ARS.2077 // 0x0A' : egg === 'netrun' ? 'NET.2076 // 0x4C' : egg === 'samurai' ? 'SAM.2013 // 0x77' : 'SYS.2077 // 0x3F'
167  return `<text x="${x}" y="${y}" text-anchor="${anchor}" font-family="${font}" font-size="7" letter-spacing=".8" fill="${c.dim}" fill-opacity=".8">${code}</text>`
168}
169// Japanese text needs a Japanese face: macOS, Windows, then Linux's Noto.
170export const CJK_FONT = "'Hiragino Sans','Hiragino Kaku Gothic ProN','PingFang SC','Yu Gothic UI','Meiryo','Noto Sans CJK JP',sans-serif"
171export const KATAKANA: Record<Egg, string> = { runners: 'ミッション・コントロール', netrun: 'ネットランナー', corpo: 'アラサカ', samurai: 'サムライ' }
172
173// Night City radio, one station an hour (names only).
174const STATIONS = ['89.7 GROWL FM', '88.9 PACIFIC DREAMS', '91.9 ROYAL BLUE', '92.9 NIGHT FM', '95.2 SAMIZDAT', '96.1 RITUAL FM', '98.7 BODY HEAT', '99.9 IMPULSE', '101.9 THE DIRGE', '103.5 RADIO PEBKAC', '106.9 30 PRINCIPALES', '107.3 MORRO ROCK', '107.5 DARK STAR', '89.3 RADIO VEXELSTROM']
175export const radio = (at: number): string => STATIONS[Math.floor(at / 3_600_000) % STATIONS.length] as string
176
177// Breach Protocol card for Mission Control: a 5×5 code matrix with the valid row lit,
178// a buffer of the job's steps and the daemons it uploads.
179const MATRIX = [
180  ['1C', 'BD', '55', 'E9', '7A'],
181  ['55', '7A', '1C', 'FF', 'BD'],
182  ['E9', '1C', 'BD', '55', 'FF'],
183  ['FF', '55', '7A', '1C', 'E9'],
184  ['BD', 'E9', 'FF', '7A', '1C'],
185]
186export const BREACH_H = 132
187export const breachCard = (W: number, done: number, total: number, isWorking: boolean, egg: Egg, font: string, frame: (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string): string => {
188  const c = COLORS[egg]
189  const x0 = 18
190  const y0 = 36
191  const cell = 32
192  const rh = 17
193  const row = total ? Math.min(4, done % 5) : 0
194  const grid = MATRIX.map((r, j) =>
195    r.map((code, i) => {
196      const lit = j === row
197      return `<text x="${x0 + i * cell + cell / 2}" y="${y0 + j * rh + 12.5}" text-anchor="middle" font-family="${font}" font-size="13" font-weight="700" fill="${lit ? c.a : c.dim}" fill-opacity="${lit ? 1 : 0.55}">${code}</text>`
198    }).join(''),
199  ).join('')
200  const band = `<rect x="${x0 - 2}" y="${y0 + row * rh}" width="${cell * 5 + 4}" height="${rh}" fill="${c.a}" fill-opacity=".12" stroke="${c.a}" stroke-opacity=".6"/>`
201  const sweep = isWorking ? `<rect class="bx" style="--bw:${cell * 5}px" x="${x0}" y="${y0 + row * rh}" width="${cell}" height="${rh}" fill="${c.a}" fill-opacity=".25"/>` : ''
202  const bx = x0 + cell * 5 + 36
203  const slots = Math.max(1, Math.min(8, total || 6, Math.floor((W - bx - 14) / 26)))
204  const fill = total ? Math.min(slots, Math.round((done / total) * slots)) : 0
205  const hasDaemons = W - bx >= 130
206  const buf = Array.from({ length: slots }, (_, i) =>
207    i < fill
208      ? `<rect x="${bx + i * 26}" y="${y0}" width="22" height="16" fill="${c.a}" fill-opacity=".9"/><text x="${bx + i * 26 + 11}" y="${y0 + 12}" text-anchor="middle" font-family="${font}" font-size="10" font-weight="800" fill="${inkOn(c.a)}">${(MATRIX[i % 5] as string[])[i % 5]}</text>`
209      : `<rect${i === fill && isWorking ? ' class="bslot"' : ''} x="${bx + i * 26 + 0.5}" y="${y0 + 0.5}" width="21" height="15" fill="none" stroke="${c.a}" stroke-opacity=".6" stroke-dasharray="3 2"/>`,
210  ).join('')
211  const daemons = (hasDaemons ? ['DATAMINE_V1', 'DATAMINE_V2', 'DATAMINE_V3'] : [])
212    .map((d, i) => {
213      const ok = total > 0 && done >= Math.ceil(((i + 1) * total) / 3)
214      return `<text x="${bx}" y="${y0 + 42 + i * 18}" font-family="${font}" font-size="11.5" font-weight="700" letter-spacing=".6" fill="${ok ? c.a : c.dim}">${ok ? '✓ ' : '▫\uFE0E '}${d}</text>`
215    })
216    .join('')
217  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BREACH_H}" viewBox="0 0 ${W} ${BREACH_H}"><style>${EGG_CSS}.cur{}@keyframes cur{50%{opacity:0}}.bslot{animation:cur 1s steps(1) infinite}@media (prefers-reduced-motion: reduce){.bslot{animation:none}}</style>
218${frame(0.5, 0.5, W - 1, BREACH_H - 1, `fill="${c.bg}" fill-opacity=".85" stroke="${c.a}" stroke-opacity=".35"`, 10)}
219<rect x="16" y="11" width="3" height="14" fill="${c.a}"/>
220<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${c.text}">BREACH PROTOCOL</text>
221${sysTag(W - 14, 22, egg, font)}
222${band}${sweep}${grid}
223<text x="${bx}" y="${y0 - 6}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".8" fill="${c.dim}">BUFFER ${fill}/${slots}</text>
224${buf}${daemons}
225<text x="${W - 14}" y="${BREACH_H - 10}" text-anchor="end" font-family="${font}" font-size="9" letter-spacing=".6" fill="${c.dim}">${isWorking ? 'UPLOADING DAEMONS…' : total && done >= total ? 'BREACH SUCCESSFUL' : 'AWAITING JACK-IN'}</text>
226</svg>`
227}
228
229// The Afterlife names a drink after every legend; Arasaka files the engram in Mikoshi.
230export type Legend = { task: string; costUsd: number; at: number; ms: number }
231export const LEGENDS_ROW = 18
232export const legendsHeight = (n: number): number => 46 + Math.max(1, n) * LEGENDS_ROW + 8
233export const legendsCard = (W: number, list: Legend[], egg: Egg, font: string, money: (usd: number) => string, frame: (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string): string => {
234  const c = COLORS[egg]
235  const H = legendsHeight(list.length)
236  // 7. a narrow card keeps just the place's name
237  const title = W < 460 ? (egg === 'corpo' ? 'MIKOSHI' : 'AFTERLIFE') : egg === 'corpo' ? 'MIKOSHI · ENGRAMS ON FILE' : 'AFTERLIFE · DRINKS NAMED AFTER LEGENDS'
238  const rows = list.length
239    ? list
240        .map((l, i) => {
241          const y = 52 + i * LEGENDS_ROW
242          const name = egg === 'corpo' ? `ENGRAM ${String(i + 1).padStart(3, '0')} — ${l.task}` : l.task
243          const room = Math.max(10, Math.floor((W - 160) / 6.6))
244          const chars = [...name]
245          const clipped = chars.length > room ? `${chars.slice(0, room - 1).join('')}…` : name
246          return `<text x="18" y="${y}" font-family="${font}" font-size="12" font-weight="600" fill="${c.text}">${egg === 'corpo' ? '▣' : '🍸'} ${esc(clipped)}</text>
247<line x1="18" y1="${y + 5}" x2="${W - 18}" y2="${y + 5}" stroke="${c.dim}" stroke-opacity=".35" stroke-dasharray="1 3"/>
248<text x="${W - 18}" y="${y}" text-anchor="end" font-family="${font}" font-size="12" font-weight="800" fill="${c.a}">${money(l.costUsd)}</text>`
249        })
250        .join('')
251    : `<text x="18" y="52" font-family="${font}" font-size="12" fill="${c.dim}">${egg === 'corpo' ? 'No engrams yet. Finish a job.' : 'No legends yet. Finish a job, choom.'}</text>`
252  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
253${frame(0.5, 0.5, W - 1, H - 1, `fill="${c.bg}" fill-opacity=".85" stroke="${c.a}" stroke-opacity=".3"`, 10)}
254<rect x="16" y="11" width="3" height="14" fill="${c.a}"/>
255<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${c.text}">${title}</text>
256${sysTag(W - 14, 22, egg, font)}
257${rows}
258</svg>`
259}
260
261// Words: the spinner talks like Night City, and the cheer on a long job.
262export const EGG_WORDS: Record<Egg, string[]> = {
263  runners: ['Jacking in', 'Breaching ICE', 'Uploading daemon', 'Pinging the subnet', 'Chipping in', 'Calling a Delamain', 'Asking the fixer', 'Overclocking', 'Dodging NetWatch', 'Hitting the Afterlife', 'Checking the chrome', 'Scanning with Kiroshis'],
264  netrun: ['Diving into the Net', 'Chasing the moon', 'Slipping past ICE', 'Deep diving', 'Drifting through the Net', 'Riding the subway', 'Jacking in, choom', 'Cutting the cables', 'Breaching the Blackwall… not today'],
265  samurai: ['Tuning up', 'Plugging in the amp', 'Soundcheck', 'Burning corpo towers', 'Writing a riff', 'Playing the Atlantis', 'Chipping in', 'Loading the Malorian'],
266  corpo: ['Processing', 'Executing protocol', 'Consulting the board', 'Filing the engram', 'Securing assets', 'Dispatching Smasher', 'Reporting to Saburo', 'Running Soulkiller… diagnostics', 'Auditing the floor'],
267}
268export const EGG_TOOLS: Record<Egg, Record<string, string>> = {
269  runners: { Read: 'Scan', Edit: 'Rewire', MultiEdit: 'Rewire', Write: 'Burn chip', Bash: 'Quickhack', PowerShell: 'Quickhack', Grep: 'Ping', Glob: 'Datamine', Agent: 'Call a fixer', WebFetch: 'Jack in', WebSearch: 'Netsearch', ToolSearch: 'Visit Vik' },
270  netrun: { Read: 'Dive', Edit: 'Rewrite', MultiEdit: 'Rewrite', Write: 'Leave a trace', Bash: 'Breach', PowerShell: 'Breach', Grep: 'Ping', Glob: 'Map the Net', Agent: 'Call the crew', WebFetch: 'Pull', WebSearch: 'Search the Net', ToolSearch: 'Swap chrome' },
271  samurai: { Read: 'Read the setlist', Edit: 'Rewrite the riff', MultiEdit: 'Rewrite the riff', Write: 'Cut a track', Bash: 'Plug in', PowerShell: 'Plug in', Grep: 'Find the hook', Glob: 'Map the venue', Agent: 'Call the band', WebFetch: 'Pull a sample', WebSearch: 'Scout the crowd', ToolSearch: 'Swap guitars' },
272  corpo: { Read: 'Audit', Edit: 'Amend', MultiEdit: 'Amend', Write: 'File', Bash: 'Execute', PowerShell: 'Execute', Grep: 'Surveil', Glob: 'Catalogue', Agent: 'Dispatch', WebFetch: 'Retrieve', WebSearch: 'Surveil the Net', ToolSearch: 'Requisition' },
273}
274export const GREETING: Record<Egg, string> = {
275  runners: 'Wake up, Samurai. We have a city to burn.',
276  netrun: 'Wake up, choom. The moon is waiting.',
277  corpo: 'Arasaka welcomes you, employee. Loyalty is rewarded.',
278  samurai: '🎸 SAMURAI reunited. Never fade away.',
279}
280
hooks/extras.ts 433 lines
1// Extras for Mission Control: Street Cred (XP, levels, badges), the Braindance
2// replay of a session's tool calls, and the boot-up strip. Pure functions and SVG
3// strings; the hooks module keeps the state and decides when each shows.
4
5// --- Street Cred ---
6
7export type Cred = {
8  xp: number
9  jobs: number
10  longJobs: number
11  agents: number
12  crewJobs: number
13  nightJobs: number
14  chromed: boolean
15  samurai: boolean
16  badges: string[]
17}
18export const BLANK_CRED: Cred = { xp: 0, jobs: 0, longJobs: 0, agents: 0, crewJobs: 0, nightJobs: 0, chromed: false, samurai: false, badges: [] }
19
20// Level L needs 25·L·(L−1) XP: 0, 50, 150, 300, 500, 750…
21export const levelOf = (xp: number): number => Math.max(1, Math.floor((1 + Math.sqrt(1 + Math.max(0, xp) / 6.25)) / 2))
22export const xpFor = (level: number): number => 25 * level * (level - 1)
23
24// Badge ids are what the store keeps; the names are what Night City calls them.
25export type Badge = { id: string; name: string; icon: string; how: string; progress: (c: Cred) => number }
26const share = (n: number, of: number) => Math.max(0, Math.min(1, n / of))
27export const BADGES: Badge[] = [
28  { id: 'choom', name: 'FIRST GIG', icon: '✦', how: 'Finish your first gig', progress: c => share(c.jobs, 1) },
29  { id: 'merc', name: "FIXER'S FAVORITE", icon: '◆', how: 'Finish 10 gigs', progress: c => share(c.jobs, 10) },
30  { id: 'edgerunner', name: 'ICONIC', icon: '★', how: 'Finish 50 gigs', progress: c => share(c.jobs, 50) },
31  { id: 'legend', name: 'THE BIG LEAGUES', icon: '♛', how: '10 major gigs (3+ minutes each)', progress: c => share(c.longJobs, 10) },
32  { id: 'netrunner', name: 'NETRUNNER', icon: '⌁', how: 'Call 100 sub-agents', progress: c => share(c.agents, 100) },
33  { id: 'crewboss', name: 'FIXER', icon: '⚑', how: 'Run a gig with 5+ sub-agents', progress: c => share(c.crewJobs, 1) },
34  { id: 'chromed', name: 'CYBERPSYCHO', icon: '⚙', how: 'Push the context past 90%', progress: c => (c.chromed ? 1 : 0) },
35  { id: 'owl', name: 'GRAVEYARD SHIFT', icon: '☾', how: 'Finish a gig after midnight', progress: c => share(c.nightJobs, 1) },
36  { id: 'samurai', name: 'SAMURAI', icon: '♪', how: 'Find the secret skin', progress: c => (c.samurai ? 1 : 0) },
37]
38
39// The badges a cred has just earned, and the cred with them recorded.
40export const award = (c: Cred): { cred: Cred; fresh: Badge[] } => {
41  const fresh = BADGES.filter(b => !c.badges.includes(b.id) && b.progress(c) >= 1)
42  return { cred: fresh.length ? { ...c, badges: [...c.badges, ...fresh.map(b => b.id)] } : c, fresh }
43}
44
45export const cleanCred = (v: unknown): Cred => {
46  const r = (v && typeof v === 'object' ? v : {}) as Partial<Cred>
47  const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0)
48  return {
49    xp: n(r.xp),
50    jobs: n(r.jobs),
51    longJobs: n(r.longJobs),
52    agents: n(r.agents),
53    crewJobs: n(r.crewJobs),
54    nightJobs: n(r.nightJobs),
55    chromed: r.chromed === true,
56    samurai: r.samurai === true,
57    badges: Array.isArray(r.badges) ? [...new Set(r.badges.filter((b): b is string => typeof b === 'string' && BADGES.some(x => x.id === b)))] : [],
58  }
59}
60
61export type Frame = (x: number, y: number, w: number, h: number, attrs: string, r?: number) => string
62type Theme = { accent: string; accent2: string; done: string; text: string; subtext: string; muted: string; tile: string; track: string; line: string; alert?: string }
63
64const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
65
66// '#rgb' or '#rrggbb' as [r, g, b] in 0..255; null for anything else.
67const rgbOf = (hex: string): [number, number, number] | null => {
68  const m = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(hex.trim())
69  if (!m) return null
70  const h = (m[1] as string).length === 3 ? [...(m[1] as string)].map(ch => ch + ch).join('') : (m[1] as string)
71  return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
72}
73// WCAG relative luminance, 0 (black) .. 1 (white).
74const luminance = (hex: string): number => {
75  const rgb = rgbOf(hex)
76  if (!rgb) return 0
77  const [r, g, b] = rgb.map(v => {
78    const s = v / 255
79    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
80  }) as [number, number, number]
81  return 0.2126 * r + 0.7152 * g + 0.0722 * b
82}
83// The ink for text drawn on a solid `bg`: near-black on light fills, white on dark ones.
84export const inkOn = (bg: string): string => (luminance(bg) > 0.18 ? '#111111' : '#FFFFFF')
85// Two colours a glance can tell apart ("redmean" distance; unparseable ones count as apart).
86const isApart = (a: string, b: string): boolean => {
87  const x = rgbOf(a)
88  const y = rgbOf(b)
89  if (!x || !y) return a.toLowerCase() !== b.toLowerCase()
90  const rm = (x[0] + y[0]) / 2
91  const [dr, dg, db] = [x[0] - y[0], x[1] - y[1], x[2] - y[2]]
92  return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db) >= 150
93}
94// Glyphs macOS may draw as colour emoji, held to their text form with U+FE0E.
95const asText = (s: string): string => s.replace(/([⚙▶◀⏮⚠▫‼])(?!\uFE0E)/gu, '$1\uFE0E')
96
97// Rough advance of UI text in px (bold, a touch wide on purpose): enough to keep
98// a label inside its slot at narrow widths.
99const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿가-힣豈-﫿!-⦆☀-➿\u{1F300}-\u{1FAFF}]/u
100export const textW = (s: string, size: number, ls = 0): number =>
101  [...s].reduce((w, ch) => (ch === '\uFE0E' ? w : w + (WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.32 : /[A-Z@%MW]/.test(ch) ? 0.74 : 0.6) * size + ls), 0)
102// `s` cut to fit `maxW`, with an ellipsis when cut; '' when not even that fits.
103export const clipW = (s: string, size: number, maxW: number, ls = 0): string => {
104  if (textW(s, size, ls) <= maxW) return s
105  const chars = [...s]
106  while (chars.length && textW(`${chars.join('')}…`, size, ls) > maxW) chars.pop()
107  return chars.length ? `${chars.join('').trimEnd()}…` : ''
108}
109// The largest size from `max` down to `min` at which `s` fits `maxW`.
110const fitSize = (s: string, max: number, min: number, maxW: number, ls = 0): number => {
111  let size = max
112  while (size > min && textW(s, size, ls) > maxW) size -= 0.5
113  return size
114}
115
116// The replay's selection when the timeline drops its `dropped` oldest beats: it
117// follows its beat, and holds on the oldest kept one if its own was dropped.
118export const shiftPick = (pick: number, dropped: number): number => (pick < 0 || dropped <= 0 ? pick : Math.max(0, pick - dropped))
119// One step back (-1) or forward (+1) through `n` beats; -1 is "live" (the newest).
120export const stepPick = (pick: number, n: number, d: -1 | 1): number => {
121  if (n <= 0) return -1
122  const at = pick < 0 || pick >= n ? n - 1 : pick
123  if (d > 0) return pick < 0 || at + 1 >= n ? -1 : at + 1
124  return Math.max(0, at - 1)
125}
126
127// Ranks by level, the way Night City sizes you up.
128export const rankOf = (level: number): string =>
129  level >= 20 ? 'LEGEND OF THE AFTERLIFE' : level >= 15 ? 'EDGERUNNER' : level >= 10 ? 'SOLO' : level >= 7 ? 'MERC' : level >= 4 ? 'PUNK' : level >= 2 ? 'STREET RAT' : 'NOBODY'
130// SAMURAI climbs a Rockerboy's ladder instead.
131export const rockerRankOf = (level: number): string =>
132  level >= 20 ? 'LEGEND' : level >= 10 ? 'ROCKERBOY' : level >= 4 ? 'HEADLINER' : level >= 2 ? 'ROADIE' : 'OPENING ACT'
133
134const hexPath = (cx: number, cy: number, r: number): string =>
135  Array.from({ length: 6 }, (_, i) => {
136    const a = (Math.PI / 3) * i - Math.PI / 2
137    return `${i ? 'L' : 'M'}${(cx + r * Math.cos(a)).toFixed(1)} ${(cy + r * Math.sin(a)).toFixed(1)}`
138  }).join('') + 'Z'
139
140const CRED_CSS = `.spin{animation:spin 9s linear infinite;transform-box:fill-box;transform-origin:center}@keyframes spin{to{transform:rotate(360deg)}}
141.pulse{animation:pulse 2.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.45}}
142.sweep{animation:sweep 2.8s ease-in-out infinite}@keyframes sweep{from{transform:translateX(-40px);opacity:0}25%{opacity:.9}to{transform:translateX(var(--sw));opacity:0}}
143.fresh{animation:fresh 1.4s ease-in-out infinite}@keyframes fresh{50%{opacity:.35}}
144.gl1{animation:gl 5s steps(1) infinite}@keyframes gl{0%,93%,100%{transform:translate(0,0)}94%{transform:translate(-2px,0)}96%{transform:translate(2px,1px)}}
145@media (prefers-reduced-motion: reduce){.spin,.pulse,.sweep,.fresh,.gl1{animation:none}.sweep{opacity:0}}`
146
147const MED = 46
148// Below this width the stats leave the emblem's side for two rows under it.
149const NARROW = 360
150// The head: emblem, rank, bar and stats; the medallions follow in cells at least
151// 100px wide, so the longest name ("AFTERLIFE LEGEND") keeps clear of its neighbours.
152const credHead = (W: number): number => (W < NARROW ? 206 : 146)
153// (the medallion rows start a little lower, under the "NEXT" line)
154const credPer = (W: number): number => Math.max(1, Math.floor((W - 28) / 100))
155export const credHeight = (W: number): number => credHead(W) + 20 + Math.ceil(BADGES.length / credPer(W)) * 70
156export const credCard = (W: number, c: Cred, t: Theme, font: string, frame: Frame, title: string, kana = '', ranks: (level: number) => string = rankOf): string => {
157  const H = credHeight(W)
158  const head = credHead(W)
159  const isNarrow = W < NARROW
160  const level = levelOf(c.xp)
161  const lo = xpFor(level)
162  const hi = xpFor(level + 1)
163  const share = Math.max(0, Math.min(1, (c.xp - lo) / Math.max(1, hi - lo)))
164  // The emblem: a hex with the level, a ring of 12 segments lit by the progress, a scan ring turning.
165  const cx = 66
166  const cy = 72
167  const segs = Array.from({ length: 12 }, (_, i) => {
168    const a0 = (Math.PI * 2 * i) / 12 - Math.PI / 2 + 0.06
169    const a1 = (Math.PI * 2 * (i + 1)) / 12 - Math.PI / 2 - 0.06
170    const r = 46
171    const on = i < Math.round(share * 12)
172    return `<path d="M${(cx + r * Math.cos(a0)).toFixed(1)} ${(cy + r * Math.sin(a0)).toFixed(1)}A${r} ${r} 0 0 1 ${(cx + r * Math.cos(a1)).toFixed(1)} ${(cy + r * Math.sin(a1)).toFixed(1)}" fill="none" stroke="${on ? t.accent : t.line}" stroke-width="5"${on && i === Math.round(share * 12) - 1 ? ' class="pulse"' : ''}/>`
173  }).join('')
174  const emblem = `<g filter="url(#cg)"><path d="${hexPath(cx, cy, 34)}" fill="${t.accent}" fill-opacity=".14" stroke="${t.accent}" stroke-width="2"/></g>
175<path d="${hexPath(cx, cy, 27)}" fill="none" stroke="${t.accent}" stroke-opacity=".35"/>
176<circle class="spin" cx="${cx}" cy="${cy}" r="54" fill="none" stroke="${t.accent2}" stroke-opacity=".55" stroke-width="1" stroke-dasharray="3 9 22 9"/>
177${segs}
178<text x="${cx + 1.5}" y="${cy + 13}" text-anchor="middle" font-family="${font}" font-size="34" font-weight="900" fill="${t.accent2}" fill-opacity=".55" font-variant-numeric="tabular-nums">${level}</text>
179<text x="${cx}" y="${cy + 12}" text-anchor="middle" font-family="${font}" font-size="34" font-weight="900" fill="${t.text}" font-variant-numeric="tabular-nums">${level}</text>
180<text x="${cx}" y="${cy - 17}" text-anchor="middle" font-family="${font}" font-size="9" font-weight="900" letter-spacing="1.5" fill="${t.accent}">LVL</text>`
181  // The rank and the XP: an angled, segmented bar with a light running along it.
182  const rx = 136
183  const barW = Math.max(80, W - rx - 18)
184  const n = Math.max(10, Math.floor(barW / 12))
185  const sw = barW / n
186  const lit = Math.round(share * n)
187  const bar = Array.from({ length: n }, (_, i) => {
188    const x = rx + i * sw
189    const on = i < lit
190    return `<path d="M${(x + 3).toFixed(1)} 84h${(sw - 3).toFixed(1)}l-3 10h-${(sw - 3).toFixed(1)}z" fill="${on ? t.accent : t.track}"${on ? ` fill-opacity="${(0.5 + 0.5 * ((i + 1) / Math.max(1, lit))).toFixed(2)}"` : ''}/>`
191  }).join('')
192  const rank = ranks(level)
193  const stat = (x: number, y: number, slot: number, k: string, v: string) =>
194    `<text x="${x.toFixed(1)}" y="${y}" font-family="${font}" font-size="9" font-weight="800" letter-spacing=".8" fill="${t.muted}">${esc(clipW(k, 9, slot - 6, 0.8))}</text><text x="${x.toFixed(1)}" y="${y + 13}" font-family="${font}" font-size="13" font-weight="800" fill="${v === '0' ? t.muted : t.text}" font-variant-numeric="tabular-nums">${v === '0' ? '—' : esc(clipW(v, 13, slot - 6))}</text>`
195  const statList: [string, string][] = [
196    ['GIGS', `${c.jobs}`],
197    ['MAJOR GIGS', `${c.longJobs}`],
198    ['AGENTS', `${c.agents}`],
199    ['BADGES', `${c.badges.length}/${BADGES.length}`],
200  ]
201  // Wide: one row beside the emblem. Narrow: two rows of two under it, edge to edge.
202  const stats = isNarrow
203    ? statList.map(([k, v], i) => stat(14 + (i % 2) * ((W - 28) / 2), 152 + Math.floor(i / 2) * 28, (W - 28) / 2, k, v)).join('')
204    : statList.map(([k, v], i) => stat(rx + i * Math.min(110, barW / 4), 116, Math.min(110, barW / 4), k, v)).join('')
205  // The right column's text, sized to its room: shrunk first, then cut.
206  const room = W - rx - 14
207  const rankSize = fitSize(rank, rank.length > 14 ? 17 : 24, 10, room, 1.5)
208  const rankText = clipW(rank, rankSize, room, 1.5)
209  const titleSize = fitSize(title, 10, 7, room, 2)
210  const titleText = clipW(title, titleSize, room, 2)
211  const showKana = kana !== '' && textW(titleText, titleSize, 2) + 8 + textW(kana, 7, 1) <= room
212  const toGo = hi - c.xp
213  const xpTail = textW(`${c.xp} XP · ${toGo} to level ${level + 1}`, 11) <= room ? ` XP · ${toGo} to level ${level + 1}` : textW(`${c.xp} XP · +${toGo}`, 11) <= room ? ` XP · +${toGo}` : ' XP'
214  // The medallions: hexes with an icon; earned ones glow, locked ones are dark with a "?".
215  const per = credPer(W)
216  const cell = (W - 28) / per
217  const meds = BADGES.map((b, i) => {
218    const on = c.badges.includes(b.id)
219    const mx = 14 + (i % per) * cell + cell / 2
220    const my = head + 40 + Math.floor(i / per) * 70
221    const label = on ? b.name : 'CLASSIFIED'
222    const labelSize = fitSize(label, 8.5, 6.5, cell - 8, 0.6)
223    const p = on ? 1 : b.progress(c)
224    const ring = !on && p > 0 ? `<path d="${hexPath(mx, my, MED / 2)}" pathLength="100" fill="none" stroke="${t.accent}" stroke-width="2" stroke-dasharray="${(p * 100).toFixed(1)} 100"/>` : ''
225    return `<g${on ? ' filter="url(#cg)"' : ''}><path d="${hexPath(mx, my, MED / 2)}" fill="${on ? t.accent : t.tile}" fill-opacity="${on ? 0.2 : 1}" stroke="${on ? t.accent : t.line}" stroke-width="${on ? 1.8 : 1}"${on ? '' : ' stroke-dasharray="3 3"'}/></g>${ring}
226<text x="${mx}" y="${my + 7}" text-anchor="middle" font-family="'Apple Symbols','Segoe UI Symbol','Noto Sans Symbols 2',sans-serif" font-size="19" fill="${on ? t.accent : t.text}" fill-opacity="${on ? 1 : 0.13}">${asText(b.icon)}<title>${esc(b.name)}: ${esc(b.how)}${on ? '' : ` (${Math.round(p * 100)}%)`}</title></text>
227<text x="${mx}" y="${my + MED / 2 + 15}" text-anchor="middle" font-family="${font}" font-size="${labelSize}" font-weight="800" letter-spacing=".6" fill="${on ? t.text : t.muted}">${esc(clipW(label, labelSize, cell - 8, 0.6))}</text>`
228  }).join('')
229  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>${CRED_CSS}</style>
230<defs><filter id="cg" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
231<radialGradient id="cbg" cx="${cx}" cy="${cy}" r="80" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${t.accent}" stop-opacity=".12"/><stop offset="1" stop-color="${t.accent}" stop-opacity="0"/></radialGradient>
232<clipPath id="cxb"><rect x="${rx}" y="84" width="${barW.toFixed(1)}" height="10"/></clipPath></defs>
233${frame(0.5, 0.5, W - 1, H - 1, `fill="${t.tile}" stroke="${t.accent}" stroke-opacity=".35"`, 10)}
234<circle cx="${cx}" cy="${cy}" r="80" fill="url(#cbg)"/>
235${emblem}
236<text x="${rx}" y="26" font-family="${font}" font-size="${titleSize}" font-weight="800" letter-spacing="2" fill="${t.accent2}">${esc(titleText)}${showKana ? `<tspan dx="8" font-size="7" letter-spacing="1" fill-opacity=".55">${esc(kana)}</tspan>` : ''}</text>
237<g class="gl1"><text x="${rx}" y="56" font-family="${font}" font-size="${rankSize}" font-weight="900" letter-spacing="1.5" fill="${t.accent}">${esc(rankText)}</text></g>
238<text x="${rx}" y="77" font-family="${font}" font-size="11" fill="${t.subtext}" font-variant-numeric="tabular-nums"><tspan font-weight="800" fill="${t.text}">${c.xp}</tspan>${xpTail}</text>
239${bar}
240<g clip-path="url(#cxb)"><rect class="sweep" style="--sw:${barW + 40}px" x="${rx}" y="83" width="28" height="12" fill="#ffffff" fill-opacity=".5" transform="skewX(-16)"/></g>
241${stats}
242<line x1="14" y1="${head + 0.5}" x2="${W - 14}" y2="${head + 0.5}" stroke="${t.line}"/>
243${(() => {
244  const next = BADGES.filter(b => !c.badges.includes(b.id)).sort((a, b2) => b2.progress(c) - a.progress(c))[0]
245  const line = next ? `NEXT: ${next.name} · ${next.how}` : 'ALL BADGES EARNED. PREEM.'
246  return `<text x="${W - 14}" y="${head + 12}" text-anchor="end" font-family="${font}" font-size="8.5" font-weight="800" letter-spacing=".8" fill="${t.muted}">${esc(clipW(line, 8.5, W - 28, 0.8))}</text>`
247})()}
248${meds}
249</svg>`
250}
251
252// --- Braindance: a replay of the session's tool calls, styled like the BD editor ---
253
254export type Beat = { at: number; tool: string; text: string; who: string }
255export const BEATS_KEPT = 160
256
257const kindOf = (tool: string): 'read' | 'edit' | 'run' | 'agent' | 'web' | 'other' =>
258  /^(Read|Grep|Glob|LS)$/.test(tool) ? 'read'
259  : /^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) ? 'edit'
260  : /^(Bash|PowerShell)$/.test(tool) ? 'run'
261  : tool === 'Agent' ? 'agent'
262  : /^Web/.test(tool) ? 'web'
263  : 'other'
264
265// The BD editor's layers: what you saw, what you heard, what ran hot.
266type Layer = 'VISUAL' | 'AUDIO' | 'THERMAL'
267const LAYER: Record<string, Layer> = { read: 'VISUAL', other: 'VISUAL', web: 'AUDIO', agent: 'AUDIO', edit: 'THERMAL', run: 'THERMAL' }
268const layerName = (kind: string): Layer => LAYER[kind] ?? 'VISUAL'
269// The game's own colours, for a caller without a theme.
270const GAME: Record<Layer, string> = { VISUAL: '#FCEE0A', AUDIO: '#2DE2E6', THERMAL: '#FF5A1F' }
271// The skin's colours for the layers: accent, accent2, alert (or accent). When one
272// would read as another's colour, it takes the text colour instead.
273export const layerColors = (t: Theme): Record<Layer, string> => {
274  const visual = t.accent
275  const audio = isApart(t.accent2, visual) ? t.accent2 : t.text
276  const hot = t.alert ?? t.accent
277  const thermal = isApart(hot, visual) && isApart(hot, audio) ? hot : t.text
278  return { VISUAL: visual, AUDIO: audio, THERMAL: thermal }
279}
280export const layerOf = (tool: string, t?: Theme): { name: string; color: string } => {
281  const name = layerName(kindOf(tool))
282  return { name, color: t ? layerColors(t)[name] : GAME[name] }
283}
284
285// Up to five lanes: the Manager's, then whoever acted, in order of appearance.
286const bdRows = (beats: Beat[]): string[] => ['Manager', ...[...new Set(beats.map(b => b.who))].filter(w => w !== 'Manager')].slice(0, 5)
287// The card's height for these beats: header and ruler, one 17px lane per row shown, the detail line.
288export const bdHeight = (beats: Beat[]): number => 91 + 17 * bdRows(beats).length
289// The tallest card (five lanes); the default room to keep for it.
290export const BD_H = 176
291export const braindanceCard = (W: number, beats: Beat[], pick: number, t: Theme, font: string, frame: Frame, title: string, _tone?: (k: string) => string): string => {
292  // The layers in the skin's colours; a caller may pass its own.
293  const colors = layerColors(t)
294  const tone = (k: string) => (_tone ? _tone(k) : colors[layerName(k)])
295  const H = bdHeight(beats)
296  const x0 = 104
297  const x1 = W - 16
298  const trackW = Math.max(40, x1 - x0)
299  const rows = bdRows(beats)
300  const t0 = beats[0]?.at ?? 0
301  const t1 = Math.max(t0 + 1, beats.at(-1)?.at ?? t0 + 1)
302  const xOf = (at: number) => x0 + ((at - t0) / (t1 - t0)) * trackW
303  const yOf = (w: string) => 58 + Math.max(0, rows.indexOf(w)) * 17
304  const sel = beats.length ? Math.max(0, Math.min(beats.length - 1, pick < 0 ? beats.length - 1 : pick)) : -1
305  // Timecode from the start of the recording, like the editor's: 00:06:25.
306  const time = (at: number) => {
307    const sec = Math.max(0, Math.round((at - t0) / 1000))
308    return `${String(Math.floor(sec / 3600)).padStart(2, '0')}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`
309  }
310  // A ruler with ticks and times, like the editor's.
311  const ticks = Array.from({ length: 11 }, (_, i) => {
312    const x = x0 + (trackW * i) / 10
313    const major = i % 5 === 0
314    const isLabelled = major && beats.length > 0 && (i !== 5 || trackW >= 150)
315    return `<line x1="${x.toFixed(1)}" y1="${major ? 38 : 41}" x2="${x.toFixed(1)}" y2="45" stroke="${t.muted}" stroke-opacity="${major ? 0.8 : 0.4}"/>${isLabelled ? `<text x="${x.toFixed(1)}" y="35" text-anchor="${i === 0 ? 'start' : i === 10 ? 'end' : 'middle'}" font-family="${font}" font-size="8.5" fill="${t.muted}" font-variant-numeric="tabular-nums">${time(t0 + ((t1 - t0) * i) / 10)}</text>` : ''}`
316  }).join('')
317  const lanes = rows
318    .map((w, i) => {
319      const y = yOf(w)
320      return `<rect x="${x0}" y="${y - 7}" width="${trackW}" height="14" fill="${t.track}" fill-opacity="${i % 2 ? 0.35 : 0.6}"/>
321<text x="16" y="${y + 3.5}" font-family="${font}" font-size="10" font-weight="700" letter-spacing=".4" fill="${t.subtext}">${esc(clipW(w.toUpperCase(), 10, x0 - 22, 0.4))}</text>`
322    })
323    .join('')
324  const s = sel >= 0 ? (beats[sel] as Beat) : null
325  // Each layer has its own mark, so it reads without its colour: a bar for VISUAL,
326  // a diamond for AUDIO, a triangle for THERMAL. The picked beat's is larger and outlined.
327  const mark = (b: Beat, isPicked: boolean): string => {
328    const kind = kindOf(b.tool)
329    const x = xOf(b.at)
330    const y = yOf(b.who)
331    const fill = `fill="${tone(kind)}"${isPicked ? ` stroke="${t.text}" stroke-width="1"` : ' fill-opacity=".85"'}`
332    const hh = isPicked ? 7 : 6
333    const layer = layerName(kind)
334    if (layer === 'AUDIO') {
335      const hw = isPicked ? 4 : 3
336      return `<path d="M${x.toFixed(1)} ${y - hh}l${hw} ${hh}l-${hw} ${hh}l-${hw} -${hh}z" ${fill}/>`
337    }
338    if (layer === 'THERMAL') {
339      const hw = isPicked ? 4.5 : 3.5
340      return `<path d="M${x.toFixed(1)} ${y - hh}l${hw} ${hh * 2}h-${hw * 2}z" ${fill}/>`
341    }
342    const w = isPicked ? 5 : 3
343    return `<rect x="${(x - w / 2).toFixed(1)}" y="${y - hh}" width="${w}" height="${hh * 2}" rx="1" ${fill}/>`
344  }
345  const marks = beats
346    .filter(b => rows.includes(b.who))
347    .map(b => mark(b, b === s))
348    .join('')
349  const bottom = 58 + (rows.length - 1) * 17 + 9
350  const head = s
351    ? `<rect x="${(xOf(s.at) - 8).toFixed(1)}" y="46" width="16" height="${bottom - 46}" fill="${t.accent}" fill-opacity=".10"/>
352<line x1="${xOf(s.at).toFixed(1)}" y1="44" x2="${xOf(s.at).toFixed(1)}" y2="${bottom}" stroke="${t.accent}" stroke-width="1.6" filter="url(#bg)"/>
353<path d="M${xOf(s.at).toFixed(1)} 46l-5 -7h10z" fill="${t.accent}"/>`
354    : ''
355  // The selected beat's line, fitted to the box: the index always, then the tool,
356  // who did it, the time, and the text with whatever room is left.
357  const detail = (() => {
358    const box = `<rect x="14" y="${H - 34}" width="${W - 28}" height="22" rx="3" fill="${t.accent}" fill-opacity=".08" stroke="${t.accent}" stroke-opacity=".3"/>`
359    if (!s) {
360      const empty = W >= 520 ? 'No recording yet: every tool call lands on the timeline as the session runs.' : 'No recording yet.'
361      return `<text x="22" y="${H - 19}" font-family="${font}" font-size="12" fill="${t.muted}">${esc(clipW(empty, 12, W - 44))}</text>`
362    }
363    const SEP = 10
364    let left = W - 44
365    const idx = `◆ ${sel + 1}/${beats.length}`
366    left -= textW(idx, 12) + SEP
367    const tool = clipW(s.tool, 12, Math.max(0, Math.min(150, left * 0.5)))
368    if (tool) left -= textW(tool, 12) + SEP
369    const who = left >= 40 ? clipW(s.who, 12, Math.min(110, left * 0.6)) : ''
370    if (who) left -= textW(who, 12) + SEP
371    const clock = time(s.at)
372    const showTime = left >= textW(clock, 12) + SEP + 60
373    if (showTime) left -= textW(clock, 12) + SEP
374    const text = s.text && left >= 30 ? clipW(s.text, 12, left) : ''
375    const parts = [
376      `<tspan fill="${t.accent}" font-weight="900">${idx}</tspan>`,
377      showTime ? `<tspan fill="${t.subtext}" font-variant-numeric="tabular-nums">${clock}</tspan>` : '',
378      who ? `<tspan font-weight="800">${esc(who)}</tspan>` : '',
379      tool ? `<tspan fill="${tone(kindOf(s.tool))}" font-weight="700">${esc(tool)}</tspan>` : '',
380      text ? esc(text) : '',
381    ].filter(Boolean)
382    return `${box}
383<text x="22" y="${H - 19}" font-family="${font}" font-size="12" fill="${t.text}" xml:space="preserve">${parts.join('  ')}</text>`
384  })()
385  // The layer tabs; the selected beat's layer is lit.
386  const active = s ? layerOf(s.tool).name : 'VISUAL'
387  const legend = (['VISUAL', 'AUDIO', 'THERMAL'] as const)
388    .map((name, i) => {
389      const color = tone(name === 'VISUAL' ? 'read' : name === 'AUDIO' ? 'web' : 'edit')
390      const x = W - 14 - (3 - i) * 74
391      const on = name === active
392      return `<path d="M${x + 4} 10h64l-4 14h-64z" fill="${on ? color : 'none'}" fill-opacity="${on ? 0.9 : 0}" stroke="${color}" stroke-opacity="${on ? 1 : 0.55}"/><text x="${x + 34}" y="21" text-anchor="middle" font-family="${font}" font-size="9.5" font-weight="900" letter-spacing="1" fill="${on ? inkOn(color) : color}">${name}</text>`
393    })
394    .join('')
395  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>.rec{animation:rec 1.2s steps(1) infinite}@keyframes rec{50%{opacity:.2}}@media (prefers-reduced-motion: reduce){.rec{animation:none}}</style>
396<defs><filter id="bg" x="-200%" y="-10%" width="500%" height="120%"><feGaussianBlur stdDeviation="1.6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
397${frame(0.5, 0.5, W - 1, H - 1, `fill="${t.tile}" stroke="${t.accent}" stroke-opacity=".35"`, 10)}
398<rect x="16" y="11" width="3" height="14" fill="${t.accent}"/>
399<text x="25" y="23" font-family="${font}" font-size="14" font-weight="800" letter-spacing=".8" fill="${t.text}">${esc(title)}</text>
400<text class="rec" x="${(25 + textW(title, 14, 0.8) + 12).toFixed(1)}" y="22.5" font-family="${font}" font-size="9" font-weight="900" letter-spacing="1" fill="${t.accent2}">▶\uFE0E PLAYBACK</text>
401${W >= 520 && W - 236 > 25 + textW(title, 14, 0.8) + 100 ? legend : ''}
402${ticks}
403${lanes}${marks}${head}
404${detail}
405</svg>`
406}
407
408// --- Boot-up: a strip that plays its jack-in once, when the pane is drawn, then rests.
409// Its source never changes between redraws, so the animation does not restart.
410export const BOOT_H = 30
411export const bootStrip = (W: number, t: Theme, font: string, line: string, done: string): string => {
412  // The loading bar keeps to wide panes; the words shrink, then cut, to their room.
413  const hasBar = W >= 360
414  const room = (hasBar ? W - 132 - 12 : W - 12) - 12
415  const lineSize = fitSize(line, 11, 8, room - 10, 1.2)
416  const doneSize = fitSize(done, 11, 8, room, 1.2)
417  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BOOT_H}" viewBox="0 0 ${W} ${BOOT_H}"><style>
418.bt1{animation:bt1 1.8s steps(1) both}@keyframes bt1{0%{opacity:1}99%{opacity:1}100%{opacity:0}}
419.bt2{animation:bt2 1.8s steps(1) both}@keyframes bt2{0%{opacity:0}99%{opacity:0}100%{opacity:1}}
420.bar{animation:bar 1.6s steps(12) both}@keyframes bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}
421.bc{animation:bc 1s steps(1) infinite}@keyframes bc{50%{opacity:0}}
422.ty{transform-origin:0 0;animation:ty 1.4s steps(28) 1.8s both}@keyframes ty{from{transform:scaleX(0)}to{transform:scaleX(1)}}
423@media (prefers-reduced-motion: reduce){.bt1{animation:none;opacity:0}.bt2,.bar,.bc,.ty{animation:none}.bt2{opacity:1}}
424</style>
425<rect x="0.5" y="0.5" width="${W - 1}" height="${BOOT_H - 1}" rx="4" fill="${t.tile}" stroke="${t.line}"/>
426<g class="bt1"><text x="12" y="19" font-family="${font}" font-size="${lineSize}" font-weight="800" letter-spacing="1.2" fill="${t.accent2}">${esc(clipW(line, lineSize, room - 10, 1.2))}<tspan class="bc">▮</tspan></text></g>
427<defs><clipPath id="bty"><rect class="ty" x="0" y="0" width="${W}" height="${BOOT_H}"/></clipPath></defs>
428<g class="bt2" clip-path="url(#bty)"><text x="12" y="19" font-family="${font}" font-size="${doneSize}" font-weight="800" letter-spacing="1.2" fill="${t.done}">${esc(clipW(done, doneSize, room, 1.2))}</text></g>
429${hasBar ? `<rect x="${W - 132}" y="11" width="120" height="8" rx="1" fill="${t.track}"/>
430<rect class="bar" style="transform-origin:${W - 132}px 15px" x="${W - 132}" y="11" width="120" height="8" rx="1" fill="${t.accent}"/>` : ''}
431</svg>`
432}
433
hooks/richtext.ts 393 lines
1// Rich chat blocks for the skins: edit diffs, a reply split into its fences,
2// tables and math, LaTeX as Unicode, and Mermaid flowcharts and pies as SVG.
3// Pure functions: no engine calls, so they are tested on their own.
4
5// ---------------------------------------------------------------------------
6// Edit diffs: one unified hunk from an edit's old and new text, the lines both
7// share at the ends kept as context.
8
9export type Diff = { source: string; added: number; removed: number }
10
11export const unifiedDiff = (oldText: string, newText: string, context = 2): Diff => {
12  const a = oldText.split(/\r?\n/)
13  const b = newText.split(/\r?\n/)
14  let head = 0
15  while (head < a.length && head < b.length && a[head] === b[head]) head++
16  let tail = 0
17  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
18  const pre = a.slice(Math.max(0, head - context), head)
19  const removed = a.slice(head, a.length - tail)
20  const added = b.slice(head, b.length - tail)
21  const post = a.slice(a.length - tail, a.length - tail + context)
22  const start = head - pre.length + 1
23  const lines = [...pre.map(l => ` ${l}`), ...removed.map(l => `-${l}`), ...added.map(l => `+${l}`), ...post.map(l => ` ${l}`)]
24  const oldCount = pre.length + removed.length + post.length
25  const newCount = pre.length + added.length + post.length
26  return {
27    source: `@@ -${start},${oldCount} +${start},${newCount} @@\n${lines.join('\n')}`,
28    added: added.length,
29    removed: removed.length,
30  }
31}
32
33// ---------------------------------------------------------------------------
34// A reply, split into the blocks the skins draw themselves.
35
36export type Block =
37  | { kind: 'md'; text: string }
38  | { kind: 'code'; text: string; lang: string }
39  | { kind: 'mermaid'; text: string }
40  | { kind: 'math'; text: string }
41  | { kind: 'table'; text: string }
42
43const isTableRow = (l: string): boolean => /^\s*\|.*\|\s*$/.test(l)
44const isTableRule = (l: string): boolean => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(l)
45
46export const splitBlocks = (text: string): Block[] => {
47  const out: Block[] = []
48  const lines = text.split(/\r?\n/)
49  let md: string[] = []
50  const flush = () => {
51    if (md.join('').trim()) out.push({ kind: 'md', text: md.join('\n') })
52    md = []
53  }
54  for (let i = 0; i < lines.length; i++) {
55    const line = lines[i] ?? ''
56    const fence = /^(\s*)(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/.exec(line)
57    if (fence) {
58      const mark = fence[2] ?? '```'
59      const lang = (fence[3] ?? '').toLowerCase()
60      const body: string[] = []
61      let j = i + 1
62      while (j < lines.length && !(lines[j] ?? '').trim().startsWith(mark)) body.push(lines[j++] ?? '')
63      if (j >= lines.length) {
64        // An open fence (a reply still streaming) stays markdown.
65        md.push(line)
66        continue
67      }
68      flush()
69      out.push(lang === 'mermaid' ? { kind: 'mermaid', text: body.join('\n') } : lang === 'math' || lang === 'latex' || lang === 'tex' ? { kind: 'math', text: body.join('\n') } : { kind: 'code', text: body.join('\n'), lang })
70      i = j
71      continue
72    }
73    if (line.trim().startsWith('$$')) {
74      const body: string[] = []
75      const first = line.trim().slice(2)
76      if (first.endsWith('$$') && first.length > 2) {
77        flush()
78        out.push({ kind: 'math', text: first.slice(0, -2) })
79        continue
80      }
81      if (first) body.push(first)
82      let j = i + 1
83      while (j < lines.length && !(lines[j] ?? '').includes('$$')) body.push(lines[j++] ?? '')
84      if (j < lines.length) {
85        const last = (lines[j] ?? '').replace('$$', '').trim()
86        if (last) body.push(last)
87        flush()
88        out.push({ kind: 'math', text: body.join('\n') })
89        i = j
90        continue
91      }
92    }
93    if (isTableRow(line) && isTableRule(lines[i + 1] ?? '')) {
94      flush()
95      const rows = [line]
96      let j = i + 1
97      while (j < lines.length && (isTableRow(lines[j] ?? '') || isTableRule(lines[j] ?? ''))) rows.push(lines[j++] ?? '')
98      out.push({ kind: 'table', text: rows.join('\n') })
99      i = j - 1
100      continue
101    }
102    md.push(line)
103  }
104  flush()
105  return out
106}
107
108// ---------------------------------------------------------------------------
109// LaTeX as Unicode: Greek, operators, arrows, sub- and superscripts, fractions
110// and roots, so a formula reads as one in plain text.
111
112const SYMBOLS: Record<string, string> = {
113  alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ',
114  lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π', rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
115  Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
116  sum: '∑', prod: '∏', int: '∫', oint: '∮', infty: '∞', partial: '∂', nabla: '∇', sqrt: '√',
117  cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', approx: '≈', equiv: '≡', sim: '∼', propto: '∝',
118  to: '→', rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', Leftarrow: '⇐', leftrightarrow: '↔', iff: '⇔', implies: '⇒', mapsto: '↦',
119  in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', forall: '∀', exists: '∃', neg: '¬', land: '∧', lor: '∨',
120  ldots: '…', cdots: '⋯', dots: '…', circ: '∘', degree: '°', angle: '∠', perp: '⊥', parallel: '∥', hbar: 'ℏ', ell: 'ℓ',
121  quad: '  ', qquad: '    ', ',': ' ', ';': ' ', '!': '', left: '', right: '', displaystyle: '', limits: '',
122}
123const BLACKBOARD: Record<string, string> = { R: 'ℝ', N: 'ℕ', Z: 'ℤ', Q: 'ℚ', C: 'ℂ', P: 'ℙ' }
124const SUP: Record<string, string> = {
125  '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾',
126  a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ', w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', T: 'ᵀ',
127}
128const SUB: Record<string, string> = {
129  '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎',
130  a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ',
131}
132
133// The group after a script mark: `{...}` or one character.
134const takeGroup = (s: string, at: number): [string, number] => {
135  if (s[at] !== '{') return [s[at] ?? '', at + 1]
136  let depth = 0
137  for (let i = at; i < s.length; i++) {
138    if (s[i] === '{') depth++
139    else if (s[i] === '}' && --depth === 0) return [s.slice(at + 1, i), i + 1]
140  }
141  return [s.slice(at + 1), s.length]
142}
143
144const script = (text: string, table: Record<string, string>, mark: string): string => {
145  const plain = texToUnicode(text)
146  return [...plain].every(ch => table[ch]) ? [...plain].map(ch => table[ch]).join('') : `${mark}(${plain})`
147}
148
149export const texToUnicode = (src: string): string => {
150  let s = src
151  // Fractions and roots first: their groups may hold anything.
152  for (let guard = 0; guard < 20 && /\\(d|t)?frac\s*\{/.test(s); guard++) {
153    s = s.replace(/\\(?:d|t)?frac\s*/, '\u0000')
154    const at = s.indexOf('\u0000')
155    const [num, i1] = takeGroup(s, at + 1)
156    const [den, i2] = takeGroup(s, i1)
157    const wrap = (x: string) => (/^[\w.αβγδθλμπσφω∞]+$/.test(texToUnicode(x)) ? texToUnicode(x) : `(${texToUnicode(x)})`)
158    s = s.slice(0, at) + `${wrap(num)}⁄${wrap(den)}` + s.slice(i2)
159  }
160  for (let guard = 0; guard < 20 && /\\sqrt\s*\{/.test(s); guard++) {
161    s = s.replace(/\\sqrt\s*/, '\u0000')
162    const at = s.indexOf('\u0000')
163    const [body, end] = takeGroup(s, at + 1)
164    const inner = texToUnicode(body)
165    s = s.slice(0, at) + (inner.length === 1 ? `√${inner}` : `√(${inner})`) + s.slice(end)
166  }
167  s = s.replace(/\\mathbb\s*\{(\w)\}/g, (_, ch: string) => BLACKBOARD[ch] ?? ch)
168  s = s.replace(/\\(?:mathrm|mathbf|mathit|text|textbf|operatorname)\s*\{([^{}]*)\}/g, '$1')
169  s = s.replace(/\\([A-Za-z]+|[,;!])/g, (all, name: string) => (name in SYMBOLS ? (SYMBOLS[name] ?? '') : all))
170  let out = ''
171  for (let i = 0; i < s.length; ) {
172    const ch = s[i]
173    if (ch === '^' || ch === '_') {
174      const [group, next] = takeGroup(s, i + 1)
175      out += script(group, ch === '^' ? SUP : SUB, ch)
176      i = next
177      continue
178    }
179    if (ch === '{' || ch === '}') {
180      i++
181      continue
182    }
183    out += ch
184    i++
185  }
186  return out.replace(/[ \t]+/g, ' ').trim()
187}
188
189// Inline `$...$` in prose becomes Unicode; a `$` that reads as money is left alone.
190export const inlineMath = (md: string): string =>
191  md.replace(/(^|[^$\\\w])\$(?!\s)([^$\n]{1,200}?)(?<!\s)\$(?!\d)/g, (all, lead: string, body: string) =>
192    /[\\^_{}]|^[A-Za-z]$/.test(body) ? `${lead}${texToUnicode(body)}` : all,
193  )
194
195export const hasInlineMath = (md: string): boolean => inlineMath(md) !== md
196
197// ---------------------------------------------------------------------------
198// Mermaid, drawn: flowcharts (graph / flowchart, any direction) and pies. Any
199// other diagram returns null and is shown as code.
200
201export type ChartPalette = {
202  font: string
203  tile: string
204  text: string
205  subtext: string
206  line: string
207  accent: string
208  colors: string[]
209  chamfer: boolean
210}
211
212export type Chart = { svg: string; height: number }
213
214const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
215const textW = (s: string, size: number): number => [...s].length * size * 0.56
216
217type Node = { id: string; label: string; shape: 'box' | 'round' | 'diamond' | 'circle' }
218type Edge = { from: string; to: string; label: string; dashed: boolean }
219
220const NODE_RE = /^([A-Za-z0-9_\-]+)\s*(?:(\(\(|\(\[|\[\[|\[\(|\{\{|\[|\(|\{|>)(.*?)(\)\)|\]\)|\]\]|\)\]|\}\}|\]|\)|\}))?\s*$/
221const ARROW_RE = /\s*(-\.->|-\.-|==>|-->|---|--\s*[^->|]+?\s*-->)\s*(?:\|([^|]*)\|)?\s*/
222
223const parseNode = (token: string, nodes: Map<string, Node>): string | null => {
224  const m = NODE_RE.exec(token.trim())
225  if (!m) return null
226  const id = m[1] ?? ''
227  const open = m[2] ?? ''
228  const label = (m[3] ?? '').replace(/^["']|["']$/g, '').trim()
229  const shape: Node['shape'] = open === '{' || open === '{{' ? 'diamond' : open === '((' ? 'circle' : open === '(' || open === '([' ? 'round' : 'box'
230  const known = nodes.get(id)
231  if (!known || (label && known.label === known.id)) nodes.set(id, { id, label: label || known?.label || id, shape: open ? shape : known?.shape ?? 'box' })
232  return id
233}
234
235const flowchart = (src: string, W: number, p: ChartPalette): Chart | null => {
236  const lines = src.split(/\n|;/).map(l => l.trim()).filter(l => l && !l.startsWith('%%'))
237  const head = /^(graph|flowchart)\s*(TD|TB|BT|LR|RL)?/i.exec(lines[0] ?? '')
238  if (!head) return null
239  const dir = (head[2] ?? 'TD').toUpperCase()
240  const isAcross = dir === 'LR' || dir === 'RL'
241  const nodes = new Map<string, Node>()
242  const edges: Edge[] = []
243  for (const line of lines.slice(1)) {
244    if (/^(subgraph|end|classDef|class|style|linkStyle|click|direction)\b/.test(line)) continue
245    const parts = line.split(ARROW_RE)
246    // parts: node, arrow, label, node, arrow, label, node...
247    let prev = parseNode(parts[0] ?? '', nodes)
248    for (let i = 1; i + 2 < parts.length; i += 3) {
249      const arrow = parts[i] ?? ''
250      const inline = /--\s*([^->|]+?)\s*-->/.exec(arrow)?.[1] ?? ''
251      const label = (parts[i + 1] ?? inline ?? '').trim()
252      const next = parseNode(parts[i + 2] ?? '', nodes)
253      if (prev && next) edges.push({ from: prev, to: next, label, dashed: arrow.includes('.') })
254      prev = next
255    }
256  }
257  if (!nodes.size || nodes.size > 40) return null
258  // Layers: the longest path from a source, cycles cut.
259  const layer = new Map<string, number>([...nodes.keys()].map(id => [id, 0]))
260  for (let pass = 0; pass < nodes.size; pass++) {
261    let moved = false
262    for (const e of edges) {
263      const want = (layer.get(e.from) ?? 0) + 1
264      if (e.from !== e.to && want > (layer.get(e.to) ?? 0) && want < nodes.size) {
265        layer.set(e.to, want)
266        moved = true
267      }
268    }
269    if (!moved) break
270  }
271  const layers: string[][] = []
272  for (const id of nodes.keys()) (layers[layer.get(id) ?? 0] ??= []).push(id)
273  const H_NODE = 34
274  const size = (n: Node) => ({ w: Math.max(64, Math.min(200, textW(n.label, 12.5) + 28)), h: n.shape === 'diamond' ? 44 : H_NODE })
275  const pos = new Map<string, { x: number; y: number; w: number; h: number }>()
276  const gapMain = isAcross ? Math.max(150, Math.min(240, W / Math.max(1, layers.length))) : 86
277  layers.forEach((ids, li) => {
278    ids.forEach((id, k) => {
279      const n = nodes.get(id) as Node
280      const { w, h } = size(n)
281      if (isAcross) {
282        const x = 16 + li * gapMain + (gapMain - 32 - w) / 2 + w / 2
283        const y = 30 + k * 62 + h / 2
284        pos.set(id, { x: dir === 'RL' ? W - x : x, y, w, h })
285      } else {
286        const slot = W / ids.length
287        const x = slot * k + slot / 2
288        const y = 24 + li * gapMain + h / 2
289        pos.set(id, { x, y, w, h })
290      }
291    })
292  })
293  const maxY = Math.max(...[...pos.values()].map(q => q.y + q.h / 2)) + 24
294  if (dir === 'BT') for (const q of pos.values()) q.y = maxY - q.y
295  const height = Math.min(4000, Math.round(isAcross ? Math.max(...[...pos.values()].map(q => q.y + q.h / 2)) + 24 : layers.length * gapMain + 30))
296  const colorOf = (id: string) => p.colors[(layer.get(id) ?? 0) % p.colors.length] ?? p.accent
297  const arrows = edges
298    .map(e => {
299      const a = pos.get(e.from)
300      const b = pos.get(e.to)
301      if (!a || !b) return ''
302      const [x1, y1, x2, y2] = isAcross
303        ? [a.x + (b.x >= a.x ? a.w / 2 : -a.w / 2), a.y, b.x - (b.x >= a.x ? b.w / 2 : -b.w / 2), b.y]
304        : [a.x, a.y + (b.y >= a.y ? a.h / 2 : -a.h / 2), b.x, b.y - (b.y >= a.y ? b.h / 2 : -b.h / 2)]
305      const mid = isAcross ? `C${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2}` : `C${x1} ${(y1 + y2) / 2} ${x2} ${(y1 + y2) / 2}`
306      const label = e.label
307        ? `<text x="${(x1 + x2) / 2}" y="${(y1 + y2) / 2 - 4}" text-anchor="middle" font-family="${p.font}" font-size="11" fill="${p.subtext}" paint-order="stroke" stroke="${p.tile}" stroke-width="4">${esc(e.label)}</text>`
308        : ''
309      return `<path d="M${x1} ${y1}${mid} ${x2} ${y2}" fill="none" stroke="${colorOf(e.to)}" stroke-opacity=".75" stroke-width="1.6"${e.dashed ? ' stroke-dasharray="5 4"' : ''} marker-end="url(#ah)"/>${label}`
310    })
311    .join('\n')
312  const boxes = [...nodes.values()]
313    .map(n => {
314      const q = pos.get(n.id)
315      if (!q) return ''
316      const c = colorOf(n.id)
317      const x = q.x - q.w / 2
318      const y = q.y - q.h / 2
319      const fill = `fill="${p.tile}" stroke="${c}" stroke-width="1.6"`
320      const shape =
321        n.shape === 'diamond'
322          ? `<path d="M${q.x} ${y}L${x + q.w} ${q.y}L${q.x} ${y + q.h}L${x} ${q.y}Z" ${fill}/>`
323          : n.shape === 'circle'
324            ? `<ellipse cx="${q.x}" cy="${q.y}" rx="${q.w / 2}" ry="${q.h / 2 + 4}" ${fill}/>`
325            : p.chamfer && n.shape === 'box'
326              ? `<path d="M${x + 8} ${y}H${x + q.w}V${y + q.h - 8}L${x + q.w - 8} ${y + q.h}H${x}V${y + 8}Z" ${fill}/>`
327              : `<rect x="${x}" y="${y}" width="${q.w}" height="${q.h}" rx="${n.shape === 'round' ? q.h / 2 : 6}" ${fill}/>`
328      const label = n.label.length > 26 ? `${n.label.slice(0, 25)}…` : n.label
329      return `${shape}<text x="${q.x}" y="${q.y + 4.5}" text-anchor="middle" font-family="${p.font}" font-size="12.5" font-weight="600" fill="${p.text}">${esc(label)}</text>`
330    })
331    .join('\n')
332  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}">
333<defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="${p.subtext}"/></marker></defs>
334${arrows}
335${boxes}
336</svg>`
337  return { svg, height }
338}
339
340const pie = (src: string, W: number, p: ChartPalette): Chart | null => {
341  const lines = src.split('\n').map(l => l.trim()).filter(Boolean)
342  if (!/^pie\b/i.test(lines[0] ?? '')) return null
343  const title = (/title\s+(.+)$/i.exec(lines[0] ?? '')?.[1] ?? lines.find(l => /^title\s+/i.test(l))?.replace(/^title\s+/i, '') ?? '').trim()
344  const slices = lines
345    .map(l => /^"([^"]+)"\s*:\s*([\d.]+)/.exec(l))
346    .filter((m): m is RegExpExecArray => Boolean(m))
347    .map(m => ({ label: m[1] ?? '', value: Number(m[2]) }))
348    .filter(sl => sl.value > 0)
349    .slice(0, 12)
350  if (!slices.length) return null
351  const total = slices.reduce((a, b) => a + b.value, 0)
352  const height = Math.max(180, 40 + slices.length * 24)
353  const r = 70
354  const cx = 24 + r
355  const cy = height / 2 + (title ? 8 : 0)
356  let angle = -Math.PI / 2
357  const arcs = slices
358    .map((sl, i) => {
359      const sweep = (sl.value / total) * Math.PI * 2
360      const a2 = angle + sweep
361      const large = sweep > Math.PI ? 1 : 0
362      const pt = (a: number, rr: number) => `${(cx + rr * Math.cos(a)).toFixed(1)} ${(cy + rr * Math.sin(a)).toFixed(1)}`
363      const d =
364        slices.length === 1
365          ? `M${cx - r} ${cy}A${r} ${r} 0 1 1 ${cx + r} ${cy}A${r} ${r} 0 1 1 ${cx - r} ${cy}Z`
366          : `M${pt(angle, r)}A${r} ${r} 0 ${large} 1 ${pt(a2, r)}L${pt(a2, r * 0.58)}A${r * 0.58} ${r * 0.58} 0 ${large} 0 ${pt(angle, r * 0.58)}Z`
367      angle = a2
368      return `<path d="${d}" fill="${p.colors[i % p.colors.length] ?? p.accent}" stroke="${p.tile}" stroke-width="2"/>`
369    })
370    .join('')
371  const legendX = cx + r + 32
372  const legend = slices
373    .map((sl, i) => {
374      const y = cy - (slices.length * 24) / 2 + i * 24 + 16
375      return `<rect x="${legendX}" y="${y - 9}" width="11" height="11" rx="${p.chamfer ? 1 : 3}" fill="${p.colors[i % p.colors.length] ?? p.accent}"/>
376<text x="${legendX + 18}" y="${y}" font-family="${p.font}" font-size="12.5" fill="${p.text}">${esc(sl.label)}<tspan fill="${p.subtext}">  ${Math.round((sl.value / total) * 100)}%</tspan></text>`
377    })
378    .join('\n')
379  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}">
380${title ? `<text x="16" y="22" font-family="${p.font}" font-size="13" font-weight="700" fill="${p.accent}">${esc(title)}</text>` : ''}
381${arcs}
382${legend}
383</svg>`
384  return { svg, height }
385}
386
387export const mermaidChart = (src: string, W: number, p: ChartPalette): Chart | null => {
388  const first = src.trim().split('\n')[0]?.trim() ?? ''
389  if (/^(graph|flowchart)\b/i.test(first)) return flowchart(src.trim(), W, p)
390  if (/^pie\b/i.test(first)) return pie(src.trim(), W, p)
391  return null
392}
393
hooks/smooth.ts 213 lines
1// Smooth drawing on the desktop. There an Svg is an image drawn from its source: a
2// new source is a new image, decoded again, its animations started from zero. So a
3// drawing keeps its source while what it shows holds (`steady`), a new one carries on
4// its animations' phase (`phased`), and what moves each second moves inside the image
5// (`liveText`, `liveWidth`) instead of asking for a redraw.
6
7// Every phased animation counts from here: fixed for the module's life, so two images
8// drawn a second apart agree on the phase. Kept small: a whole minute near the first draw.
9let EPOCH: number | null = null
10const epoch = (at: number): number => (EPOCH ??= at - (at % 60_000))
11
12const TIME = /^(-?(?:\d+\.?\d*|\.\d+))(m?s)$/
13const seconds = (tok: string): number | null => {
14  const m = TIME.exec(tok.trim())
15  return m ? Number(m[1]) / (m[2] === 'ms' ? 1000 : 1) : null
16}
17const sec = (n: number): string => `${Math.round(n * 1000) / 1000}s`
18
19// An endless animation's shorthand with its delay moved by `shift`; one that ends
20// (an entrance, a strip) plays from its own start, so it is left alone.
21const shiftShorthand = (value: string, shift: number): string => {
22  if (!/\binfinite\b/.test(value)) return value
23  const parts = value.trim().split(/\s+/)
24  const times = parts.flatMap((p, i) => (seconds(p) === null ? [] : [i]))
25  const at = times[1]
26  if (at === undefined) return `${parts.join(' ')} ${sec(shift)}`
27  parts[at] = sec((seconds(parts[at] ?? '') ?? 0) + shift)
28  return parts.join(' ')
29}
30
31// `@@T<ms>@@` stands for "seconds from this image's start to that moment": how a
32// strip or a countdown is pinned to the wall clock.
33const PIN = /@@T(-?\d+)@@/g
34export const pin = (ms: number): string => `@@T${Math.round(ms)}@@`
35
36/**
37 * The source as drawn at `at`: each endless CSS or SMIL animation is delayed by the
38 * time since the epoch, so a new image picks up where the one it replaces was, and
39 * each pin is resolved. Animations that end are left to play from their start.
40 */
41export const phased = (content: string, at: number): string => {
42  const shift = (epoch(at) - at) / 1000
43  return content
44    .replace(/animation:([^;}"]+)/g, (_, v: string) => `animation:${shiftShorthand(v, shift)}`)
45    .replace(/animation-delay:([^;}"]+)/g, (all, v: string) => {
46      const s = seconds(v)
47      return s === null ? all : `animation-delay:${sec(s + shift)}`
48    })
49    .replace(/<(animate|animateTransform|animateMotion)\b([^>]*)>/g, (all, tag: string, attrs: string) => {
50      if (!/repeatCount="indefinite"/.test(attrs)) return all
51      const begin = /\bbegin="([^"]*)"/.exec(attrs)
52      if (!begin) return `<${tag} begin="${sec(shift)}"${attrs}>`
53      const s = seconds(begin[1] ?? '')
54      return s === null ? all : `<${tag}${attrs.replace(begin[0], `begin="${sec(s + shift)}"`)}>`
55    })
56    .replace(PIN, (_, ms: string) => sec((Number(ms) - at) / 1000))
57}
58
59// `@@G<id>:<width>@@` inside a bar's rect: drawn as a short glide from the width the
60// slot showed last, so a bar grows to its new value instead of jumping.
61const GLIDE = /@@G([\w-]+):(-?[\d.]+)@@/g
62export const glide = (id: string, width: number): string => `@@G${id.replace(/[^\w-]/g, '_')}:${Math.round(width * 10) / 10}@@`
63const glides = (content: string): Map<string, string> => new Map([...content.matchAll(GLIDE)].map(m => [m[1] ?? '', m[2] ?? '']))
64
65// --- fewer bytes for the same picture.
66
67const STYLE = /<style>([\s\S]*?)<\/style>/g
68const RULE = /[^{}]+\{[^{}]*\}/g
69const FAMILY = / font-family="([^"]*)"/g
70
71/**
72 * The same drawing in fewer bytes: each new source is sent to the desktop and decoded
73 * there, so a big crew's every redraw paid for kilobytes nothing reads. Rules whose
74 * every selector starts with a class the markup never uses are dropped (a card with no
75 * crab carries no crab CSS), and a drawing set in one font names it once, on its root.
76 */
77export const compact = (content: string): string => {
78  if (!content.startsWith('<svg')) return content
79  const markup = content.replace(STYLE, '')
80  const used = new Set<string>()
81  for (const m of markup.matchAll(/\bclass="([^"]*)"/g)) for (const c of (m[1] ?? '').split(/\s+/)) if (c) used.add(c)
82  const isDead = (rule: string): boolean =>
83    rule
84      .slice(0, rule.indexOf('{'))
85      .split(',')
86      .every(sel => {
87        const m = /^\s*\.([\w-]+)/.exec(sel)
88        return Boolean(m && !used.has(m[1] ?? ''))
89      })
90  let out = content.replace(STYLE, (all, css: string) => {
91    const kept = css
92      .split('\n')
93      .map(line => {
94        const rules = line.match(RULE)
95        return rules && rules.join('') === line ? rules.filter(r => !isDead(r)).join('') : line
96      })
97      .filter(Boolean)
98      .join('\n')
99    return kept.trim() ? `<style>${kept}</style>` : ''
100  })
101  // One font named once: only when it is the only one, attributes and styles alike.
102  const families = new Set([...out.matchAll(FAMILY)].map(m => m[1]))
103  const root = /^<svg\b[^>]*>/.exec(out)?.[0] ?? ''
104  const rootFamily = / font-family="([^"]*)"/.exec(root)?.[1]
105  if (families.size === 1 && !out.includes('font-family:')) {
106    const family = [...families][0] as string
107    if (rootFamily === undefined || rootFamily === family) {
108      const body = out.slice(root.length).split(` font-family="${family}"`).join('')
109      out = `${rootFamily === undefined ? root.replace(/^<svg\b/, `<svg font-family="${family}"`) : root}${body}`
110    }
111  }
112  return out
113}
114
115type Kept = { content: string; source: string; widths: Map<string, string> }
116const kept = new Map<string, Kept>()
117const KEPT_MAX = 400
118
119/**
120 * The source for one slot of a drawing (`slot` names it across redraws): the very
121 * same string while the content is the same, so the image is neither decoded again
122 * nor restarted; a new content is phased at `at`, its bars gliding from their last widths.
123 */
124export const steady = (slot: string, content: string, at: number): string => {
125  const was = kept.get(slot)
126  if (was && was.content === content) return was.source
127  const widths = glides(content)
128  const source = phased(compact(content), at).replace(GLIDE, (_, id: string, to: string) => {
129    const from = was?.widths.get(id)
130    return from === undefined || from === to
131      ? ''
132      : `<animate attributeName="width" from="${from}" to="${to}" dur=".45s" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1" fill="freeze"/>`
133  })
134  kept.delete(slot)
135  kept.set(slot, { content, source, widths })
136  if (kept.size > KEPT_MAX) kept.delete(kept.keys().next().value as string)
137  return source
138}
139
140type Node = { type?: string; key?: unknown; props?: Record<string, unknown>; children?: unknown[] }
141const keyOf = (n: Node): string => String(n.key ?? n.props?.key ?? '')
142
143/**
144 * A whole drawing's sources made steady: each Svg's slot is its own key, else its
145 * nearest keyed box's (an agent's row), so a card keeps its slot as rows come and go.
146 */
147export const steadyTree = <T>(tree: T, scope: string, at: number): T => {
148  const seen = new Map<string, number>()
149  const walk = (x: unknown, near: string): unknown => {
150    if (Array.isArray(x)) return x.map(c => walk(c, near))
151    if (!x || typeof x !== 'object') return x
152    const n = x as Node
153    const name = keyOf(n) || near
154    if (n.type === 'Svg' && typeof n.props?.source === 'string') {
155      const k = seen.get(name) ?? 0
156      seen.set(name, k + 1)
157      return { ...n, props: { ...n.props, source: steady(`${scope}:${name}#${k}`, n.props.source, at) } }
158    }
159    return Array.isArray(n.children) ? { ...n, children: n.children.map(c => walk(c, name)) } : n
160  }
161  return walk(tree, '') as T
162}
163
164// --- what moves each second, drawn once a minute.
165
166/** Seconds a strip covers: the wall minute and a few past it, for a redraw that comes late. */
167export const LIVE_N = 66
168const minuteOf = (at: number): number => at - (at % 60_000)
169const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
170const r1 = (n: number): number => Math.round(n * 10) / 10
171
172/**
173 * A text that changes each second (a clock, a countdown, an estimate), as one line per
174 * second of the wall minute stepped through by CSS, so it reads right with no redraw.
175 * The same minute gives the same markup. `attrs` go on the line group (x via `x`);
176 * `fill` is repeated on each line, so a toned value still reads as one element.
177 */
178export const liveText = (x: number, y: number, at: number, size: number, attrs: string, value: (t: number) => string, fill = ''): string => {
179  const t0 = minuteOf(at)
180  const step = Math.ceil(size * 1.6)
181  const lines: string[] = []
182  for (let i = 0; i < LIVE_N; i++) lines.push(`<text y="${i * step}"${fill ? ` fill="${fill}"` : ''}>${esc(value(t0 + i * 1000 + 500))}</text>`)
183  const name = `lv${step}`
184  return `<svg x="0" y="${r1(y - size * 1.1)}" width="100%" height="${r1(size * 1.5)}" overflow="hidden"><style>@keyframes ${name}{to{transform:translateY(-${(LIVE_N - 1) * step}px)}}</style><g transform="translate(${r1(x)} ${r1(size * 1.1)})" ${attrs}><g style="animation:${name} ${LIVE_N - 1}s steps(${LIVE_N - 1}) forwards;animation-delay:${pin(t0)}">${lines.join('')}</g></g></svg>`
185}
186
187/** A bar's width over the same minute, sampled each second: it glides as the time goes. */
188export const liveWidth = (at: number, width: (t: number) => number): { start: number; animate: string } => {
189  const t0 = minuteOf(at)
190  const values = Array.from({ length: LIVE_N }, (_, i) => r1(width(t0 + i * 1000)))
191  return {
192    start: values[0] ?? 0,
193    animate: `<animate attributeName="width" values="${values.join(';')}" dur="${LIVE_N - 1}s" begin="${pin(t0)}" fill="freeze"/>`,
194  }
195}
196
197/** The wall minute: what a strip's markup is keyed on. */
198export const wallMinute = (at: number): number => Math.floor(at / 60_000)
199
200// --- writes that redraw only when something drawn changed.
201
202/** Equal as data: a write of an equal value would still redraw every reader. */
203export const isSame = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b)
204
205// The last drawn-time reading per tick: a tick is written only when it moved.
206const readings = new Map<string, string>()
207/** True, once, when `reading` differs from the last one under `name`. */
208export const isNewReading = (name: string, reading: string): boolean => {
209  if (readings.get(name) === reading) return false
210  readings.set(name, reading)
211  return true
212}
213
hooks/warm.ts 69 lines
1// Smart Keep-Warm: the Token Manager refreshes the prompt cache shortly before it
2// lapses, when the refresh is expected to save more than it costs.
3//
4// The approach follows paulbkim-dev/claude-code-cache-warmer (MIT, after Pi's cache
5// warmer): a side request over the session's own transcript ($.model.fork), which
6// never enters the conversation; an expected-value rule; and a cap on idle refreshes.
7// Mission Control's own additions: work still running in the background counts as a
8// sure return; a context about to be compacted is not warmed; and the outcome shows
9// in the Token Manager instead of a pane of its own.
10//
11// Pure arithmetic here, so it is tested on its own.
12
13export const WARM_TAG = '[mission-control keep-warm]'
14export const WARM_PROMPT = `${WARM_TAG} Automated prompt cache refresh by Mission Control, not a message from the user. Reply with the single word ok.`
15
16/** A refresh goes out only when it is expected to save at least this much. */
17export const MIN_SAVINGS_USD = 0.05
18/** The chance a prompt comes before an idle cache lapses (Pi's measured figure). */
19export const IDLE_CHANCE = 0.15
20/** A refresh's reply is a word, but a thinking model spends a little before it. */
21export const WARM_OUTPUT_TOKENS = 200
22/** Idle refreshes per prompt: with the hour-long cache, close to three more warm hours. */
23export const IDLE_LIMIT = 3
24/** Refreshes per prompt while agents or background tasks still run. */
25export const BUSY_LIMIT = 12
26/** The refresh goes this long before the cache lapses, and not in its last seconds. */
27export const LEAD_MS = 6 * 60_000
28export const LATE_MS = 15_000
29/** A context this full is about to be compacted, which rewrites the cache anyway. */
30export const COMPACT_PCT = 85
31
32/** USD per million tokens: input, output, cache read, cache write (the hour-long write). */
33export type WarmPrice = readonly [input: number, output: number, cacheRead: number, cacheWrite: number]
34
35export type WarmCall = {
36  isWarm: boolean
37  /** What the refresh costs: reading the prefix, and its short reply. */
38  warmUsd: number
39  /** What a lapsed cache adds to the next prompt: writing the prefix instead of reading it. */
40  missUsd: number
41  /** The chance a prompt comes while the refresh still holds. */
42  chance: number
43  expectedUsd: number
44  /** Why not, when a refresh does not pay. */
45  reason?: string
46}
47
48export const warmCall = (price: WarmPrice, promptTokens: number, isLikely: boolean, contextPct: number, outputTokens = WARM_OUTPUT_TOKENS): WarmCall => {
49  const [, output, cacheRead, cacheWrite] = price
50  const warmUsd = (promptTokens * cacheRead + outputTokens * output) / 1e6
51  const missUsd = Math.max(0, (promptTokens * (cacheWrite - cacheRead)) / 1e6)
52  const chance = isLikely ? 1 : IDLE_CHANCE
53  const expectedUsd = chance * missUsd - warmUsd
54  if (contextPct >= COMPACT_PCT) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: `context ${contextPct}%: a /compact rewrites the cache anyway` }
55  if (promptTokens <= 0) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: 'nothing cached yet' }
56  if (expectedUsd < MIN_SAVINGS_USD) return { isWarm: false, warmUsd, missUsd, chance, expectedUsd, reason: `too small to pay (saves under $${MIN_SAVINGS_USD.toFixed(2)})` }
57  return { isWarm: true, warmUsd, missUsd, chance, expectedUsd }
58}
59
60/** Whether a refresh is due: inside the lead before the cache lapses, not too late. */
61export const isWarmDue = (cacheUntil: number | null, at: number): boolean =>
62  cacheUntil !== null && at >= cacheUntil - LEAD_MS && at < cacheUntil - LATE_MS
63
64/** A refresh that read under half the prefix found the cache gone and wrote it again. */
65export const isWarmed = (cacheRead: number, promptTokens: number): boolean => promptTokens > 0 && cacheRead >= promptTokens / 2
66
67/** The refreshes left before the cap: a busy session gets more than an idle one. */
68export const warmLimitHit = (isLikely: boolean, idle: number, busy: number): boolean => (isLikely ? busy >= BUSY_LIMIT : idle >= IDLE_LIMIT)
69
types/index.d.ts 261 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7  /** The timekeeper's budget for this task, as the plan gave it. */
8  minutes?: number
9}
10
11export type Flow = {
12  title: string
13  total: number
14  done: number
15  running: number
16  phase: Phase
17  isFinished: boolean
18  tasks: PlannedTask[]
19  /** The timekeeper's budget for the whole plan, as the plan gave it. */
20  minutes?: number
21}
22
23/** `stopped`: cut short on purpose (Esc, a release), shown neutral, never as an error. */
24export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
25
26export type AgentRun = {
27  id: string
28  agentId?: string
29  type: string
30  description: string
31  model: string
32  effort?: string
33  status: AgentStatus
34  startedAt: number
35  endedAt?: number
36  contextTokens: number
37  contextMax: number
38  tokens: number
39  costUsd: number
40  steps: number
41  round: number
42  /** Self-reported by the worker through the `step` tool. */
43  stepDone?: number
44  stepTotal?: number
45  stepNote?: string
46  /** The agent that started this one; absent when the main session did. */
47  parentAgentId?: string
48  /** What it is doing right now, from its latest tool call ("reading register.tsx"). */
49  activity?: string
50  /** When it last showed a sign of life: a model request or a tool call. */
51  lastProgressAt?: number
52  /** Quiet too long while running; the panel flags it and pings once. */
53  isStuck?: boolean
54  /** The timekeeper's budget for this run, in minutes; absent while the timekeeper is off. */
55  budgetMin?: number
56  /** Past its budget: flagged like a stuck one, and alerted once. */
57  isOverBudget?: boolean
58  /** When Mission Control last checked in with the person about this quiet agent. */
59  checkedInAt?: number
60  /** Started in the background: its end queues a report the main loop still has to answer. */
61  isBackground?: boolean
62  /** Its tool calls still in flight: a long build or a permission prompt is no silence. */
63  toolsInFlight?: number
64}
65
66export type Panel = {
67  isCompact: boolean
68  isDoneCollapsed: boolean
69  /** Finished agents the person opened again; every other finished agent shows folded. */
70  expanded?: string[]
71  autoOpenedFor: string
72}
73
74/** What the session card shows: where Claude runs, how full its context is, what it is connected to. */
75export type Env = {
76  cwd: string
77  model: string
78  surface: string
79  contextPct: number
80  contextTokens: number
81  contextWindow: number
82  costUsd: number
83  connectors: string[]
84  /** Tokens each model has burned this session, the main session's and every sub-agent's. */
85  modelTokens?: Record<string, number>
86  /** The main session's effort level, as its last model request carried it. */
87  effort?: string
88  /** Usage windows as the last API response reported them: `five_hour`, `seven_day`. */
89  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
90}
91
92/** The main session: what the person last asked for, and whether it is at work. */
93export type PlanStep = { content: string; status: 'pending' | 'in_progress' | 'completed' }
94
95export type Manager = {
96  task: string
97  isWorking: boolean
98  startedAt: number
99  /** The manager's own task list, as the session's plan tool last set it. */
100  plan?: PlanStep[]
101  /** What the main session is doing right now. */
102  activity?: string
103  /** Until when the crew celebrates a finished job. */
104  celebrateUntil?: number
105  /** When the whole job finished: the turn over and no agent of its crew still running. */
106  doneAt?: number
107  /** The session's cost and tokens when this task began: the task's own spend is the difference. */
108  costAtStart?: number
109  tokensAtStart?: number
110  /** The job's budget from its plan's `minutes`, when the progress tool gave one. */
111  planMin?: number
112  /** The job's budget in minutes: the plan's, else the default; absent while the timekeeper is off. */
113  budgetMin?: number
114  /** How the job ended short: Esc (`stopped`) or an API error or refusal (`error`). Never cheered. */
115  outcome?: 'stopped' | 'error'
116  /** When a background agent of the job ended: its report waits on a main turn to answer it. */
117  reportDue?: number
118  /** An earlier job still held open by its background work when this prompt came: the one that finishes. */
119  carried?: { task: string; startedAt: number; costAtStart?: number; ids: string[] }
120  /** Reopened by a turn Claude started after the job was done: it ends again quietly. */
121  isFollowUp?: boolean
122}
123
124/** The timekeeper: its settings (kept in `$.store`) and which job it already alerted for. */
125export type Timekeeper = {
126  on: boolean
127  jobMin: number
128  agentMin: number
129  /** The `startedAt` of the job already warned at 80%, and of the one alerted past 100%. */
130  warnedFor: number
131  overFor: number
132}
133
134/** What the token manager watches: recent burn, how much was cached, its settings. */
135export type Watch = {
136  burn: { at: number; n: number }[]
137  cacheRead: number
138  promptAll: number
139  autopilot: boolean
140  budgetUsd: number
141  /** Smart Keep-Warm: refreshes the prompt cache before it lapses (/tokens warm on). */
142  warm: KeepWarm
143}
144
145export type KeepWarm = {
146  on: boolean
147  /** Refreshes since the last prompt, while idle and while work ran in the background. */
148  idle: number
149  busy: number
150  /** Why warming stopped until the next prompt; empty while it runs. */
151  halted: string
152  /** This session: refreshes sent, what they cost, and what they saved against a rewrite. */
153  count: number
154  spentUsd: number
155  savedUsd: number
156}
157
158/** Tokens per model over every session: seeded once from the history files, then kept up. */
159export type AllTime = {
160  since: string
161  models: Record<string, number>
162}
163
164/** This session's turn stats, as the session band used to show them. */
165export type SessionStats = {
166  turn: number
167  /** Output tokens per second of the main session's last timed response. */
168  speed: number | null
169  costAtStart: number
170  /** When the prompt cache goes cold, an hour after the last answer. */
171  cacheUntil: number | null
172  /** The main thread's last request: its prompt size and model, what a refresh would read. */
173  promptTokens?: number
174  model?: string
175  /** Seconds left on it, kept by the clock so the band redraws. */
176  cacheLeft: number | null
177  isBusy: boolean
178  /** The smallest warning minute already toasted since the last answer; 0 none. */
179  warned: number
180  /** Why this task runs in crew mode (a big job), or empty. */
181  crewMode?: string
182}
183
184/** Today's totals, kept across sessions. */
185export type Today = {
186  date: string
187  tasks: number
188  agents: number
189  tokens: number
190  costUsd: number
191}
192
193/** One piece of background work, as the last Stop or SubagentStop hook listed it. */
194export type BgTask = {
195  id: string
196  /** 'shell', 'subagent', 'monitor', 'workflow', or the engine's own word for another kind. */
197  type: string
198  status: string
199  description: string
200  /** A shell's command line. */
201  command?: string
202  /** A subagent's type. */
203  agentType?: string
204  /** A monitor's MCP server and tool. */
205  server?: string
206  tool?: string
207  /** A workflow's name. */
208  name?: string
209  /** Since when it has stood at this status: first listed, or its status last changed. */
210  seenAt?: number
211  /** When Mission Control last checked in with the person about it, quiet an hour. */
212  checkedInAt?: number
213  /** Let go by the person (/missioncontrol release): still listed, no longer holding the job. */
214  released?: boolean
215}
216
217/** A scheduled wake-up (CronCreate, ScheduleWakeup, /loop) that will start a turn later. */
218export type BgCron = { id: string; schedule: string; recurring: boolean; prompt: string }
219
220/** Claude's background work. The engine lists it only as a turn or a subagent stops: `at` says when. */
221export type Background = { tasks: BgTask[]; crons: BgCron[]; at: number }
222
223declare module 'claude-code' {
224  interface PluginState {
225    'crab-progress': {
226      flow: Flow | null
227      agents: AgentRun[]
228      panel: Panel
229      now: number
230      /** The band's tick: moves only when something the band draws from the time changes. */
231      bandTick: number
232      /** The pane's tick, the same for Mission Control's desktop drawing. */
233      paneTick: number
234      /** The person's answer-style rules, mirrored from `$.store` for drawing. */
235      memory: string[]
236      env: Env
237      manager: Manager
238      watch: Watch
239      today: Today
240      allTime: AllTime
241      /** The id of the skin Mission Control and the band are drawn in. */
242      skin: string
243      /** Mission Control's settings, mirrored from `$.store` so the pane redraws. */
244      settings: { openOnStart: boolean; crewMode: boolean; currency: string; sounds: boolean }
245      /** Street Cred: XP, the counts badges are earned on, the badges. Mirrored from `$.store`. */
246      cred: { xp: number; jobs: number; longJobs: number; agents: number; crewJobs: number; nightJobs: number; chromed: boolean; samurai: boolean; badges: string[] }
247      /** The session's tool calls, for the Braindance replay. */
248      timeline: { at: number; tool: string; text: string; who: string }[]
249      /** The beat the replay shows; -1 follows the newest. */
250      bdPick: number
251      session: SessionStats
252      /** The timekeeper's settings and alert marks; the marks live here so a hot reload never re-alerts. */
253      timekeeper: Timekeeper
254      /** Background tasks and scheduled wake-ups, as the last Stop or SubagentStop listed them. */
255      background: Background
256      /** Today and All-time as this session last read or wrote them: a save adds only its own share. */
257      saved: { today: Today; allTime: AllTime }
258    }
259  }
260}
261