SLOPSHOPPER

loadout

One theme picks the whole kit: terminal colours and background, the Avatar pane, the Flow map and the frames around conversation rows. /loadout switches it.

newpanebandspinnerrowsguard
v1.4.0MITupdated 2026-10-08Zokuzo/loadout/loadout
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loadout
│ ┃ Flow map ✕ › fix the failing auth test and add an audit log call │ ┃ No workflow is running. │ ┃ ⏺ Read(src/auth.ts) │ ┃ No plan yet. Claude posts one for a 3+ ste ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /flow │ ┃ ⎿ loadout: Flow map opened. │ ┃ ⎿ loadout: Also: /loadout flow. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ $0.42 · 5h 31% · wk n/a · ctx 49% [ kirby ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
$0.42 · 5h 31% · wk n/a · ctx 49% [ kirby ]
Pane · Flow map
No workflow is running. No plan yet. Claude posts one for a 3+ step task. ────────────────────────────────── n: narrow w: wide ── z: hide
Pane · Avatar
╭─ loadouts ──────────────────────────────── 0: close ╮ │ ████ 1: auto │ │ ████ 2: dark │ │ ████ 3: light │ │ ████ 4: light-daltonized │ │ ████ 5: dark-daltonized │ │ ████ 6: light-ansi │ │ ████ 7: dark-ansi │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │ poyo~ ♥ │ │ │ ╰──────────────────────────▼──────────────────────────╯ ▣ Kirby Kirby [ pet ] ♥ 0 cost $0.42 session 5h █████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 31% week unavailable ctx █████████████████████░░░░░░░░░░░░░░░░░░░░░░ 49% 97k / 200k
README

Loadout

One theme picks the whole kit, as a loadout does in a game: Claude Code's colours, the terminal background, the avatar, the Flow map's style and the frames around conversation rows. One plugin, one command.

It is three parts in one plugin (they were the mods kirby-hud, flow-map and color-rows):

PartFolderWhat it draws
Avataravatar/The "Avatar" side pane: the theme picker, a mascot or character per theme, the agents on a stage, the usage meters. It also tints the terminal background per theme.
Flow mapflow/The "Flow map" pane: workflow runs, plain subagents, the step plan, the wayfinder map. Registers the tool mcp__loadout__steps.
Framesframes/A coloured frame around each conversation row, one hue per kind of action; and the working line in the theme's own words behind its own turning icon (a pack's spinner, THEMES.md).

The command

TypedDoes
/loadoutOpens the picker in the Avatar pane and lists the themes, the current one marked.
/loadout <theme>That theme for every terminal, with no restart (see "Live theme files" for the first time).
/loadout <theme> hereThis terminal only. With no restart in a terminal the shell launcher started. In any other, nothing changes: it copies and prints the line that starts the conversation again through the launcher (press Ctrl+D, paste, Enter).
/loadout followThe line that makes a terminal with its own theme follow the global one again.
/loadout avatar \flowOpens that pane.
`/loadout frames on\off`The frames around conversation rows.
`/loadout background on\off`Whether the theme tints the terminal background.
`/loadout style pixel\text\auto`How a Ghostrunner or Edgerunners character is drawn: text shows no footage.
/loadout statusTheme and what set it, pack in use, packs skipped, each character slot (found, with its name and number of loops, or missing), panes, frames.
/loadout helpThis list.

A theme's name is forgiving: any case, spaces or dashes (Cyberpunk Edgerunners, cyberpunk-edgerunners), or a prefix or word that only one theme has (ghost, edge, edgerunners, cyberpunk, pink). An exact name wins over a prefix (kirby is Kirby, not Kirby Pink). An ambiguous or unknown name changes nothing and lists the candidates.

Two themes were renamed, and their old names still work, here and in the shell launcher: cyberpunk-soft and soft are cyberpunk-edgerunners; cyberpunk-classic and classic are ghostrunner. (The red theme was cyberpunk: that word now picks Cyberpunk Edgerunners, the one theme it begins.)

/loadout and /loadout status say what set the theme: "the global theme", "this terminal's own theme", "pinned to this terminal", "the theme this terminal started with" (the global one changed since and shows at its next start), a project's or a managed settings file. A session started in the home directory reads ~/.claude/settings.json as its project file too: that is said as "set by your global settings", and /loadout <theme> changes it as anywhere else.

In the picker, each theme is a row: four of its own colours, a digit (1 to 9), its name, a mark on the one this terminal shows. Press the digit or click the row: it does what /loadout <theme> does, says so in a toast, and the picker folds to one line, loadout: <name> [ change ]. On a pane too short for the picker and the avatar, it stays folded, and [ change ] or /loadout says so in a toast: make the pane taller or type /loadout <theme>. At the one height where even the folded line would cost the pane its mascot, the line is not drawn.

/avatar, /kirby, /flow and /colors still work with their old arguments.

Live theme files

A running Claude Code session keeps the theme it started with: changing the "theme" setting repaints nothing. But it watches the file of the theme it uses, and repaints within seconds when that file's content changes. So Loadout switches by rewriting theme files of its own, the "live" files:

FileUsed byRewritten by
~/.claude/themes/loadout.jsonEvery terminal that follows the global setting, which is "theme": "custom:loadout"./loadout <theme>, the picker.
~/.claude/themes/loadout-<tag>.jsonThe one terminal the shell launcher started on it (<tag> is its tty: pts3)./loadout <theme> here in that terminal; /loadout <theme> too.

A live file is a copy of a theme (its base and overrides) under the name Loadout: <theme>: Claude Code's own /theme lists it by that name. A built-in theme is { "base": "dark", "overrides": {} }. Everything in the plugin (avatar, Flow map, terminal background, picker) reads the theme a live file was written from, and reads it again when the file changes. Live files are never listed as themes. A new theme is still a file in ~/.claude/themes plus a pack (THEMES.md).

The first /loadout <theme> writes loadout.json and sets the "theme" key of ~/.claude/settings.json to custom:loadout (that key only; refused under a managed setting). Terminals already running on another theme keep it until they start again: the command says so and copies the line that restarts this one. From then on a switch is one line, Loadout <theme>: every terminal.

auto (light or dark by the terminal's ground) can be no theme file: /loadout auto sets the "theme" setting itself, and each terminal takes it at its next start. A theme picked with Claude Code's own /theme is followed as before.

The shell launcher

loadout/bin/loadout [<theme>] [claude arguments...]

Starts Claude Code with a live theme file of this terminal's own, so /loadout <theme> here switches it alone, with no restart. With a theme, the file is written from it (the same forgiving names). Without one (no word, or claude's own arguments first: --resume <id>), the terminal's file is used as it was left, and made from the global look the first time. --list lists the themes. Before starting it removes the files of terminals that are gone (loadout-<tag>.json with no such tty), never loadout.json.

loadout/bin/loadout edge
loadout/bin/loadout dark --resume <id>
loadout/bin/loadout --resume <id>

Optional: to start every terminal through it, add an alias to your shell file, under a name of its own:

alias cl="$HOME/.claude/plugins/marketplaces/zokuzo/loadout/bin/loadout"

(Not as claude itself: the launcher reads a first word without a dash as a theme name, so claude plugin list or claude "a prompt" would be refused.)

bin/claude-theme is its earlier name and calls it. It takes only theme files named in lower case, digits and dashes (<slug>.json): any other file in ~/.claude/themes is not listed and not started (in a session, /loadout <that theme> here hands the plain claude --settings line, which pins the theme with no live file). auto, and any theme where there is no tty to name a file after, is pinned the same way. Its check: bash tests/launcher.sh.

Where things live

  • Claude themes (colours): ~/.claude/themes/<slug>.json, and the built-in ones. The plugin ships its own in colors/ and copies them there at its first start (see Install). loadout.json and loadout-<tag>.json there are the live files, written by Loadout: do not edit them.
  • Theme packs (background, mascot, Flow map look): themes/<slug>.json, in this folder. Without that folder the built-in tables answer.
  • Pictures and footage: assets/<slot>/, one folder per character (below).
  • What is kept between sessions (pet count, style, background switch, frames on or off, the Flow map's learned durations): this plugin's own store. At its first start it copies what the three old mods kept.

To add a theme, see THEMES.md.

The characters: one per theme and model

The Ghostrunner and Cyberpunk Edgerunners themes show a framed camera feed of a character, and which one follows the session's model. A character's place is a slot, <world>-<form>: the world is the theme's, the form the model's.

ModelGhostrunner (ghostrunner-…)Cyberpunk Edgerunners (edgerunners-…)
Opus (…-opus)JackLucy
Sonnet (…-sonnet)HelDavid Martinez
Haiku (…-haiku)MitraRebecca
Fable (…-fable)Mara the KeymasterAdam Smasher

Footage for a slot is a folder, assets/<slot>/, with a manifest.json and one folder of frames per loop (<clip>/000.png, 001.png, ...; every picture 400 by 340, so the feed's box is one size whoever is in it):

{
  "name": "HEL",
  "speech": { "idle": "...", "working": "...", "happy": "...", "worried": "..." },
  "clips": { "watching": { "frames": 20, "ms": 90, "what": "watches the corridor" } },
  "moods": { "idle": ["watching"], "working": ["..."], "happy": ["..."], "worried": ["..."] }
}
  • name: shown under the feed, before the model (24 characters at the most). Left out or unusable, the footage plays under the slot's own character's name from the cast above (/loadout status says so), never the mask's.
  • speech (optional, each mood too): what the banner says in that mood (60 characters; the banner cuts what does not fit). A mood without a line keeps the theme's own; so does a manifest without speech.
  • clips: a loop's name (lower case, digits, dashes) is its folder; frames how many pictures it has, ms how long each shows (30 to 1000, else 90). what is a note for people.
  • moods: the loops of each mood, played in turn, each for about seven seconds. idle is required; a mood left out plays the idle loops.

To add or replace a character, drop its folder in assets/: nothing else. A running session looks for the manifests still missing every 15 seconds, so the feed starts within a few seconds of the folder landing (a manifest already read is kept until /reload-plugins). When the model changes mid-session, the feed switches within a few seconds.

Where a slot has no manifest:

  • Opus shows its still portrait frames if the folder has them (assets/<slot>/<mood>-NN.png, twelve per mood), else the drawn character: the ninja for Ghostrunner, the thief for Edgerunners.
  • Sonnet, Haiku and Fable show Raijin, the mask, in that model's variant.

The same holds where the terminal draws no pictures (anything but kitty or Ghostty, or inside tmux) and with /loadout style text: no footage, the drawn character for Opus, the mask for the others.

Install

In Claude Code:

/plugin install loadout --marketplace Zokuzo/loadout

or from a shell: claude plugin marketplace add Zokuzo/loadout, then claude plugin install loadout@zokuzo. Restart Claude Code, type /loadout and pick a theme.

At its first start the plugin copies the colour files it ships (colors/<slug>.json) into ~/.claude/themes/, where no file of that name is there: a file of yours is never written over. Delete one there and it comes back at the next start with the shipped colours.

An installed plugin is a copy, in ~/.claude/plugins/cache/zokuzo/loadout/<version>/: sessions run that copy. To work on the plugin, clone the repository, add the clone as a marketplace (claude plugin marketplace add <path to the clone>), and after an edit raise version in .claude-plugin/plugin.json, then:

claude plugin update loadout@zokuzo

and restart Claude Code. /loadout status ends with Plugin folder:, the copy a session runs.

Checks

npx -y -p typescript tsc -p .      # in a clone: once Claude Code has loaded it (claude --plugin-dir .), which writes .claude-plugin/types/
claude plugin validate .
claude plugin test .
bash tests/launcher.sh
python3 themes/validate.py

The pictures

Everything in this repository is drawn in code or as data: the sprites, the text characters, the mask. The camera feed of the Ghostrunner and Cyberpunk Edgerunners themes plays footage from assets/<slot>/, and no footage is shipped: it would be other people's work. Without it those two themes draw their built-in characters and the mask. To add your own, drop a folder per character as "The characters" above describes; assets/ is ignored by git.

Source 31 files
hooks/register.tsx 67 lines
1// Loadout: one plugin, three parts. The engine takes one hooks module per plugin (a second entry in
2// hooks.json is refused), so this is the plugin's module: it loads the Flow map, the avatar (with
3// the /loadout command, beside the theme code it runs) and the frames, each from its own folder. An event takes a single
4// hook without a matcher in a module, so where several parts hook the same event each stands under
5// `{}`, the matcher any input matches; the one hook without a matcher is this file's, below.
6import type { EngineInterface, Register } from 'claude-code'
7
8import { register as avatar } from '../avatar/hooks/register'
9import { register as flow } from '../flow/hooks/register'
10import { register as frames } from '../frames/hooks/register'
11
12// What the three mods kept between sessions before they were one plugin, each in a store of its own.
13// A store is its plugin's alone, so Loadout's starts empty: these keys are copied into it, once.
14const KEPT = { 'kirby-hud': ['pets', 'background', 'style'], 'flow-map': ['hist'], 'color-rows': ['isOn'] } as const
15
16/**
17 * Copies what the three old mods kept into this plugin's store, once: read from their store files
18 * (`<config>/plugins/store/<plugin>_<marketplace>-<id>.json`, as this build writes them), the
19 * installed copy's before a development copy's, a key this store already has left as it is.
20 * With no such folder nothing is recorded, and the next session looks again.
21 */
22async function migrate($: EngineInterface): Promise<void> {
23  if ((await $.store.get('migrated')) !== undefined) return
24  const home = (await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)) ?? `${(await $.env.get('HOME').catch(() => undefined)) ?? '~'}/.claude`
25  const dir = `${home}/plugins/store`
26  const listed = await $.fs.list(dir).catch(() => null)
27  if (!Array.isArray(listed)) return
28  const mine = new Set(await $.store.keys().catch(() => []))
29  const copied: string[] = []
30  const from: string[] = []
31  for (const [old, keys] of Object.entries(KEPT)) {
32    const isDev = (name: string) => name.startsWith(`${old}_inline-`)
33    const file = listed.filter(one => one.kind === 'file' && one.name.startsWith(`${old}_`) && one.name.endsWith('.json'))
34      .sort((a, b) => Number(isDev(a.name)) - Number(isDev(b.name)) || b.mtimeMs - a.mtimeMs)[0]
35    if (file === undefined) continue
36    const text = await $.fs.read(`${dir}/${file.name}`).then(got => (typeof got === 'string' ? got : ''), () => '')
37    let was: unknown
38    try {
39      was = JSON.parse(text)
40    } catch {
41      continue
42    }
43    if (typeof was !== 'object' || was === null || Array.isArray(was)) continue
44    from.push(file.name)
45    for (const key of keys) {
46      if (!Object.hasOwn(was, key) || mine.has(key)) continue
47      await $.store.set(key, (was as Record<string, unknown>)[key])
48      copied.push(key)
49    }
50  }
51  await $.store.set('migrated', { from, keys: copied })
52}
53
54export const register: Register = (on, options) => {
55  // First, so outermost: the parts beneath read the store at their own start.
56  on('session.start', async ($, e, next) => {
57    await migrate($).catch(() => {})
58
59    return next(e)
60  })
61
62  // The Flow map before the avatar: its folded tab stands over the avatar's line of meters above the prompt.
63  flow(on, options)
64  avatar(on, options)
65  frames(on, options)
66}
67
avatar/hooks/register.tsx 1676 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4import { ASSUMED, sourceOf } from './canvas'
5import type { Cell } from './canvas'
6import { beatAfter, heroCanvas, heroGrid, isAtRest } from './cyber'
7import { CAST, SLOTS, frameAt, reelOf, slotOf } from './reel'
8import type { Reel, Slot } from './reel'
9import type { Beat, Moment } from './cyber'
10import { frameOf, nameOf, themed } from './families'
11import type { Acting, FamilyDef, Worry } from './families'
12import { NONE, agentRows, hex, maskFrame } from './hud'
13import type { Grid } from './hud'
14import { HUD_ROWS, hudTree } from './hudpane'
15import { CHROME_ROWS, LEGEND_MAX, encoded, laidOut, laidOutArt, laidOutText, mascotBlocks } from './layout'
16import type { Layout } from './layout'
17import { formOf } from './look'
18import type { Form } from './look'
19import { mascotFrame } from './mascot'
20import { packFiles, packsOf } from './packs'
21import type { Pack } from './packs'
22import { HELP, USAGE, listed, loadoutAsk } from './loadout'
23import type { LoadoutAsk } from './loadout'
24import { pickerRows, pickerTree } from './picker'
25import { BUILT_IN, LIVE, askOf, builtInOffer, customOffer, findTheme, isLive, liveOf, liveSource, liveText, pinLines, quoted, settingOf, slugOf, withTheme, writeLive } from './themes'
26import type { Disk, Offer } from './themes'
27import type { Mood } from './mascot'
28import { alone } from '../../hooks/part'
29import type { Part } from '../../hooks/part'
30import { SWAPS, colorOf } from './minis'
31import { stageFrame, stagedOf as stagedArt } from './scene'
32import { LEAVE_MS, NARROWEST as STAGE_NARROWEST, TICK_MS, sceneOf, stagedOf } from './stage'
33import { FLOW_PANE, VIEW as FLOW_VIEW } from '../../flow/hooks/register'
34import type { KirbyHudCall, KirbyHudHelper, KirbyHudLimit, KirbyHudUsage } from '../../types'
35
36const usage = atom({ plugin: 'loadout', key: 'usage' } as const, null)
37const isWorking = atom({ plugin: 'loadout', key: 'isWorking' } as const, false)
38const happyUntil = atom({ plugin: 'loadout', key: 'happyUntil' } as const, 0)
39const model = atom({ plugin: 'loadout', key: 'model' } as const, '')
40// The theme this terminal shows, as the pane, the tint and the picker take it: a live theme file's source, any other setting itself.
41const theme = atom({ plugin: 'loadout', key: 'theme' } as const, '')
42// The setting this terminal paints from (`custom:loadout`, `custom:kirby`, `dark`): its start's, which the settings file may have left behind since.
43const painted = atom({ plugin: 'loadout', key: 'painted' } as const, '')
44const calls = atom({ plugin: 'loadout', key: 'calls' } as const, [])
45const helpers = atom({ plugin: 'loadout', key: 'helpers' } as const, [])
46const pets = atom({ plugin: 'loadout', key: 'pets' } as const, 0)
47const isPicking = atom({ plugin: 'loadout', key: 'isPicking' } as const, false)
48// The frames part's switch (frames/hooks/register.tsx, where /colors writes it): `/loadout frames` writes it from here, a reference being read from the file that spells it.
49const framesOn = atom({ plugin: 'loadout', key: 'isOn' } as const, true)
50// The Flow map's view, the same way (flow/hooks/register.tsx): `/loadout flow` unfolds its pane.
51const flowView = atom({ plugin: 'loadout', key: 'view' } as const, FLOW_VIEW)
52// The Flow map's theme, the same way: told at once what a live file's source is.
53const flowTheme = atom({ plugin: 'loadout', key: 'flowTheme' } as const, '')
54
55const PANE = 'kirby'
56const PANE_COLUMNS = 34 // Sword Kirby, the widest form, and a column to spare
57const OPEN = { id: PANE, title: 'Avatar', columns: PANE_COLUMNS }
58const FRAME_MS = 90 // the pictures' clock: eleven frames a second
59const CALM_EVERY = 3 // idle, alone and at rest, one frame in three
60const SLOW_MS = 4000 // between re-reads of the usage figures, the model, the theme and the agents
61const STUCK_FRAMES = 22 // a frame not drawn after two seconds no longer holds the next ones back
62const REFUSED_FRAMES = 6 // a picture the engine refused a frame for is tried again after these, then twice as many, and so on
63const REFUSED_MAX = 44
64/**
65 * How many times each art pixel is sent, each way. At 1 the terminal enlarges the picture (kitty
66 * does it pixel for pixel); a terminal that smooths what it enlarges wants `unitOf(cell)` here,
67 * the whole enlarging done before sending, at that many times the bytes squared.
68 */
69const PRESCALE = 1
70const HAPPY_MS = 4000
71const CONTEXT_FULL = 70
72const HELPERS_KEPT = 32
73const HELPER_IDLE_MS = 5 * 60_000 // a helper no list names, silent this long, has ended unseen
74const LIMIT_NEAR = 85
75
76// One accent per meaning, each a key of the person's theme: every theme recolours the pane by itself.
77const COST = 'claude'
78const FIVE = 'suggestion'
79const WEEK = 'planMode'
80const CONTEXT = 'merged' // (not the theme's orange: beside a bar that turns amber it reads as a warning)
81const TRACK = 'subtle'
82const DIM = 'inactive'
83
84/** The figures as the pane keeps them; a measurement does not say when the session began, so that is carried over. */
85const toUsage = (u: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>, startedAt: number | undefined): KirbyHudUsage => ({
86  ...(startedAt === undefined ? {} : { startedAt }),
87  usd: u.cost?.usd,
88  limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
89  tokens: u.context.tokens,
90  window: u.context.window,
91  percent: u.context.percent,
92})
93
94const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: 'weekly' }
95
96const WORRY: Worry = { full: 'context is getting full… /clear?', near: limit => `${limit} limit is almost used up…` }
97
98/** What worries the mascot, in the family's words where it has its own; undefined for nothing. */
99function worryOf(u: KirbyHudUsage | null, { full, near }: Worry = WORRY): string | undefined {
100  if (u === null) return undefined
101  if ((u.percent ?? 0) >= CONTEXT_FULL) return full
102  const limit = u.limits.find(l => l.percentUsed >= LIMIT_NEAR)
103
104  return limit && near(LIMIT_NAMES[limit.kind] ?? limit.kind)
105}
106
107const moodOf = (u: KirbyHudUsage | null, working: boolean, isHappy: boolean): Mood =>
108  isHappy ? 'happy' : worryOf(u) !== undefined ? 'worried' : working ? 'working' : 'idle'
109
110// What a call is about, from the first telling field of its input.
111const SUMMARY_KEYS = ['description', 'command', 'file_path', 'path', 'pattern', 'query', 'url', 'skill', 'prompt', 'name']
112
113function summaryOf(input: unknown): string {
114  if (typeof input !== 'object' || input === null) return ''
115  const fields = input as Record<string, unknown>
116  const value = SUMMARY_KEYS.map(key => fields[key]).find(v => typeof v === 'string' && v !== '')
117
118  return typeof value === 'string' ? value.replace(/\s+/g, ' ').slice(0, 200) : ''
119}
120
121// --- the crew --------------------------------------------------------------
122
123const quiet = (work: Promise<unknown>) => work.catch(() => {})
124const last = <T,>(list: readonly T[]): T | undefined => list[list.length - 1]
125const withCall = (list: readonly KirbyHudCall[], call: KirbyHudCall) => [...list.filter(c => c.id !== call.id), call]
126
127/**
128 * The helpers with agent `id` among them, heard from at `at`; a new one takes the colour the
129 * fewest of them wear, the first of those.
130 */
131function joined(list: readonly KirbyHudHelper[], id: string, at: number, known: Partial<KirbyHudHelper> = {}): KirbyHudHelper[] {
132  const old = list.find(h => h.id === id)
133  // ponytail: past as many helpers at once as there are colours, two share one; draw more colours if crews that big turn up.
134  const worn = SWAPS.map((_swap, slot) => list.filter(h => h.slot % SWAPS.length === slot).length)
135  const slot = worn.indexOf(Math.min(...worn))
136  const helper = { id, label: '', slot, calls: [], joinedAt: at, ...old, ...known, seenAt: at }
137
138  return (old ? list.map(h => (h === old ? helper : h)) : [...list, helper]).slice(-HELPERS_KEPT)
139}
140
141/** The helper back at work: an agent that finished and is called on again has not left. */
142const revived = ({ doneAt: _left, isLost: _how, ...helper }: KirbyHudHelper): KirbyHudHelper => helper
143
144/** The helper, what it does having changed at `at`: until then it ran `tool`, or nothing. Its mini moves on from where that left it. */
145const turned = ({ wasTool: _was, ...helper }: KirbyHudHelper, at: number, tool: string | undefined): KirbyHudHelper =>
146  ({ ...helper, turnedAt: at, ...(tool === undefined ? {} : { wasTool: tool }) })
147
148/** A call began: the main loop's joins its running calls, a subagent's its helper's. */
149async function enter($: EngineInterface, agent: string | undefined, call: KirbyHudCall) {
150  if (agent === undefined) return void (await update($, calls, list => withCall(list, call)))
151  const at = await $.clock.now()
152  await update($, helpers, list => joined(list, agent, at).map(h => (h.id === agent ? turned({ ...revived(h), calls: withCall(h.calls, call) }, at, last(h.calls)?.tool) : h)))
153}
154
155/** A call ended: only that one leaves, an older one still running stays shown. Its helper is hurt by a failure, lands a hit otherwise. */
156async function exit($: EngineInterface, agent: string | undefined, id: string, isFailed: boolean) {
157  const without = (list: readonly KirbyHudCall[]) => list.filter(c => c.id !== id)
158  if (agent === undefined) return void (await update($, calls, without))
159  const at = await $.clock.now()
160  const mark = isFailed ? { hurtAt: at } : { hitAt: at }
161  // Only the newest call is acted out: one that ends under it changes nothing of what the mini does.
162  const ended = (h: KirbyHudHelper): KirbyHudHelper => (last(h.calls)?.id === id ? turned(h, at, last(h.calls)?.tool) : h)
163  await update($, helpers, list => list.map(h => (h.id === agent ? { ...ended(h), ...mark, calls: without(h.calls), seenAt: at } : h)))
164}
165
166const hasLeft = (h: KirbyHudHelper, now: number) => h.doneAt !== undefined && now - h.doneAt >= LEAVE_MS
167
168/** Drops the helpers whose dance and ride off are over. */
169async function sweep($: EngineInterface) {
170  const now = await $.clock.now()
171  if ((await read($, helpers)).some(h => hasLeft(h, now))) await update($, helpers, list => list.filter(h => !hasLeft(h, now)))
172}
173
174/** The agent ended: its helper dances for an answer, faints when `isLost` (stopped, or failed), then leaves. */
175async function finish($: EngineInterface, id: string, isLost: boolean) {
176  const at = await $.clock.now()
177  await update($, helpers, list => list.map(h => (h.id === id && h.doneAt === undefined ? { ...turned(h, at, last(h.calls)?.tool), calls: [], doneAt: at, isLost } : h)))
178  $.clock.after(LEAVE_MS + 50, () => void quiet(sweep($)))
179}
180
181const leave = ($: EngineInterface, id: string, isLost = false) => quiet(finish($, id, isLost))
182
183const ENDED = ['completed', 'failed', 'killed', 'idle']
184
185/** Drops the helpers whose end no event told: ended in the engine's list, or unlisted and long silent. */
186async function prune($: EngineInterface) {
187  const now = await $.clock.now()
188  const listed = new Map((await $.agent.list().catch(() => [])).map(a => [a.id, a.status as string]))
189  const isGone = (h: KirbyHudHelper) => {
190    if (h.doneAt !== undefined) return hasLeft(h, now)
191    const status = listed.get(h.id)
192
193    return status === undefined ? h.calls.length === 0 && now - h.seenAt > HELPER_IDLE_MS : ENDED.includes(status)
194  }
195  if ((await read($, helpers)).some(isGone)) await update($, helpers, list => list.filter(h => !isGone(h)))
196}
197
198// --- the pictures ------------------------------------------------------------
199
200/** A grid of cells as RasterProps has them: where a cell shows the terminal's own background, the pane's `ground` instead. */
201function packed({ words }: Grid, ground: number): string {
202  const cells = ground === NONE ? words : words.map((word, i) => (i % 3 === 2 && word === NONE ? ground : word))
203
204  return btoa(String.fromCharCode(...new Uint8Array(cells.buffer)))
205}
206
207// --- the characters' time ------------------------------------------------------
208//
209// A character (cyber.ts) is drawn from a moment: the mood, since when, and the mood before, which its
210// pose leaves smoothly; and the working kata's own clock, which runs faster while a tool does. Not
211// drawn state: a reload starts them over, and the pose blends in from the stance.
212let beat: Beat = { mood: 'idle', since: 0 }
213let kata = 0
214let kataAt = 0
215// How a character is drawn, as the store has it: in pictures, in text, or (auto) in pictures where the terminal draws them.
216type Style = 'pixel' | 'text' | 'auto'
217const STYLES: readonly Style[] = ['pixel', 'text', 'auto']
218let style: Style = 'auto'
219
220/** The character's moment at `now`. `happyUntil` is when the last cheer ends: one that comes while she is still glad starts her flip over. */
221function momentOf(mood: Mood, now: number, isBusy: boolean, happyUntil: number): Moment {
222  beat = beatAfter(beat, mood, now, mood === 'happy' ? happyUntil - HAPPY_MS : undefined)
223  kata += Math.max(0, Math.min(500, now - kataAt)) * (isBusy ? 1.3 : 0.8)
224  kataAt = now
225
226  return { mood, now, since: beat.since, clock: kata, ...(beat.was === undefined ? {} : { was: beat.was }) }
227}
228
229// A mascot that acts (Clawd, in true pixels) keeps its own beat: its pose leaves the mood before smoothly too.
230let stride: Beat = { mood: 'idle', since: 0 }
231
232function actingOf(mood: Mood, now: number): Acting {
233  stride = beatAfter(stride, mood, now)
234
235  return { mood, now, since: stride.since, ...(stride.was === undefined ? {} : { was: stride.was.mood }) }
236}
237
238/** Where the pictures are, once found: a folder per slot (reel.ts), `<assets>/<slot>/`. */
239let assets: string | null = null
240const PORTRAIT_FRAMES = 12
241const PORTRAIT_MS: Readonly<Record<Mood, number>> = { idle: 160, working: 110, happy: 90, worried: 110 }
242
243// Footage per slot, where its manifest was found (`<slot>/manifest.json`, loops at `<slot>/<clip>/NNN.png`).
244const reels = new Map<Slot, Reel>()
245// The slots with still portrait frames (`<slot>/<mood>-NN.png`, twelve a loop): what an Opus without a manifest shows.
246const stills = new Set<Slot>()
247// When the mood on screen began and whose it is, so a mood's loops start from their first frame, and again when the character changes.
248let reelMood: { slot: Slot; mood: Mood; since: number } | null = null
249let reelsTriedAt = -Infinity
250
251/**
252 * Looks for the slots' manifests (and the Opus slots' stills) not found yet. Footage can arrive
253 * after the session began, a folder at a time: looked for again a few times a minute, until all
254 * eight manifests are found. Never throws.
255 */
256async function findAssets($: EngineInterface) {
257  if (reels.size >= SLOTS.length) return
258  const now = await $.clock.now()
259  if (now - reelsTriedAt < 15_000) return
260  reelsTriedAt = now
261  for (const dir of assets === null ? [`${$.plugin.root}/assets`, `${$.plugin.root}/../assets`] : [assets]) {
262    for (const slot of SLOTS) {
263      if (reels.has(slot)) continue
264      const reel = reelOf(await $.fs.read(`${dir}/${slot}/manifest.json`).then(read => (typeof read === 'string' ? read : ''), () => ''))
265      if (reel !== null) reels.set(slot, reel)
266      else if (slot.endsWith('-opus') && !stills.has(slot) && (await $.fs.exists(`${dir}/${slot}/idle-00.png`).then(found => found === true, () => false))) stills.add(slot)
267    }
268    if (reels.size + stills.size > 0) {
269      assets = dir
270      break
271    }
272  }
273}
274
275/** Whether a slot has pictures for the camera feed: its footage, or its stills. */
276const hasPictures = (slot: Slot): boolean => assets !== null && (reels.has(slot) || stills.has(slot))
277
278/** The feed's frame at `now`: the slot's loop for the mood, from the first frame of a mood or a character new on screen; without footage its still portrait, a happy one played from the cheer's start, the others looping on the clock. */
279function portraitOf(slot: Slot, mood: Mood, now: number, happyUntil: number): { file: string; format: 'png' } {
280  const reel = reels.get(slot)
281  if (reel !== undefined) {
282    if (reelMood === null || reelMood.mood !== mood || reelMood.slot !== slot) reelMood = { slot, mood, since: now }
283    const at = frameAt(reel, mood, now - reelMood.since)
284    if (at !== null) return { file: `${assets}/${slot}/${at.clip}/${String(at.frame).padStart(3, '0')}.png`, format: 'png' }
285  }
286  const step = mood === 'happy' ? Math.min(PORTRAIT_FRAMES - 1, Math.max(0, Math.floor((now - (happyUntil - HAPPY_MS)) / PORTRAIT_MS.happy))) : Math.floor(now / PORTRAIT_MS[mood]) % PORTRAIT_FRAMES
287
288  return { file: `${assets}/${slot}/${mood}-${String(step).padStart(2, '0')}.png`, format: 'png' }
289}
290
291/** What a Raster or an Image shows: cells, or a source. */
292type Picture = { cells: string } | { source: ReturnType<typeof sourceOf> | { file: string; format: 'png' } }
293
294/** The mascot at `now`: a true-pixel frame where the layout has them (clear where nothing is drawn: the pane's ground shows through), else the cell-block step of that moment on `ground`. */
295function mascotPicture(drawn: Layout, mood: Mood, now: number, ground: number, isBusy = false, happyUntil = 0, isLight = false): Picture {
296  if ('text' in drawn && drawn.feed !== undefined) return { source: portraitOf(drawn.feed, mood, now, happyUntil) }
297  if ('text' in drawn && drawn.hero !== undefined) {
298    const moment = momentOf(mood, now, isBusy, happyUntil)
299
300    return drawn.art === undefined ? { cells: packed(heroGrid(drawn.hero, moment, drawn.size.columns, drawn.size.rows), ground) }
301      : { source: sourceOf(heroCanvas(drawn.hero, moment, drawn.art.mascot.width, drawn.art.mascot.height), PRESCALE) }
302  }
303  if ('text' in drawn) return { cells: packed(maskFrame(drawn.text.skin, drawn.text.form, mood, now, drawn.size.columns, drawn.size.rows), ground) }
304  if (drawn.art === undefined) return { cells: encoded(mascotBlocks(drawn, mood, Math.floor(now / TICK_MS)), ground) }
305  const { mascot, scale } = drawn.art
306  const acted = drawn.look.family.acted
307  if (acted !== undefined) return { source: sourceOf(acted.frame(drawn.look.form, actingOf(mood, now), mascot.width, mascot.height, scale, isLight), PRESCALE) }
308
309  return { source: sourceOf(mascotFrame(frameOf(drawn.look, mood, now), mood, now, mascot.width, mascot.height, scale), PRESCALE) }
310}
311
312/** The stage at `now`, the same way; the layout has one. */
313function stagePicture(drawn: Layout, crew: readonly KirbyHudHelper[], now: number, ground: number, isLight: boolean): Picture {
314  if ('text' in drawn) return { cells: packed(agentRows(drawn.text.skin, crew, now, drawn.stage?.columns ?? 1, drawn.stage?.rows ?? 1), ground) }
315  const { family } = drawn.look
316  const art = drawn.art?.stage
317  if (art) return { source: sourceOf(stageFrame(family.mini, crew, now, art.width, art.height, isLight), PRESCALE) }
318
319  return { cells: encoded(sceneOf(family.blocks, crew, Math.floor(now / TICK_MS), now, drawn.stage?.columns ?? STAGE_NARROWEST, isLight), ground) }
320}
321
322// --- text ------------------------------------------------------------------
323
324const pad2 = (n: number) => String(n).padStart(2, '0')
325
326function countdown(resetsAt: string | undefined, now: number): string {
327  const minutes = Math.floor((Date.parse(resetsAt ?? '') - now) / 60000)
328  if (Number.isNaN(minutes)) return ''
329  if (minutes <= 0) return 'reset due' // the reading is older than its window
330  const d = Math.floor(minutes / 1440)
331  const h = Math.floor((minutes % 1440) / 60)
332  const m = minutes % 60
333
334  return `resets in ${d > 0 ? `${d}d${pad2(h)}h` : h > 0 ? `${h}h${pad2(m)}` : `${m}m`}`
335}
336
337const tokens = (n: number) =>
338  n >= 1e6 ? `${Number((n / 1e6).toFixed(1))}M` : `${Math.round(n / 1000)}k`
339
340const money = (usd: number | undefined) => (usd === undefined ? '$ n/a' : `$${usd.toFixed(2)}`)
341
342const tone = (percent: number, warnAt: number, badAt: number) =>
343  percent < warnAt ? 'success' : percent < badAt ? 'warning' : 'error'
344
345const limitTone = (percent: number) => tone(percent, 60, LIMIT_NEAR)
346const contextTone = (percent: number) => tone(percent, 50, CONTEXT_FULL)
347
348/** `text` on at most `max` lines of `width`, broken at spaces where it can, the last cut with an ellipsis. */
349function wrapped(text: string, width: number, max: number): string[] {
350  const lines: string[] = []
351  let rest = text.trim()
352  while (rest.length > width && lines.length < max - 1) {
353    const space = rest.lastIndexOf(' ', width)
354    const at = space > width / 2 ? space : width
355    lines.push(rest.slice(0, at))
356    rest = rest.slice(at).trimStart()
357  }
358
359  return [...lines, rest.length > width ? `${rest.slice(0, width - 1)}…` : rest]
360}
361
362// --- life ------------------------------------------------------------------
363
364// Not drawn state: the timers die with the module, so these may too.
365let isSeeded = false
366let isTicking = false
367let shape = '' // the pane's size when the cell was last measured
368
369// --- the terminal ------------------------------------------------------------
370
371// Whether this terminal draws pictures: kitty and Ghostty do, a multiplexer passes none through. Unknown
372// until asked; a blit told "an Image draws its alt here" turns it off for a while, not for good:
373// the engine says so too while it is still asking the terminal, or is out of image ids.
374let canPicture: boolean | undefined
375const RETRY_MS = 15_000 // after the first such denial; doubled by each one in a row
376const RETRY_MAX_MS = 10 * 60_000
377let retryMs = RETRY_MS
378let retryAt = 0 // when pictures are tried again, 0 for no retry due
379// A cell in device pixels, null while nobody could measure it.
380let cell: Cell | null = null
381
382async function detect($: EngineInterface) {
383  const read = (value: Promise<string | undefined>) => value.catch(() => undefined)
384  const [term, program, kitty] = [await read($.env.get('TERM')), await read($.env.get('TERM_PROGRAM')), await read($.env.get('KITTY_WINDOW_ID'))]
385  // A multiplexer between the session and the terminal: tmux, screen or zellij here, or one reached over ssh, told by the TERM it sets.
386  const muxes = [await read($.env.get('TMUX')), await read($.env.get('STY')), await read($.env.get('ZELLIJ'))]
387  const isMuxed = /^(screen|tmux)/.test(term ?? '') || muxes.some(value => value !== undefined)
388  canPicture = !isMuxed && (kitty !== undefined || /kitty|ghostty/i.test(`${term ?? ''} ${program ?? ''}`))
389  // A terminal that can be told its colours: not a multiplexer's pane (the pty is the multiplexer's, which keeps its own), not the console, not no terminal at all.
390  isColourable = !isMuxed && term !== undefined && !/^(dumb|linux)?$/.test(term)
391}
392
393// --- the theme packs -------------------------------------------------------------
394//
395// What a theme asks of the pane is data: the packs of `<mods>/themes/` (packs.ts). They are read at
396// the start and again when a file of theirs changes; the families' own tables answer where no pack does.
397let packs: readonly Pack[] = []
398// Where they were read from (null: nowhere), what its listing was then, and the files left out, each with why.
399let packsAt: string | null = null
400let packsPrint: string | undefined
401let packsSkipped: readonly string[] = []
402
403/**
404 * Reads the packs where the folder's listing changed (a name, a size, a time); resolves true when
405 * what was read did. Never throws: a folder or a file that cannot be read is left out, and a file
406 * that is no pack is skipped by name.
407 */
408async function loadPacks($: EngineInterface): Promise<boolean> {
409  for (const dir of [`${$.plugin.root}/themes`, `${$.plugin.root}/../themes`]) {
410    const entries = await $.fs.list(dir).catch(() => null)
411    if (!Array.isArray(entries)) continue
412    const { files, print } = packFiles(entries)
413    if (files.length === 0) continue
414    if (packsAt === dir && packsPrint === print) return false
415    const texts = new Map<string, string | null>()
416    for (const file of files) texts.set(file.name, await $.fs.read(`${dir}/${file.name}`).then(text => (typeof text === 'string' ? text : null), () => null))
417    const read = packsOf(texts)
418    ;[packs, packsSkipped, packsAt, packsPrint] = [read.packs, read.skipped, dir, print]
419
420    return true
421  }
422  if (packsAt === null) return false
423  ;[packs, packsSkipped, packsAt, packsPrint] = [[], [], null, undefined]
424
425  return true
426}
427
428// --- the terminal's background -------------------------------------------------
429//
430// A theme cannot set the terminal's own background; the terminal can be told: OSC 11 sets it, OSC 111
431// gives back the one the person configured. The engine has no word for writing to the terminal, so
432// the escape is written to the pty the session runs on, by a child that finds it from its parents
433// (Linux). Nothing here ever throws into a hook, and nothing is written anywhere but a pty of the
434// person's own: the path is checked here before the child is started, and again by the child on the
435// file it has opened.
436
437// Whether this session is one a person sits at in a terminal, and whether that terminal takes colours.
438let isAtTerminal = false
439let isColourable = false
440// The /kirby background switch, as the store has it (on while nobody said).
441let isTinting = true
442// The session's pty: undefined until looked for, null where there is none to write to.
443let tty: string | null | undefined
444// What the terminal was last told, for sure (the write ended well): a colour, '' for its own background back; undefined while nothing was.
445let tinted: string | undefined
446// What the write under way asks for, undefined for none: a tick that comes meanwhile sends nothing.
447let asking: string | undefined
448// A colour was written, or tried (a write that failed may have reached the terminal all the same), and its own background not since given back for sure.
449let isDirty = false
450// The writes that failed in a row. After GIVE_UP of them the ticks stop trying; the person's own word, and the session's end, try once more.
451let failures = 0
452const GIVE_UP = 3
453// The session is over and the terminal has its own background back: a timer still running tells it nothing more.
454let isEnded = false
455// The writes, one after the other: each is a child of its own, and children started together do not write in the order they were started.
456let writing: Promise<unknown> = Promise.resolve()
457
458// Prints the nearest parent's pty, whether it is a character device, whose it is, and who asks.
459const FIND_TTY = `import os, stat
460pid = os.getppid()
461for _ in range(4):
462    for fd in (1, 0, 2):
463        try:
464            path = os.readlink(f'/proc/{pid}/fd/{fd}')
465            if path.startswith('/dev/pts/'):
466                info = os.stat(path)
467                print(path, int(stat.S_ISCHR(info.st_mode)), info.st_uid, os.getuid())
468                raise SystemExit
469        except OSError:
470            pass
471    try:
472        pid = int(open(f'/proc/{pid}/stat').read().rsplit(')', 1)[1].split()[1])
473    except (OSError, ValueError):
474        break
475`
476
477// Writes one escape to the pty named: OSC 11 with the colour given, OSC 111 with none. Anything that
478// is not a pty of the caller's own, or not a colour, is refused before a byte is written.
479const SET_GROUND = `import os, re, stat, sys
480path, color = sys.argv[1], sys.argv[2]
481if not re.fullmatch('/dev/pts/[0-9]+', path) or not re.fullmatch('(#[0-9a-f]{6})?', color):
482    sys.exit(2)
483fd = os.open(path, os.O_WRONLY | os.O_NOCTTY | os.O_NOFOLLOW | os.O_NONBLOCK)
484info = os.fstat(fd)
485if not (stat.S_ISCHR(info.st_mode) and info.st_uid == os.getuid() and os.isatty(fd)):
486    sys.exit(3)
487os.write(fd, (f'\\033]11;{color}\\a' if color else '\\033]111\\a').encode())
488`
489
490const PTY = /^\/dev\/pts\/\d+$/
491
492/** The pty this session runs on, or null: looked for once. Only a `/dev/pts/N` that is a character device owned by the person is one. */
493async function ttyOf($: EngineInterface): Promise<string | null> {
494  if (tty !== undefined) return tty
495  const got = await $.process.run(['python3', '-I', '-c', FIND_TTY], { timeoutMs: 3000 }).catch(() => null)
496  const [path = '', isDevice, owner, me] = (got?.stdout ?? '').trim().split(' ')
497  tty = got?.exitCode === 0 && PTY.test(path) && isDevice === '1' && owner !== undefined && owner === me ? path : null
498
499  return tty
500}
501
502/**
503 * Tells the terminal the background `theme` calls for (its own back for a theme that brings none,
504 * or with the switch off), once per change. A theme not read yet changes nothing. Where it cannot
505 * work (no pty, not a person's terminal) it does nothing. A write that fails is not taken for done:
506 * the next tick tries again, GIVE_UP times in a row at the most, so one lost write never leaves the
507 * terminal in another theme's colour.
508 */
509async function tint($: EngineInterface, theme: string) {
510  if (isEnded || !isAtTerminal || !isColourable || theme === '') return
511  const ground = isTinting ? themed(theme, packs).ground : undefined
512  const want = ground === undefined ? '' : hex(ground.terminal)
513  if (want === (asking ?? tinted) || failures >= GIVE_UP) return
514  const path = await ttyOf($)
515  if (isEnded || path === null || want === (asking ?? tinted)) return
516  await written($, path, want)
517}
518
519/** Writes `want` and records what became of it: `tinted` only once the write has ended well. */
520async function written($: EngineInterface, path: string, want: string) {
521  asking = want
522  if (want !== '') isDirty = true
523  const sent = await told($, path, want)
524  if (asking === want) asking = undefined
525  if (sent?.exitCode !== 0) return void (failures += 1)
526  failures = 0
527  tinted = want
528  // (A colour asked for while this restore was running is written after it: the terminal is not clean then.)
529  if (want === '' && (asking === undefined || asking === '')) isDirty = false
530}
531
532/** One escape written to the pty, after every write asked for before it. */
533function told($: EngineInterface, path: string, color: string) {
534  const sent = writing.then(() => $.process.run(['python3', '-I', '-c', SET_GROUND, path, color], { timeoutMs: 3000 }).catch(() => null))
535  writing = sent
536
537  return sent
538}
539
540/** The terminal's own background back, if a colour was ever written or tried since it last had it: for the session's end, whatever failed before. */
541async function untint($: EngineInterface) {
542  if (!isDirty || tty === undefined || tty === null) return
543  await written($, tty, '')
544}
545
546// The terminal's size in cells and in pixels, asked of the pty the session runs on: the engine
547// has no word for it, and a child has no terminal of its own, so it looks up its parents' (Linux).
548const PROBE = `import fcntl, os, struct, termios
549pid = os.getpid()
550for _ in range(16):
551    for fd in (0, 1, 2):
552        try:
553            path = os.readlink(f'/proc/{pid}/fd/{fd}')
554            if path.startswith('/dev/pts/'):
555                size = fcntl.ioctl(os.open(path, os.O_RDONLY | os.O_NOCTTY), termios.TIOCGWINSZ, bytes(8))
556                print(*struct.unpack('HHHH', size))
557                raise SystemExit
558        except OSError:
559            pass
560    try:
561        pid = int(open(f'/proc/{pid}/stat').read().rsplit(')', 1)[1].split()[1])
562    except (OSError, ValueError):
563        break
564`
565
566/** Measures a cell; resolves true when it changed. Where it cannot, the cell stays unknown and is taken for one by two. */
567async function measure($: EngineInterface): Promise<boolean> {
568  const got = await $.process.run(['python3', '-I', '-c', PROBE], { timeoutMs: 3000 }).catch(() => null)
569  const [rows = 0, columns = 0, x = 0, y = 0] = (got?.stdout ?? '').trim().split(/\s+/).map(Number)
570  const next = { w: Math.round(x / columns), h: Math.round(y / rows) }
571  const found = next.w >= 4 && next.w <= 64 && next.h >= next.w && next.h <= 160 ? next : null
572  const isNew = JSON.stringify(found) !== JSON.stringify(cell)
573  cell = found
574
575  return isNew
576}
577
578/** Re-reads the engine's figures; writes (and so redraws) only when they moved. */
579async function refresh($: EngineInterface) {
580  const live = await $.session.usage()
581  const fresh = toUsage(live, live.startedAt)
582  if (JSON.stringify(fresh) !== JSON.stringify(await read($, usage))) await update($, usage, () => fresh)
583}
584
585async function seed($: EngineInterface) {
586  if (isSeeded) return
587  isSeeded = true
588  try {
589    const petted = await $.store.get('pets')
590    if (typeof petted === 'number') await update($, pets, () => petted)
591    isTinting = (await $.store.get('background')) !== false
592    const kept = await $.store.get('style')
593    style = STYLES.find(one => one === kept) ?? 'auto'
594  } catch (error) {
595    isSeeded = false // try again: a pet must not overwrite a count never read
596
597    throw error
598  }
599}
600
601/** Re-reads what picks the mascot: the session's model, its form, and the theme, its family. */
602async function sync($: EngineInterface) {
603  const id = await $.session.model().catch(() => '')
604  if (id !== (await read($, model))) await update($, model, () => id)
605  const known = await themesOf($)
606  if (!known.isListed) return
607  const row = String(known.row?.value ?? '')
608  // What this terminal paints from. A running session keeps the theme it started with whatever the
609  // settings file says later (themes.ts): where a live theme is in play, a change of the row is
610  // never followed. What the person picks in this session (/theme, /config) is told by `config.set`
611  // (`chose`), not read from the row. Any other change is followed as it always was.
612  const kept = (await read($, painted)) || paintedHere
613  const isMoved = lastRow !== undefined && row !== lastRow
614  const runs = kept === '' || (isMoved && liveOf(row) === undefined && liveOf(kept) === undefined) ? row : kept
615  lastRow = row
616  paintedHere = runs
617  if (runs !== (await read($, painted))) await update($, painted, () => runs)
618  const name = await sourced($, runs, known)
619  if (name !== (await read($, theme))) await update($, theme, () => name)
620  if (name !== (await read($, flowTheme))) await quiet(update($, flowTheme, () => name))
621  await quiet(tint($, name))
622}
623
624const sessionLook = async ($: EngineInterface): Promise<{ family: FamilyDef; form: Form }> => ({
625  family: themed(await read($, theme), packs).family,
626  form: formOf(await read($, model)),
627})
628
629/** The slow work: what the engine does not tell by an event is read again. */
630async function slow($: EngineInterface) {
631  await seed($)
632  // session.measure is not raised by a /clear or a /compact: without this
633  // re-read the context row would keep the old fill until the next turn ends.
634  await refresh($)
635  // A pack edited, added or taken away shows within a tick: the pane is drawn from it, the terminal told its colour.
636  const isRepacked = await loadPacks($).catch(() => false)
637  const isReoffered = await loadOffers($).catch(() => false)
638  await sync($)
639  if (isRepacked || isReoffered) $.ui.invalidate('ui.render')
640  await quiet(prune($))
641  if (retryAt !== 0 && (await $.clock.now()) >= retryAt) {
642    retryAt = 0
643    await quiet(detect($))
644  }
645  // A pane behind another tab, or closed, is not drawn: its frames stop, and nothing asks for a
646  // redraw that would start them again. Shown again, the engine draws it and they resume.
647  if (!(await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)) {
648    isPaneUp = false
649    shown = null
650
651    return
652  }
653  $.ui.invalidate('ui.render') // keeps the countdowns moving
654}
655
656// What the pane's last draw mounted and drew from: a frame repaints exactly those pictures, with
657// no render pass and without asking the engine for anything but the time.
658// `keys` are the pictures mounted.
659// `ground` is the pane's own background, NONE for the terminal's; `isLight` whether the theme is a light one.
660type Shown = { layout: Layout; ground: number; isLight: boolean; usage: KirbyHudUsage | null; isWorking: boolean; isBusy: boolean; happyUntil: number; crew: readonly KirbyHudHelper[]; keys: Set<string> }
661let shown: Shown | null = null
662let isPaneUp = false
663// What each picture last showed: a frame equal to it is not sent.
664const sent = new Map<string, string>()
665const printOf = (picture: Picture) => ('cells' in picture ? picture.cells : 'file' in picture.source ? picture.source.file : picture.source.rgba)
666// The pictures the engine refused a frame for, by key: the frame count at which each is tried again, and how long it waited.
667const refused = new Map<string, { until: number; wait: number }>()
668
669/**
670 * Swaps one mounted picture for its next frame, unless nothing in it moved. A frame the engine
671 * refuses means the picture is not there as drawn (not mounted yet, another size): it is tried
672 * again a few frames later, then more and more sparsely, and at once when the pane is drawn again.
673 */
674async function put($: EngineInterface, key: string, box: { columns: number; rows: number }, picture: Picture) {
675  if (sent.get(key) === printOf(picture) || frames < (refused.get(key)?.until ?? 0)) return
676  sent.set(key, printOf(picture))
677  const got = await $.ui.blit({ requestId: PANE, key, ...picture, columns: box.columns, rows: box.rows })
678  if (got.deny === undefined) {
679    if ('source' in picture) retryMs = RETRY_MS
680    refused.delete(key)
681
682    return
683  }
684  sent.delete(key)
685  const wait = Math.min(REFUSED_MAX, (refused.get(key)?.wait ?? REFUSED_FRAMES / 2) * 2)
686  refused.set(key, { until: frames + wait, wait })
687  // An Image drawing its alt: no pictures here for now, the cell blocks take over until the retry.
688  if ('source' in picture && /placeholder|\balt\b/i.test(got.deny)) {
689    canPicture = false
690    retryAt = (await $.clock.now()) + retryMs
691    retryMs = Math.min(RETRY_MAX_MS, retryMs * 2)
692    $.ui.invalidate('ui.render')
693  }
694}
695
696let frames = 0
697let isCalm = false
698
699/** One frame of the pictures. Idle with no agents, little moves: most frames are skipped before anything is asked. */
700async function draw($: EngineInterface) {
701  frames += 1
702  const mounted = shown
703  if (mounted === null || mounted.keys.size === 0 || (isCalm && frames % CALM_EVERY !== 0)) return
704  const now = await $.clock.now()
705  const mood = moodOf(mounted.usage, mounted.isWorking, now < mounted.happyUntil)
706  // (A mask in text never rests: its scan line and its glow are the whole of its idling, and a frame of cells is small.)
707  // A character rests too, but only while nothing of it moves faster than its breathing, nor is about to: a head that turns, fingers on a deck, a flourish are drawn every frame.
708  const hero = 'text' in mounted.layout ? mounted.layout.hero : undefined
709  // (A mascot that acts rests the same way: between a blink, a look, a shuffle and a hop, nothing of it moves but its breathing.)
710  const acted = 'look' in mounted.layout && mounted.layout.art !== undefined ? mounted.layout.look.family.acted : undefined
711  // (A camera feed never rests: its loops are the whole of it.)
712  const isStill = 'text' in mounted.layout ? mounted.layout.feed === undefined && hero !== undefined && isAtRest(hero, momentOf(mood, now, false, mounted.happyUntil)) : acted === undefined || acted.isAtRest(actingOf(mood, now))
713  isCalm = mood === 'idle' && mounted.crew.length === 0 && isStill
714  if (mounted.keys.has('kirby')) await put($, 'kirby', mounted.layout.size, mascotPicture(mounted.layout, mood, now, mounted.ground, mounted.isBusy, mounted.happyUntil, mounted.isLight))
715  if (mounted.keys.has('stage') && mounted.layout.stage !== null && mounted.crew.length > 0) await put($, 'stage', mounted.layout.stage, stagePicture(mounted.layout, mounted.crew, now, mounted.ground, mounted.isLight))
716}
717
718// The frame being drawn, 0 for none, and for how many ticks: a slow blit makes the clock skip
719// frames, never queue them, and one that never answers is given up on.
720let drawing = 0
721let waited = 0
722let turn = 0
723
724function frame($: EngineInterface) {
725  if (!isPaneUp) return
726  if (drawing !== 0 && (waited += 1) < STUCK_FRAMES) return
727  const mine = (turn += 1)
728  drawing = mine
729  waited = 0
730  void draw($).catch(() => {}).finally(() => {
731    if (drawing === mine) drawing = 0
732  })
733}
734
735function startTicking($: EngineInterface) {
736  if (isTicking) return
737  isTicking = true
738  $.clock.every(FRAME_MS, () => frame($))
739  $.clock.every(SLOW_MS, () => void slow($).catch(() => {}))
740}
741
742async function cheer($: EngineInterface) {
743  const now = await $.clock.now()
744  await update($, happyUntil, () => now + HAPPY_MS)
745  $.clock.after(HAPPY_MS + 50, () => $.ui.invalidate('ui.render'))
746}
747
748// --- hooks -----------------------------------------------------------------
749
750/** A source's own theme, undefined where it sets none (or cannot be read). */
751async function themeIn($: EngineInterface, source: 'user' | 'project' | 'local' | 'flag' | 'policy'): Promise<string | undefined> {
752  const value = (await $.settings.read({ source }).catch(() => null))?.['theme']
753
754  return typeof value === 'string' && value !== '' ? value : undefined
755}
756
757// The settings files that outrank the user's, as the person knows them.
758const OVER = { policy: "your organization's managed settings", local: ".claude/settings.local.json of this project", project: ".claude/settings.json of this project" } as const
759
760/** A path with its `.`, `..`, doubled and closing slashes taken out: two spellings of one file compare equal. */
761const tidied = (path: string): string => {
762  const parts: string[] = []
763  for (const part of path.split('/')) {
764    if (part === '..') parts.pop()
765    else if (part !== '' && part !== '.') parts.push(part)
766  }
767
768  return `/${parts.join('/')}`
769}
770
771/**
772 * Whether the project's settings file is the user's own: a session started in the home directory,
773 * where `<cwd>/.claude/settings.json` is `~/.claude/settings.json`. Both paths are resolved: as
774 * written first, then through their links (`realpath`, where the two still differ).
775 */
776async function isUsersFile($: EngineInterface, users: string): Promise<boolean> {
777  const cwd = await $.session.cwd().catch(() => '')
778  if (cwd === '') return false
779  const projects = `${cwd}/.claude/settings.json`
780  if (tidied(projects) === tidied(users)) return true
781  const got = await $.process.run(['realpath', '-m', '--', projects, users], { timeoutMs: 3000 }).catch(() => null)
782  const [one, other] = (got?.exitCode === 0 ? got.stdout : '').trim().split('\n')
783
784  return one !== undefined && one !== '' && one === other
785}
786
787/** Where the settings and the themes are, and the themes there are: the engine's own row, and the files of `<config>/themes`. */
788async function themesOf($: EngineInterface) {
789  const rows = await $.config.list().catch(() => null)
790  const row = rows?.find(one => one.key === 'theme')
791  const home = (await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)) ?? `${(await $.env.get('HOME').catch(() => undefined)) ?? '~'}/.claude`
792  const listed = await $.fs.list(`${home}/themes`).catch(() => null)
793  const all = (Array.isArray(listed) ? listed : []).filter(one => one.kind === 'file' && one.name.endsWith('.json')).sort((x, y) => (x.name.slice(0, -5) < y.name.slice(0, -5) ? -1 : 1))
794  // The live files (themes.ts) are Loadout's own copies: never a source, never offered, never matched by name.
795  const files = all.filter(one => !isLive(one.name.slice(0, -5)))
796
797  return { row, isListed: rows !== null, home, files, lives: all.filter(one => isLive(one.name.slice(0, -5))), builtIns: row?.options ?? BUILT_IN, customs: files.map(one => one.name.slice(0, -5)) }
798}
799
800type Themes = Awaited<ReturnType<typeof themesOf>>
801
802/**
803 * Puts the colour files the plugin ships (`colors/<slug>.json`) in `<config>/themes` where no file of
804 * that name is there: a first start has the themes to pick. A file already there is the person's own
805 * and is never written over. Resolves the names written.
806 */
807async function installColours($: EngineInterface): Promise<string[]> {
808  const shipped = await $.fs.list(`${$.plugin.root}/colors`).catch(() => null)
809  if (!Array.isArray(shipped)) return []
810  const { home, files, lives } = await themesOf($)
811  // (No home to name: `~` is no place to write.)
812  if (!/^(\/|[A-Za-z]:[\\/])/.test(home)) return []
813  const there = new Set([...files, ...lives].map(one => one.name))
814  const written: string[] = []
815  for (const one of shipped) {
816    if (one.kind !== 'file' || !/^[a-z0-9]+(-[a-z0-9]+)*\.json$/.test(one.name) || there.has(one.name)) continue
817    const text = await $.fs.read(`${$.plugin.root}/colors/${one.name}`).catch(() => null)
818    if (typeof text !== 'string' || liveText(one.name.slice(0, -5), text) === undefined) continue
819    // A folder that could not be listed, or a link there: what stands is the person's. In doubt, nothing is written.
820    if (await $.fs.exists(`${home}/themes/${one.name}`).catch(() => true)) continue
821    await $.fs.write(`${home}/themes/${one.name}`, text)
822    written.push(one.name)
823  }
824
825  return written
826}
827
828// --- the live theme files (themes.ts says why) --------------------------------------------------
829
830// This terminal's painted setting again, for where the session's state starts over and the module does not (a /clear).
831let paintedHere = ''
832// The settings row as last read: a change of it is what `sync` weighs.
833let lastRow: string | undefined
834
835/**
836 * The person picked a theme in this session, with Claude Code's own /theme or /config: this terminal
837 * paints it from now on, whatever the settings row said before (the same theme picked again, or the
838 * live theme on a terminal that was not on it, moves no row). Probed: /theme raises `config.set`
839 * with the theme picked, a `custom:` one too, and raises nothing when left with Esc.
840 */
841async function chose($: EngineInterface, setting: string) {
842  paintedHere = setting
843  await update($, painted, () => setting)
844  await sync($)
845}
846// Each live file's source, with the size and time it was read at.
847const liveSeen = new Map<string, { print: string; source: string }>()
848
849/** A theme setting as the theme it shows: a live file's source (read again where the file's size or time changed), any other setting itself. */
850async function sourced($: EngineInterface, setting: string, { home, lives, builtIns }: Pick<Themes, 'home' | 'lives' | 'builtIns'>): Promise<string> {
851  const slug = liveOf(setting)
852  const entry = slug === undefined ? undefined : lives.find(one => one.name === `${slug}.json`)
853  if (slug === undefined || entry === undefined) return setting
854  const print = `${entry.size}:${entry.mtimeMs}`
855  const seen = liveSeen.get(slug)
856  if (seen?.print === print) return seen.source
857  const text = await $.fs.read(`${home}/themes/${slug}.json`).then(got => (typeof got === 'string' ? got : ''), () => '')
858  const source = liveSource(text, builtIns) ?? setting
859  liveSeen.set(slug, { print, source })
860
861  return source
862}
863
864// Whether `mv` can be run here, once it was seen to.
865let canMove = false
866
867/** The file system as `writeLive` takes it. The engine has no rename, and no removal: both are a process's (`mv`, `rm`). */
868async function diskOf($: EngineInterface): Promise<Disk> {
869  const read: Disk['read'] = path => $.fs.read(path).then(got => (typeof got === 'string' ? got : null), () => null)
870  canMove ||= await $.process.run(['sh', '-c', 'command -v mv'], { timeoutMs: 3000 }).then(got => got.exitCode === 0, () => false)
871  if (!canMove) {
872    // ponytail: where no `mv` can be run the file is written in place, not atomically (one small write), and no temporary file is made: nothing here could remove it. A rename in `$.fs` would lift this.
873    const held = new Map<string, string>()
874
875    return {
876      read,
877      write: async (path, text) => void held.set(path, text),
878      move: async (from, to) => {
879        const text = held.get(from)
880        if (text === undefined) throw new Error('nothing to write')
881        await $.fs.write(to, text)
882      },
883    }
884  }
885
886  return {
887    read,
888    write: (path, text) => $.fs.write(path, text),
889    move: async (from, to) => {
890      const got = await $.process.run(['mv', '-f', '--', from, to], { timeoutMs: 3000 }).catch(error => ({ exitCode: -1, stderr: String(error) }))
891      if (got.exitCode === 0) return
892      await quiet($.process.run(['rm', '-f', '--', from], { timeoutMs: 3000 })) // no temporary file is left in the themes folder
893      throw new Error(got.stderr.trim() || 'the rename failed')
894    },
895  }
896}
897
898/** A live file's text for `setting`'s theme, read from its file: `text`, or `failed`, why there is none. Asked before anything is written or set. */
899async function liveFor($: EngineInterface, home: string, setting: string): Promise<{ text: string; failed?: undefined } | { failed: string }> {
900  const slug = slugOf(setting)
901  const source = setting.startsWith('custom:') ? await $.fs.read(`${home}/themes/${slug}.json`).then(got => (typeof got === 'string' ? got : ''), () => '') : null
902  const text = liveText(slug, source)
903
904  return text === undefined ? { failed: `Not changed: ${home}/themes/${slug}.json could not be read as a theme.` } : { text }
905}
906
907/** Rewrites the live files named with `text` (`liveFor`), each only where it says something else. Resolves why not, undefined once all say it. */
908async function putLive($: EngineInterface, home: string, text: string, lives: readonly string[]): Promise<string | undefined> {
909  const stamp = (await $.session.id().catch(() => '')).replace(/[^\w-]/g, '') || 'x'
910  const disk = await diskOf($)
911  for (const live of lives) {
912    const failed = await writeLive(disk, `${home}/themes`, live, text, stamp).then(() => undefined, error => String(error))
913    if (failed !== undefined) return `Not changed: ${home}/themes/${live}.json could not be written (${failed}).`
914    liveSeen.delete(live) // read again at once, whatever its size and time say
915  }
916
917  return undefined
918}
919
920/**
921 * What a theme change must know: the themes, the theme this session runs under and what set it
922 * (`pinned`: its own `--settings`, since its start; `over`: a project's, a local or a managed file
923 * that outranks the user's), the global one, and whether an organization holds it. `isHome` where
924 * the project's file that sets it is the user's own (a session started in the home directory): that
925 * is the global theme, set by the global settings, and nothing outranks them.
926 */
927async function themeFacts($: EngineInterface) {
928  await quiet(sync($)) // what this terminal paints from, as of now and not of the last tick
929  const known = await themesOf($)
930  const { home, builtIns, customs } = known
931  const row = String(known.row?.value ?? '')
932  // `runs` is the setting this terminal paints from, `active` the theme that shows; `global` and `globalSource` the same of the user's file.
933  const runs = (await read($, painted)) || paintedHere || row
934  const active = await sourced($, runs, known)
935  const global = await themeIn($, 'user')
936  const globalSource = global === undefined ? undefined : await sourced($, global, known)
937  const lives = known.lives.map(one => one.name.slice(0, -5))
938  // Live here: this terminal paints from a live file that is there, so a rewrite of it shows at once. `own`: a file of its own.
939  const liveHere = liveOf(runs)
940  const isLiveHere = liveHere !== undefined && lives.includes(liveHere)
941  const own = isLiveHere && liveHere !== LIVE ? liveHere : undefined
942  // What outranks the user's file here, highest first (the types: 'user' | 'project' | 'local' | 'flag' | 'policy', lowest first).
943  // A project's, a local or a managed file counts only where the session does run under its theme: that the engine takes `theme` from them is not probed.
944  let held: { source: 'policy' | 'flag' | 'local' | 'project'; theme: string } | undefined
945  let isHome = false
946  for (const source of ['policy', 'flag', 'local', 'project'] as const) {
947    const theme = await themeIn($, source)
948    if (held !== undefined || theme === undefined || (source !== 'flag' && theme !== row)) continue
949    if (source === 'project' && (await isUsersFile($, `${home}/settings.json`))) isHome = true
950    else held = { source, theme }
951  }
952  // `pinned`: held by its start's `--settings` to a theme that is no live file (one that is can be rewritten: `own`).
953  const pinned = held?.source === 'flag' && liveOf(held.theme) === undefined ? held.theme : undefined
954  const over = held === undefined || held.source === 'flag' ? undefined : { theme: held.theme, where: OVER[held.source] }
955  const isLocked = known.row?.isLocked === true || held?.source === 'policy'
956  const session = await $.session.id().catch(() => '')
957  // Left behind: the global setting moved on since this terminal's start, and it still paints its start's theme.
958  const isBehind = pinned === undefined && over === undefined && own === undefined && global !== undefined && runs !== global
959
960  return { runs, active, builtIns, customs, home, global, globalSource, lives, own, isLiveHere, isBehind, pinned, over, isHome, isLocked, session, path: `${home}/settings.json` }
961}
962
963type ThemeFacts = Awaited<ReturnType<typeof themeFacts>>
964
965/**
966 * Sets the theme every terminal follows: the "theme" key of the user's settings, and nothing else
967 * of that file. Resolves why it was not changed, undefined once it is. Refused under a managed theme.
968 */
969async function setEverywhere($: EngineInterface, { isLocked, path }: ThemeFacts, setting: string): Promise<string | undefined> {
970  if (isLocked) return 'Not changed: your organization sets the theme.'
971  if (setting.startsWith('custom:')) {
972    // The settings row takes a built-in name alone: a custom theme is written where /theme writes it, the user's settings file, its other keys as they are.
973    // (No settings file yet is an empty one; one that is there and cannot be read is left alone.)
974    const isThere = await $.fs.exists(path).then(found => found === true, () => true)
975    const text = isThere ? await $.fs.read(path).then(got => (typeof got === 'string' ? got : null), () => null) : ''
976    const next = text === null ? null : withTheme(text, setting)
977    if (next === null) return `Not changed: ${path} could not be read as settings. Pick the theme with /theme.`
978    const failed = await $.fs.write(path, next).then(() => undefined, error => String(error))
979    if (failed !== undefined) return `Not changed: ${path} could not be written (${failed}). Pick the theme with /theme.`
980  } else {
981    const got = await $.config.set({ key: 'theme', value: setting }).catch(error => ({ deny: String(error) }))
982    if (got.deny !== undefined) return `Not changed: ${got.deny}.`
983  }
984  // The engine reads the file again by itself; the pane follows as soon as it has.
985  $.clock.after(1500, () => void quiet(sync($)))
986
987  return undefined
988}
989
990/** The line that starts this conversation again in this terminal, with nothing pinned: it then follows the global theme. */
991const plainLine = (session: string): string => `claude ${session === '' ? '--continue' : `--resume ${quoted(session)}`}`
992
993/**
994 * `/loadout <theme>`, and a press in the picker: the theme for every terminal. Rewrites every live
995 * file (the shared one and each terminal's own), and where the global setting is not the live
996 * theme yet, makes it so. `text` says what became of it and no more; `restart` is the line that
997 * brings this terminal onto the live theme, where it is not on one.
998 */
999async function switchAll($: EngineInterface, setting: string, known?: ThemeFacts): Promise<{ text: string; restart?: string }> {
1000  const facts = known ?? (await themeFacts($))
1001  const name = slugOf(setting)
1002  if (facts.isLocked) return { text: 'Not changed: your organization sets the theme.' }
1003  if (setting === 'auto') {
1004    // No theme file can follow the terminal's light or dark ground: `auto` is the setting itself, read at a session's start.
1005    const failed = await setEverywhere($, facts, setting)
1006
1007    return { text: failed ?? 'The theme setting is now auto. Nothing changed on screen: each terminal takes it at its next start.' }
1008  }
1009  // The theme is read before anything is written or set: one that cannot be read changes nothing.
1010  const made = await liveFor($, facts.home, setting)
1011  if (made.failed !== undefined) return { text: made.failed }
1012  const isFirst = facts.global !== `custom:${LIVE}`
1013  if (isFirst) {
1014    // The live file before the setting where there is none (nobody uses a file that is not there): no terminal starts on a theme without a file.
1015    const unmade = facts.lives.includes(LIVE) ? undefined : await putLive($, facts.home, made.text, [LIVE])
1016    const failed = unmade ?? (await setEverywhere($, facts, `custom:${LIVE}`))
1017    if (failed !== undefined) return { text: failed }
1018  }
1019  const failed = await putLive($, facts.home, made.text, [LIVE, ...facts.lives.filter(one => one !== LIVE)])
1020  if (failed !== undefined) return { text: failed }
1021  await quiet(sync($))
1022  if (facts.over !== undefined) return { text: `Loadout ${name} is now the global theme. This terminal stays on ${facts.over.theme}: ${facts.over.where} sets it.` }
1023  if (facts.pinned !== undefined) return { text: `Loadout ${name} set for the terminals that follow the global theme. This one stays on ${facts.pinned} (pinned at its start): /loadout follow releases it.` }
1024  if (facts.isLiveHere) return { text: `Loadout ${name}: every terminal.${isFirst ? ' One started on another theme shows it at its next start.' : ''}` }
1025
1026  return {
1027    text: `Loadout ${name} is set, and nothing changed here yet: a running session keeps the theme it started with (${slugOf(facts.active) || 'unknown'}). This terminal, and any other not yet on a Loadout theme, shows it at its next start; from then on switching is instant.`,
1028    restart: plainLine(facts.session),
1029  }
1030}
1031
1032/** The line that starts this conversation again in this terminal on `setting`, through the launcher where the plugin's copy has `bin/loadout` (it then has a live file of its own), else pinned by `--settings`. */
1033async function pinLine($: EngineInterface, setting: string, session: string): Promise<{ line: string; isLauncher: boolean }> {
1034  const file = `${$.plugin.root}/bin/loadout`
1035  const lines = pinLines(setting, session, file)
1036  // (The launcher is the mod's own file: a copy of the mod without it gets the plain line, which needs nothing.
1037  // And it takes only a name the settings spell in lower case, digits and dashes: any other theme gets the plain line.)
1038  const isLauncher = (await $.fs.exists(file).then(found => found === true, () => false)) && /^[a-z0-9-]+$/.test(slugOf(setting))
1039
1040  return { line: isLauncher ? lines.short : lines.plain, isLauncher }
1041}
1042
1043/**
1044 * `/loadout <theme> here`: this terminal alone. Live where it has a theme file of its own (the
1045 * launcher gave it one at its start): that file is rewritten. Anywhere else nothing changes, and
1046 * the answer is the line that starts it again with one.
1047 */
1048async function switchHere($: EngineInterface, setting: string, facts: ThemeFacts): Promise<string> {
1049  const name = slugOf(setting)
1050  if (facts.isLocked) return 'Not possible: your organization sets the theme.'
1051  if (facts.own !== undefined && setting !== 'auto') {
1052    const made = await liveFor($, facts.home, setting)
1053    const failed = made.failed ?? (await putLive($, facts.home, made.text, [facts.own]))
1054    if (failed !== undefined) return failed
1055    await quiet(sync($))
1056
1057    return `Loadout ${name}: this terminal.`
1058  }
1059  if (facts.pinned === setting) return `Nothing to do: this terminal is already pinned to ${name}.`
1060  const { line, isLauncher } = await pinLine($, setting, facts.session)
1061
1062  return handed($, line, isLauncher && setting !== 'auto'
1063    ? `Nothing changed yet: this terminal has no theme file of its own to rewrite. That line starts this conversation again here on ${name}, with one: from then on "/loadout <theme> here" is instant. The others keep theirs.`
1064    : `Nothing changed yet: a running session cannot change its own theme. That line starts this conversation again in this terminal, pinned to ${name}; the others keep theirs.`)
1065}
1066
1067/** A theme asked for by name, everywhere or here, said in full: with the restart line on the clipboard where one is needed. */
1068async function switched($: EngineInterface, setting: string, scope: 'all' | 'here', facts: ThemeFacts): Promise<string> {
1069  if (scope === 'here') return switchHere($, setting, facts)
1070  const got = await switchAll($, setting, facts)
1071
1072  return got.restart === undefined ? got.text : handed($, got.restart, got.text)
1073}
1074
1075/** `/loadout follow`: the way back to the global theme, which only a start without the pin gives. */
1076async function followed($: EngineInterface, facts: ThemeFacts): Promise<string> {
1077  const { over, pinned, own, isLocked, isBehind, active, globalSource, session } = facts
1078  const global = globalSource === undefined ? 'not set' : slugOf(globalSource)
1079  if (over !== undefined) return isLocked ? 'Not possible: your organization sets the theme.' : `Nothing changed: this terminal's theme (${over.theme}) is set by ${over.where}. Remove the "theme" key there and it follows the global theme (${global}).`
1080  if (own !== undefined) return handed($, plainLine(session), `Nothing changed yet: this terminal has its own theme (${slugOf(active)}) since its start. That line starts this conversation again without it: it then follows the global theme (${global}).`)
1081  if (pinned !== undefined) return handed($, plainLine(session), `Nothing changed yet: this terminal is pinned to ${slugOf(pinned)} since its start. That line starts this conversation again without the pin: it then follows the global theme (${global}).`)
1082
1083  return isBehind ? `Nothing to do: this terminal follows the global theme, and shows it (${global}) at its next start.` : `Nothing to do: this terminal already follows the global theme (${slugOf(active)}).`
1084}
1085
1086/**
1087 * `/avatar theme`: the themes and this terminal's, then `/loadout`'s own answers under the older
1088 * words (a name as the setting spells it, and `all` or `here` said): a theme for every terminal, for
1089 * this one, or `follow`, the way back. Says what it did, and no more.
1090 */
1091async function themeCommand($: EngineInterface, words: readonly string[]): Promise<string> {
1092  const ask = askOf(words)
1093  const facts = await themeFacts($)
1094  const { active, builtIns, customs, home } = facts
1095  const themes = `Built-in: ${builtIns.join(', ')}.\nCustom: ${customs.join(', ') || 'none'} (${home}/themes).`
1096  const usage = 'Change it: /avatar theme <name> all (every terminal) · /avatar theme <name> here (this terminal) · /avatar theme follow (this terminal follows the global theme again).'
1097
1098  if (ask.kind === 'list') return `Theme here: ${active || 'not read yet'} (${scopeOf(facts)}).\n${themes}\n${usage}`
1099  if (ask.kind === 'follow') return followed($, facts)
1100  if (ask.name === '' || ask.scope === undefined) return `Say which theme and where: /avatar theme ${ask.name || '<name>'} all, or /avatar theme ${ask.name || '<name>'} here.\n${themes}`
1101  const setting = settingOf(ask.name, builtIns, customs)
1102  if (setting === undefined) return `No theme "${ask.name}".\n${themes}`
1103
1104  return switched($, setting, ask.scope, facts)
1105}
1106
1107// --- the loadouts ------------------------------------------------------------------
1108//
1109// What the pane's picker offers: the custom themes (the files of `<config>/themes`, each with four of
1110// its own colours) and then the built-in ones. Read at the start and again when a file there changes.
1111let offers: readonly Offer[] = []
1112let offersPrint: string | undefined
1113/** The picker was asked for by `/loadout`: the next drawing of the pane says so where it has no room to unfold it. */
1114let isPickerAsked = false
1115const NO_ROOM = 'The Avatar pane is too short to list the themes here: make it taller, or type /loadout <theme>.'
1116
1117/** Reads the themes on offer where their listing changed; resolves true when it did. Never throws. */
1118async function loadOffers($: EngineInterface): Promise<boolean> {
1119  const { home, files, builtIns } = await themesOf($)
1120  const print = `${builtIns.join(',')}|${files.map(one => `${one.name}:${one.size}:${one.mtimeMs}`).join('|')}`
1121  if (print === offersPrint) return false
1122  const customs: Offer[] = []
1123  for (const file of files.slice(0, 40)) customs.push(customOffer(file.name.slice(0, -5), await $.fs.read(`${home}/themes/${file.name}`).then(text => (typeof text === 'string' ? text : ''), () => '')))
1124  ;[offers, offersPrint] = [[...customs, ...builtIns.map(builtInOffer)], print]
1125
1126  return true
1127}
1128
1129/** Opens the Avatar pane; with `isPicker`, its Loadouts picker unfolded. */
1130async function openAvatar($: EngineInterface, isPicker = false): Promise<void> {
1131  if (isPicker) {
1132    await quiet(loadOffers($))
1133    await quiet(update($, isPicking, () => true))
1134    isPickerAsked = true
1135    $.ui.invalidate('ui.render') // an open pane whose picker was already asked for draws again, to say where it has no room
1136  }
1137  await $.ui.open(OPEN)
1138}
1139
1140/** A row of the picker was pressed: that theme for every terminal, the picker folded, and a word of what became of it. */
1141async function pick($: EngineInterface, setting: string) {
1142  const told = await switchAll($, setting).catch(error => ({ text: `Not changed: ${String(error)}.`, restart: undefined }))
1143  await quiet(update($, isPicking, () => false))
1144  $.ui.toast(told.restart === undefined ? told.text : `${told.text} "/loadout ${slugOf(setting)}" gives the line that starts it again.`)
1145}
1146
1147/** What `/avatar status` and `/loadout status` tell of this part. */
1148async function avatarStatus($: EngineInterface) {
1149  reelsTriedAt = -Infinity
1150  await quiet(findAssets($))
1151  await quiet(loadPacks($))
1152  const name = await read($, theme)
1153  const served = themed(name, packs)
1154
1155  return {
1156    theme: name,
1157    by: served.pack === undefined ? 'the built-in table' : `pack ${served.pack.slug}`,
1158    told: `${served.family.key}${'skin' in served.family ? ` ${served.family.skin.variant}` : ''}, ${served.isLight ? 'light' : 'dark'}, background ${served.ground === undefined ? "the terminal's own" : hex(served.ground.terminal)}`,
1159    packs: packsAt === null ? 'none found (the built-in tables answer)' : `${packs.map(pack => pack.slug).join(', ') || 'none'} from ${packsAt}${packsSkipped.length === 0 ? '' : `; skipped: ${packsSkipped.join(', ')}`}`,
1160    pictures: assets ?? 'not found',
1161    // Each slot: found, with its character's name and how many loops, or missing.
1162    footage: SLOTS.map(slot => [slot, reels.get(slot)] as const).map(([slot, reel]) => (reel === undefined ? `${slot} missing` : `${slot} ${reel.name ?? `${CAST[slot]} [no name in its manifest]`} (${Object.keys(reel.clips).length} loops)`)).join(', '),
1163    style,
1164    isTinting,
1165  }
1166}
1167
1168/**
1169 * `/avatar` and `/kirby`, whose words `/loadout` takes too (`cmd` is the command as typed, for the
1170 * usage lines): the status, the theme, the two switches, or the pane. `also` is the same in /loadout's words.
1171 */
1172async function avatarCommand($: EngineInterface, args: string, cmd = '/avatar'): Promise<{ text: string; also: string }> {
1173  const [word, value] = args.trim().toLowerCase().split(/\s+/)
1174  if (word === 'status') {
1175    const now = await avatarStatus($)
1176
1177    return { also: '/loadout status', text: `Pictures: ${now.pictures}. Footage: ${now.footage}. Style: ${now.style}. Theme: ${now.theme || 'not read yet'}, by ${now.by} (${now.told}). Packs: ${now.packs}. Plugin folder: ${$.plugin.root}.` }
1178  }
1179  if (word === 'theme') return { also: '/loadout <theme> · /loadout <theme> here · /loadout follow', text: await themeCommand($, args.trim().toLowerCase().split(/\s+/).slice(1)) }
1180  if (word === 'background' && (value === 'on' || value === 'off')) {
1181    isTinting = value === 'on'
1182    failures = 0 // the person asks: tried again, whatever failed before
1183    await quiet($.store.set('background', isTinting))
1184    await quiet(tint($, await read($, theme)))
1185
1186    return { also: '/loadout background on|off', text: isTinting ? 'The terminal background follows the theme.' : 'The terminal background is your own again.' }
1187  }
1188  if (word === 'background') return { also: '/loadout background on|off', text: `Usage: ${cmd} background on|off (now ${isTinting ? 'on' : 'off'}).` }
1189  if (word === 'style') {
1190    const asked = STYLES.find(one => one === value)
1191    if (asked === undefined) return { also: '/loadout style pixel|text|auto', text: `Usage: ${cmd} style pixel|text|auto (now ${style}).` }
1192    style = asked
1193    await quiet($.store.set('style', style))
1194    $.ui.invalidate('ui.render')
1195
1196    return { also: '/loadout style pixel|text|auto', text: style === 'auto' ? 'The character is drawn in pixel art where the terminal draws pictures, in text characters elsewhere.' : style === 'pixel' ? 'The character is drawn in pixel art (in text characters where the terminal draws no pictures).' : 'The character is drawn in text characters.' }
1197  }
1198  await openAvatar($)
1199
1200  return { also: '/loadout avatar (the pane) · /loadout (the theme picker)', text: 'Avatar pane opened.' }
flow/hooks/register.tsx 927 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, FsEntry, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Agent, Call, History, Run, View } from '../../types'
5import { agentKey, compose, isSeg, runKey, soloKey, tab, widthOf } from './draw'
6import type { Act, Btn, Line, Seg } from './draw'
7import { brief, chart, declared, enlist, frameOf, heal, healRun, histKey, isOver, isWayfinder, keepRuns, kindOf, lingering, MAX_CALLS, newRun, noted, parseMeta, parseSites, phaseKey, pickEffort, QUEUE_MS, runView, scratchOf, settled, soloAgent, sweep, toHistory, toPlan, withSolo } from './lib'
8import { lookOf, toPack } from './palette'
9import type { Pack } from './palette'
10import { alone } from '../../hooks/part'
11import type { Part } from '../../hooks/part'
12import { liveOf } from '../../avatar/hooks/themes'
13
14const PANE = 'flow-map'
15const TITLE = 'Flow map'
16const TOOL = 'mcp__loadout__steps'
17// The dock width asked for: the handoff's default. The person's own drag or keys win over it.
18const COLUMNS = 56
19const [NARROWEST, WIDEST] = [34, 96]
20// The live beat: the handoff redraws every 150 ms; the pane is drawn again only when a spinner frame (450 ms) or a second turned.
21const FAST_MS = 150
22const SLOW_MS = 1000
23const MAP_MS = 5000
24// The themes folder is listed again this often: an edited pack shows within a few seconds. With no folder found, far less often.
25const PACKS_MS = 4000
26const NO_PACKS_MS = 60_000
27// With nothing running, the one thing still watched is the theme.
28const IDLE_MS = 4000
29// A stop is asked, then confirmed: not sooner than this after the question showed (a key held down, a double Enter), not later than that.
30const ARM_MS = 500
31const CONFIRM_MS = 15_000
32
33/** The pane as `$.ui.open` takes it, and the view nobody touched yet: `/loadout flow` opens it from the avatar's file. */
34export const FLOW_PANE = { id: PANE, title: TITLE, columns: COLUMNS } as const
35export const VIEW: View = { folded: [], sel: '', open: [], mode: '', target: '', at: 0, hidden: false, cols: 0, colsFrom: 0, colsAt: 0 }
36const runs = atom({ plugin: 'loadout', key: 'runs' } as const, [])
37const plan = atom({ plugin: 'loadout', key: 'plan' } as const, null)
38const now = atom({ plugin: 'loadout', key: 'now' } as const, 0)
39const view = atom({ plugin: 'loadout', key: 'view' } as const, VIEW)
40const way = atom({ plugin: 'loadout', key: 'way' } as const, { active: false, root: '', touched: '', chosen: '', hint: '', open: [] })
41const wayMap = atom({ plugin: 'loadout', key: 'map' } as const, null)
42const hist = atom({ plugin: 'loadout', key: 'hist' } as const, {})
43const theme = atom({ plugin: 'loadout', key: 'flowTheme' } as const, '')
44// The avatar part's (avatar/hooks/register.tsx writes them): the theme this terminal shows, a live theme file resolved to its source, and the setting it paints from.
45const shownTheme = atom({ plugin: 'loadout', key: 'theme' } as const, '')
46const painted = atom({ plugin: 'loadout', key: 'painted' } as const, '')
47const solo = atom({ plugin: 'loadout', key: 'solo' } as const, [])
48const packs = atom({ plugin: 'loadout', key: 'packs' } as const, { sig: '', list: [] })
49
50/** The view as this version writes it: one kept in $.state by an earlier load has only its first two fields. */
51const healView = (v: View): View => ({ ...VIEW, ...v })
52
53const SECTION = {
54  id: 'flow-map:steps',
55  scope: 'session',
56  text:
57    `Step map: at the start of any task with 3 or more steps (a build, a video edit, research), call the ${TOOL} tool with a short title and the full ordered step list, one step "active". ` +
58    'Call it again with the full list whenever a step changes status. It only updates a side pane the user watches; skip it for shorter tasks and inside subagents. ' +
59    'While the wayfinder skill is active the pane already shows the wayfinder map read from its files: do not re-post the map through this tool.',
60} as const
61
62const STEPS_SCHEMA = {
63  type: 'object',
64  required: ['title', 'steps'],
65  properties: {
66    title: { type: 'string', description: 'What the task is, a few words.' },
67    steps: {
68      type: 'array',
69      description: 'Every step of the plan, in order. Replaces the plan shown.',
70      items: {
71        type: 'object',
72        required: ['label', 'status'],
73        properties: {
74          label: { type: 'string' },
75          status: { type: 'string', enum: ['pending', 'active', 'done', 'blocked'] },
76        },
77      },
78    },
79  },
80}
81
82const patchAgent = (list: Run[], hit: (a: Agent) => boolean, change: (a: Agent, r: Run) => [Agent, Partial<Run>?]) =>
83  list.map(r => {
84    const at = r.agents.findIndex(hit)
85    const old = r.agents[at]
86    if (old === undefined) return r
87    const [agent, extra] = change(old, r)
88    return { ...r, ...extra, agents: r.agents.map((a, i) => (i === at ? agent : a)) }
89  })
90
91// Bookkeeping never costs the event it watches: a write that fails is dropped.
92const quiet = (work: Promise<unknown>) => work.catch(() => undefined)
93
94const isAbsolute = (path: string) => /^(\/|[A-Za-z]:[\\/])/.test(path)
95
96/**
97 * Reads the wayfinder map from disk into the pane's state, when wayfinder is on.
98 * Lists first: the files are read again only when a name, size or time changed,
99 * so the slow tick costs a few listings. Every miss (no directory, a file gone
100 * or too big) is an empty answer, never a throw.
101 */
102async function refresh($: EngineInterface) {
103  const w = await read($, way)
104  if (!w.active) return
105  const ls = (path: string) => $.fs.list(path).catch((): FsEntry[] => [])
106  const cat = (path: string) => $.fs.read(path).catch(() => null)
107  const cwd = (await $.session.cwd().catch(() => '')).replace(/[\\/]+$/, '')
108  // Where the session's own file calls pointed, then the working directory and up to the git root.
109  const places = w.root === '' ? [] : [isAbsolute(w.root) ? w.root : `${cwd}/${w.root}`]
110  for (let dir = cwd, i = 0; dir !== '' && i < 12; i++) {
111    places.push(`${dir}/.scratch`)
112    if (await $.fs.exists(`${dir}/.git`).catch(() => true)) break
113    dir = dir.slice(0, Math.max(0, dir.replace(/\\/g, '/').lastIndexOf('/')))
114  }
115  type Effort = { name: string; mtimeMs: number; files: FsEntry[]; issues: FsEntry[] }
116  let scratch = ''
117  let efforts: Effort[] = []
118  for (const place of places) {
119    for (const d of await ls(place)) {
120      if (d.kind !== 'dir') continue
121      const files = (await ls(`${place}/${d.name}`)).filter(f => f.kind === 'file' && (f.name === 'map.md' || f.name === 'gitlab.json'))
122      if (!files.some(f => f.name === 'map.md')) continue
123      const issues = (await ls(`${place}/${d.name}/issues`)).filter(f => f.kind === 'file' && /^\d+.*\.md$/.test(f.name)).sort((a, b) => a.name.localeCompare(b.name))
124      efforts.push({ name: d.name, mtimeMs: Math.max(0, ...[...files, ...issues].map(f => f.mtimeMs)), files, issues })
125    }
126    if (efforts.length > 0) {
127      scratch = place
128      break
129    }
130  }
131  efforts = efforts.sort((a, b) => a.name.localeCompare(b.name))
132  // A /wayfinder argument that names an effort is the person's own pick: it wins over an earlier one.
133  const words = w.hint.split(/[\s/]+/)
134  const named = efforts.find(e => words.includes(e.name))?.name
135  if (w.hint !== '' && efforts.length > 0) await update($, way, v => ({ ...v, hint: '', ...(named === undefined ? {} : { touched: named, chosen: '' }) }))
136  const name = pickEffort(efforts, named === undefined ? [w.chosen, w.touched] : [named])
137  const mine = efforts.find(e => e.name === name)
138  const old = await read($, wayMap)
139  if (mine === undefined) {
140    if (old !== null) await update($, wayMap, () => null)
141    return
142  }
143  const names = efforts.map(e => e.name)
144  const sig = [scratch, name, names.join(','), ...[...mine.files, ...mine.issues].map(f => `${f.name}:${f.size}:${f.mtimeMs}`)].join('|')
145  if (old?.sig === sig) return
146  const dir = `${scratch}/${name}`
147  const issues: { name: string; text: string | null }[] = []
148  for (const f of mine.issues) issues.push({ name: f.name, text: await cat(`${dir}/issues/${f.name}`) })
149  const read1 = chart(name, (await cat(`${dir}/map.md`)) ?? '', issues, mine.files.some(f => f.name === 'gitlab.json') ? await cat(`${dir}/gitlab.json`) : null)
150  await update($, wayMap, () => ({ ...read1, scratch, efforts: names, sig }))
151}
152
153/**
154 * The active theme's name, what picks the palette: the config row "theme". Where a Loadout live theme
155 * file is in play (the row or what this terminal paints from is `custom:loadout…`), the name is the
156 * avatar part's, which reads the file's source and knows what this terminal still shows.
157 */
158async function syncTheme($: EngineInterface) {
159  const rows = await $.config.list().catch(() => null)
160  if (rows === null) return
161  const row = String(rows.find(one => one.key === 'theme')?.value ?? '')
162  const shown = await read($, shownTheme)
163  const name = shown !== '' && (liveOf(row) !== undefined || liveOf(await read($, painted)) !== undefined) ? shown : row
164  if (name !== (await read($, theme))) await update($, theme, () => name)
165}
166
167/**
168 * The theme packs, read from the themes folder beside the mod (or inside it). Lists first: the files are read
169 * again only when a name, size or time changed. A file that does not parse or is not a pack is skipped; with
170 * no folder there is no pack and the built-in palettes draw. Never throws.
171 */
172async function loadPacks($: EngineInterface) {
173  const root = $.plugin.root.replace(/[\\/]+$/, '')
174  for (const dir of [`${root}/themes`, `${root.slice(0, Math.max(0, root.replace(/\\/g, '/').lastIndexOf('/')))}/themes`]) {
175    const found = await $.fs.list(dir).catch(() => null)
176    if (found === null) continue
177    const files = found.filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.name !== 'schema.json').sort((a, b) => a.name.localeCompare(b.name))
178    const sig = [dir, ...files.map(f => `${f.name}:${f.size}:${f.mtimeMs}`)].join('|')
179    if (sig === (await read($, packs)).sig) return
180    const read1: Pack[] = []
181    for (const f of files.slice(0, 60)) {
182      const text = await $.fs.read(`${dir}/${f.name}`).catch(() => null)
183      let json: unknown = null
184      try {
185        json = JSON.parse(text ?? 'null')
186      } catch {
187        // not JSON: skipped
188      }
189      const pack = toPack(json)
190      if (pack !== null) read1.push(pack)
191    }
192    await update($, packs, () => ({ sig, list: read1 }))
193    return
194  }
195  if ((await read($, packs)).sig !== '') await update($, packs, () => ({ sig: '', list: [] }))
196}
197
198// When the last run started or was resumed: the finished run still on screen leaves then (lib's lingering).
199// Not kept in $.state: after a reload a finished run sent away less than 3 minutes before shows for what is left of them.
200let begunAt = 0
201/** The runs on screen: the live ones, then the finished one that lingers. Read at the engine's clock: the drawing's stands still while nothing moves. */
202async function onScreen($: EngineInterface, list: Run[], clock: number): Promise<Run[]> {
203  const live = list.filter(r => r.endedAt === null)
204  if (live.length === list.length) return live
205  const kept = lingering(list, await $.clock.now().catch(() => clock), begunAt)
206  return kept === undefined ? live : [...live, kept]
207}
208
209// Agent ids seen on a tool call that $.agent.list() does not name: a workflow's agent whose spawn was missed, an engine fork. Not asked about twice.
210const strangers = new Set<string>()
211/**
212 * A tool call from an agent the pane does not know: a plain subagent whose start was not seen (the mod loaded
213 * after it) is taken in from $.agent.list(), which names plain subagents and teammates and no workflow agent.
214 */
215async function adopt($: EngineInterface, id: string): Promise<boolean> {
216  if (strangers.has(id)) return false
217  const row = (await $.agent.list().catch(() => [])).find(r => r.id === id)
218  if (row === undefined || row.teammateId !== undefined) {
219    if (strangers.size > 400) strangers.clear()
220    strangers.add(id)
221    return false
222  }
223  const at = await $.clock.now()
224  await update($, solo, list => (list.some(a => a.id === id) ? list : withSolo(list, soloAgent({ key: `a:${id}`, id, label: row.description || row.type, kind: row.type, startedAt: at }))))
225  return true
226}
227
228/** What $.agent.list() says ended, for a plain subagent whose last turn the pane did not see end. */
229async function reconcile($: EngineInterface) {
230  const rows = await $.agent.list().catch(() => null)
231  if (rows === null) return
232  const ends = new Map(rows.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'killed').map(r => [r.id, r.status]))
233  if (!(await read($, solo)).some(a => !isOver(a) && ends.has(a.id))) return
234  const at = await $.clock.now()
235  await update($, solo, list => list.map(a => {
236    const how = isOver(a) ? undefined : ends.get(a.id)
237    return how === undefined ? a : { ...a, status: how === 'completed' ? 'done' as const : 'failed' as const, stopped: how === 'killed', endedAt: Math.max(at, a.startedAt), calls: [] }
238  }))
239}
240
241/** Whether anything of the mod is on screen: its pane shown and placed, or its folded tab. Unknown reads as shown. */
242async function isOnScreen($: EngineInterface) {
243  if (healView(await read($, view)).hidden) return true
244  const panes = await $.ui.panes().catch(() => null)
245  return panes === null || panes.some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
246}
247
248/** One more duration under each key, in the session's state and, for the sessions after, in the store. */
249async function learn($: EngineInterface, facts: readonly (readonly [key: string, ms: number])[]) {
250  if (facts.length === 0) return
251  const next = await update($, hist, (h: History) => facts.reduce((all, [key, ms]) => noted(all, key, ms), h))
252  await $.store.set('hist', next)
253}
254
255/** A run that completed teaches how long each of its phases took. */
256const phaseFacts = (r: Run, h: History) =>
257  runView(settled(healRun(r)), r.endedAt ?? 0, h).phases.filter(p => p.status === 'done' && p.agents.length > 0).map(p => [phaseKey(r.name, p.title), p.el] as const)
258
259// The one timer. While a run is live and the mod is on screen it beats every FAST_MS and writes the clock the
260// drawing reads when a spinner frame or a second turned; with the pane hidden, or only the wayfinder map to
261// re-read, it beats every SLOW_MS and draws nothing; with neither a live run nor wayfinder it beats every IDLE_MS,
262// only to see the theme change (the palette is redrawn when it does) and a finished run's 3 minutes pass (the pane is
263// drawn once more, without it), and draws at no other beat.
264// Not drawing state: a hot reload drops it and session.start or the next event starts it again.
265let timer: Timer | undefined
266let period = 0
267let isBusy = false
268let skipped = 0
269let isShown = true
270let seenAt = 0
271let mapAt = 0
272let packsAt = 0
273let frame = ''
274// The main conversation's turn is running: the active step's node turns while it does, and stands still after.
275let isWorking = false
276function tick($: EngineInterface) {
277  const arm = (ms: number) => {
278    timer?.cancel()
279    period = ms
280    timer = ms === 0 ? undefined : $.clock.every(ms, beat)
281  }
282  const bump = async () => {
283    const all = await read($, runs)
284    const isLive = all.some(r => r.endedAt === null)
285    const isWay = (await read($, way)).active
286    const t = await $.clock.now()
287    let mine = await read($, solo)
288    if (t - seenAt >= SLOW_MS || t < seenAt) {
289      seenAt = t
290      isShown = await isOnScreen($)
291      await syncTheme($)
292      if (t - packsAt >= ((await read($, packs)).sig === '' ? NO_PACKS_MS : PACKS_MS) || t < packsAt) {
293        packsAt = t
294        await loadPacks($)
295      }
296      if (mine.some(a => !isOver(a))) await reconcile($)
297      // A plain subagent asked for long ago whose spawn never answered: nothing will end it, so it is given up.
298      if (mine.some(a => a.status === 'queued' && t - a.startedAt >= QUEUE_MS)) {
299        mine = await update($, solo, list => list.map(a => (a.status === 'queued' && t - a.startedAt >= QUEUE_MS ? { ...a, status: 'failed' as const, endedAt: t } : a)))
300      }
301    }
302    // A finished plain subagent leaves a while after its end.
303    if (sweep(mine, t) !== mine) mine = await update($, solo, list => sweep(list, t))
304    // The active step's node turns at the pack's rate, while the model works on it.
305    const stepMs = isWorking && ((await read($, plan))?.steps.some(x => x.status === 'active') === true || (await read($, wayMap))?.tickets.some(x => x.state === 'claimed') === true)
306      ? lookOf(await read($, theme), (await read($, packs)).list).S.cycle?.ms ?? 0 : 0
307    const next = frameOf({ isLive, solo: mine, stepMs, t, isKept: lingering(all, t, begunAt) !== undefined })
308    // Drawn again when the frame changed, and once more when the last thing that moved stopped: what was mid-way
309    // (a finished row still bright, a border still lit) is drawn settled. Hidden, the frame stands: the first beat
310    // back on screen draws.
311    if (isShown && next !== frame) {
312      await update($, now, () => t)
313      frame = next
314    }
315    if (isWay && (t - mapAt >= MAP_MS || t < mapAt)) {
316      mapAt = t
317      await refresh($)
318    }
319    const isMoving = isLive || mine.length > 0 || stepMs > 0
320    const want = isMoving && isShown ? FAST_MS : isMoving || isWay ? SLOW_MS : IDLE_MS
321    if (want !== period) arm(want)
322  }
323  const beat = () => {
324    // One beat at a time; a beat that never settles (a call on $ left unanswered) is given up after 40 more.
325    if (isBusy && ++skipped < 40) return
326    skipped = 0
327    isBusy = true
328    void bump().catch(() => undefined).finally(() => { isBusy = false })
329  }
330  // Already beating: a slow beat is brought forward, so a run that just started is live at once.
331  if (timer === undefined) arm(FAST_MS)
332  else if (period === FAST_MS) return
333  beat()
334}
335
336/**
337 * A path under .scratch/<effort>/ says which effort the session is on, when that directory holds a map.md:
338 * a loose file in .scratch/ or a directory without a map is no effort and leaves the remembered one alone.
339 * `isPick` is the person naming it (a /wayfinder argument): it also drops the pane's earlier choice.
340 */
341async function touch($: EngineInterface, hit: { root: string; effort: string } | null, isPick = false) {
342  if (hit === null) return
343  const w = await read($, way)
344  if (w.root === hit.root && w.touched === hit.effort && !(isPick && w.chosen !== '')) return
345  const cwd = (await $.session.cwd().catch(() => '')).replace(/[\\/]+$/, '')
346  const dir = isAbsolute(hit.root) ? hit.root : `${cwd}/${hit.root}`
347  if (!(await $.fs.exists(`${dir}/${hit.effort}/map.md`).catch(() => false))) return
348  await update($, way, v => ({ ...v, root: hit.root, touched: hit.effort, ...(isPick ? { chosen: '' } : {}) }))
349}
350
351/** Wayfinder was used: the map shows from now on. Its argument may name a map path or an effort. */
352async function wake($: EngineInterface, args: string) {
353  await update($, way, v => ({ ...v, active: true, hint: args.slice(0, 300) || v.hint }))
354  await touch($, args.split(/\s+/).map(scratchOf).find(h => h !== null) ?? null, true)
355  tick($)
356  await refresh($)
357}
358
359
360/** What the drawing knew when a Button was drawn: the agents in order, the selected one, whom a steer and a stop would reach. */
361type Ctx = { order: string[]; target: string; stop: string; cols: number }
362
363/** A press, run: every write goes through the state the press finds, never the one the drawing saw. */
364async function act($: EngineInterface, a: Act, c: Ctx) {
365  const t = await $.clock.now()
366  const set = (change: (v: View) => View) => update($, view, v => change(healView(v)))
367  const focus = (key: string) => quiet($.ui.focus({ requestId: PANE, key }))
368  if (a.a === 'agent') {
369    await set(v => ({ ...v, sel: a.key, mode: '', open: v.open.includes(a.key) ? v.open.filter(k => k !== a.key) : [...v.open, a.key].slice(-40) }))
370  } else if (a.a === 'move') {
371    const from = healView(await read($, view)).sel
372    const at = c.order.indexOf(c.order.includes(from) ? from : c.target)
373    const next = c.order[Math.max(0, Math.min(c.order.length - 1, at + a.d))]
374    if (next === undefined) return
375    await set(v => ({ ...v, sel: next, mode: '' }))
376    await focus(next)
377  } else if (a.a === 'steer') {
378    if (c.target === '') return
379    await set(v => ({ ...v, mode: 'steer', target: c.target, at: t }))
380    await focus('steer')
381  } else if (a.a === 'stop') {
382    if (c.stop === '') return
383    await set(v => ({ ...v, mode: 'confirm', target: c.stop, at: t }))
384    await focus('stop:no')
385  } else if (a.a === 'no') {
386    await set(v => ({ ...v, mode: '' }))
387  } else if (a.a === 'yes') {
388    const asked = healView(await read($, view))
389    // Only the answer to a question that is showing, and has been for a moment, stops anything.
390    if (asked.mode !== 'confirm' || t - asked.at < ARM_MS) return
391    await set(v => ({ ...v, mode: '' }))
392    if (t - asked.at > CONFIRM_MS) return
393    const run = (await read($, runs)).find(r => runKey(r) === asked.target && r.endedAt === null && r.taskId !== '')
394    if (run === undefined) return
395    // The engine's own way to stop a background task: the TaskStop tool, on the run's task. It stops the whole run.
396    const got = await $.tool.call({ tool: 'TaskStop', task_id: run.taskId, consent: `The user pressed "y: yes" under "stop ${run.name}?" in the flow map pane to stop the workflow "${run.name}".` })
397      .catch((err: unknown) => ({ deny: String(err), isError: undefined }))
398    if (got.deny !== undefined || got.isError === true) return $.ui.toast(`flow map: the run was not stopped${got.deny === undefined ? '' : `: ${got.deny.slice(0, 120)}`}`)
399    // TaskStop answered that the task is stopped: the run is over now, whether or not a notification follows.
400    // It stays on screen a while, as any finished run, with its phases stopped and on hold.
401    await update($, runs, list => list.map(r => (runKey(r) === asked.target && r.endedAt === null ? { ...r, stop: t, endedAt: t, outcome: 'killed' } : r)))
402  } else if (a.a === 'hide') {
403    const next = await set(v => ({ ...v, hidden: !v.hidden, mode: '' }))
404    if (next.hidden) {
405      await quiet($.ui.close({ id: PANE }))
406      $.ui.toast('Flow map folded: the tab above the prompt, or /flow, brings it back.')
407    } else await quiet($.ui.open({ id: PANE, title: TITLE, columns: next.cols || COLUMNS, focus: true }))
408    tick($)
409  } else if (a.a === 'size') {
410    // From the width the pane has, not the one last asked for: an ask the dock did not take never adds up.
411    const want = Math.max(NARROWEST, Math.min(WIDEST, c.cols + a.d))
412    const old = healView(await read($, view))
413    // A request, not a grant: a width the person dragged or keyed the dock to wins over it, and the inline block
414    // ignores it. The pane is as wide as it was before the last ask, a while after it: that ask was not taken.
415    if (old.colsFrom === c.cols && old.cols !== c.cols && t - old.colsAt >= SLOW_MS) {
416      await set(v => ({ ...v, colsFrom: 0 }))
417      return $.ui.toast('flow map: the pane kept its width. A width you dragged or keyed the dock to wins, and so does a terminal with no room to spare: drag the pane\'s edge.')
418    }
419    if (want === c.cols) return
420    await set(v => ({ ...v, cols: want, colsFrom: c.cols, colsAt: t }))
421    await quiet($.ui.open({ id: PANE, title: TITLE, columns: want }))
422  } else if (a.a === 'fold') {
423    await set(v => ({ ...v, folded: v.folded.includes(a.run) ? v.folded.filter(x => x !== a.run) : [...v.folded, a.run].slice(-20) }))
424  } else if (a.a === 'effort') {
425    await update($, way, v => ({ ...v, chosen: a.name }))
426    await refresh($)
427  } else {
428    await update($, way, v => ({ ...v, open: v.open.includes(a.id) ? v.open.filter(x => x !== a.id) : [...v.open, a.id] }))
429  }
430}
431
432/** The steer field's Enter: the text goes to the agent as a message, the engine's SendMessage delivery. */
433async function steer($: EngineInterface, value: string) {
434  const asked = healView(await read($, view))
435  await update($, view, (v): View => ({ ...healView(v), mode: '' }))
436  const text = value.trim()
437  if (asked.mode !== 'steer' || text === '') return
438  // A run's agent or a plain subagent: both are reached by their loop's id, the engine's SendMessage delivery.
439  const hit = [...(await read($, solo)).map(a => ({ key: soloKey(a), a })), ...(await read($, runs)).flatMap(r => r.agents.map(a => ({ key: agentKey(r, a), a })))].find(x => x.key === asked.target)
440  if (hit === undefined || hit.a.id === '' || hit.a.status !== 'running') return
441  const sent = await $.session.send({ to: { agentId: hit.a.id }, text }).catch((err: unknown) => ({ isDelivered: false as const, reason: String(err) }))
442  if (!sent.isDelivered) return $.ui.toast(`flow map: not delivered to ${hit.a.label}: ${sent.reason.slice(0, 120)}`)
443  const marked = (a: Agent): Agent => (a.key === hit.a.key && a.id === hit.a.id ? { ...a, steer: text.slice(0, 200) } : a)
444  await update($, solo, list => list.map(marked))
445  await update($, runs, list => patchAgent(list, a => a.key === hit.a.key && a.id === hit.a.id, a => [marked(a)]))
446}
447
448type Els = Elements['terminal']
449/** Lines of cells as elements: each line `width` cells of the palette's background, a Text of spans around its Buttons and field. */
450function paint($: EngineInterface, els: Pick<Els, 'Box' | 'Text' | 'Button'> & { Input?: Els['Input'] }, lines: Line[], width: number, bg: string | undefined, fg: string, ctx: Ctx): RenderElement[] {
451  const { Box, Text, Button, Input } = els
452  const span = (seg: Seg) => {
453    const back = seg.bg ?? bg
454    const struck = seg.x === true ? { strikethrough: true as const } : {}
455    return back === undefined
456      ? <Text color={seg.c ?? fg} bold={seg.b === true} {...struck}>{seg.t}</Text>
457      : <Text color={seg.c ?? fg} backgroundColor={back} bold={seg.b === true} {...struck}>{seg.t}</Text>
458  }
459  const button = (b: Btn) => (
460    <Box width={widthOf(b)} flexShrink={0} {...(b.bg ?? bg) === undefined ? {} : { backgroundColor: b.bg ?? bg }}>
461      <Button key={b.key} plain {...(b.hotkey === undefined ? {} : { hotkey: b.hotkey })} {...(b.auto === true ? { autoFocus: true as const } : {})} dimColor={b.dim === true} onPress={() => void act($, b.act, ctx).catch(() => undefined)}>{b.label}</Button>
462    </Box>
463  )
464  return lines.map(line => {
465    const used = line.reduce((n, p) => n + (isSeg(p) ? p.t.length : 'btn' in p ? widthOf(p.btn) : p.inp.width), 0)
466    const whole: Line = bg === undefined ? line : [...line, { t: ' '.repeat(Math.max(0, width - used)) }]
467    if (whole.every(isSeg)) return <Text wrap="truncate-end">{whole.length === 0 ? ' ' : whole.map(span)}</Text>
468    const parts: RenderElement[] = []
469    let run: Seg[] = []
470    const flush = () => {
471      if (run.length > 0) parts.push(<Text wrap="truncate-end">{run.map(span)}</Text>)
472      run = []
473    }
474    for (const piece of whole) {
475      if (isSeg(piece)) run.push(piece)
476      else {
477        flush()
478        if ('btn' in piece) parts.push(button(piece.btn))
479        else if (Input !== undefined) {
480          parts.push(
481            <Box width={piece.inp.width} flexShrink={0} {...(bg === undefined ? {} : { backgroundColor: bg })}>
482              <Input key={piece.inp.key} submitLabel="send" onSubmit={value => void steer($, value).catch(() => undefined)} />
483            </Box>,
484          )
485        }
486      }
487    }
488    flush()
489    return <Box flexDirection="row" {...(bg === undefined ? {} : { width, backgroundColor: bg })}>{parts}</Box>
490  })
491}
492
493/** Opens the pane (unfolded), and says what became of it: `/flow`, and `/loadout flow`. */
494export async function openFlow($: EngineInterface): Promise<string> {
495  const seen = await update($, view, (v): View => ({ ...healView(v), hidden: false }))
496  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: seen.cols || COLUMNS })
497  await quiet(syncTheme($))
498
499  return opened.isPlaced ? 'Flow map opened.' : `Flow map is open but not drawn yet: ${opened.reason}`
500}
501
502// --- the events Loadout's parts share (hooks/part.ts): this part's work on each, kept from costing the others ---
503
504const started: Part<'session.start'> = async ($, e, next) => {
505  await $.command.register({ name: 'flow', description: 'Open the Flow map pane (runs, agents, steps)' })
506  await $.tool.register({
507    name: 'steps',
508    description:
509      'Shows the user the plan of a multi-step task in the Flow map pane: a title and every step with its status. Call it at the start of a task with 3+ steps and again, with the full list, whenever a step changes status.',
510    inputSchema: STEPS_SCHEMA,
511  })
512  // What earlier sessions learned about how long agents and phases take; this session's own figures win.
513  const kept = toHistory(await $.store.get('hist').catch(() => null))
514  await quiet(update($, hist, (h: History) => ({ ...kept, ...h })))
515  await quiet(syncTheme($))
516  await quiet(loadPacks($))
517  // The plain subagents already running (the mod loaded, or loaded again, after they started).
518  const rows = await $.agent.list().catch(() => [])
519  const at = await $.clock.now().catch(() => 0)
520  for (const row of rows.filter(r => r.teammateId === undefined && (r.status === 'running' || r.status === 'pending' || r.status === 'waiting'))) {
521    await quiet(update($, solo, list => (list.some(a => a.id === row.id) ? list : withSolo(list, soloAgent({ key: `a:${row.id}`, id: row.id, label: row.description || row.type, kind: row.type, startedAt: at })))))
522  }
523  const seen = healView(await read($, view))
524  if (e.isInteractive && !seen.hidden) void $.ui.open({ id: PANE, title: TITLE, columns: seen.cols || COLUMNS }).catch(() => undefined)
525  tick($)
526
527  return next(e)
528}
529
530// The main conversation's turn starts: the active step's node turns until it ends.
531const turnStarted: Part<'turn.start'> = async ($, e, next) => {
532  isWorking = true
533  tick($)
534  return next(e)
535}
536
537// An agent of a run is asked for, then started.
538const spawned: Part<'agent.spawn'> = async ($, e, next) => {
539  const wf = e.workflow
540  if (wf === undefined) {
541    // A plain subagent: the Agent tool's, a fork, a background agent. A teammate idles between turns and is not one.
542    if (e.isTeammate === true) return next(e)
543    const raised = await $.clock.now()
544    const key = `s:${raised}:${e.tool_use_id}`
545    const kind = e.fork ? 'fork' : e.subagentType
546    await quiet(update($, solo, list => withSolo(list, soloAgent({ key, label: e.description || e.name || kind, kind, status: 'queued', startedAt: raised }))))
547    tick($)
548    let got: Awaited<ReturnType<typeof next>> | undefined
549    try {
550      got = await next(e)
551      return got
552    } finally {
553      const answer = got
554      const at = await $.clock.now().catch(() => raised)
555      await quiet(update($, solo, (list): Agent[] => {
556        // The spawn threw, or was refused: it failed.
557        if (answer === undefined || answer.deny !== undefined) return list.map(a => (a.key !== key ? a : { ...a, status: 'failed', endedAt: Math.max(at, a.startedAt) }))
558        const id = answer.agentId
559        // Started with no id (a hook answered for the engine): no event will name it, so it cannot be followed and is not shown.
560        // A tool call of its that outran this answer took it in already: that row stays, this one goes.
561        if (id === undefined || list.some(a => a.id === id)) return list.filter(a => a.key !== key)
562        return list.map(a => (a.key !== key ? a : { ...a, id, model: answer.model, status: 'running', startedAt: at }))
563      }))
564      tick($)
565    }
566  }
567  const asked = await $.clock.now()
568  const key = `${wf.agentIndex}@${asked}`
569  const isRun = (r: Run) => r.id === wf.runId || (r.id === '' && r.callId !== '' && r.callId === e.tool_use_id)
570  // A run first seen by its agent, or one that had ended, starts here.
571  if ((await read($, runs)).find(isRun)?.endedAt !== null) begunAt = asked
572  const agent: Agent = {
573    key, n: wf.agentIndex, id: '', label: e.description || `agent ${wf.agentIndex}`, model: '',
574    status: 'queued', startedAt: asked, endedAt: 0, tools: 0, calls: [], phase: 0, tokIn: 0, tokOut: 0, log: [], steer: '', stepIn: 0, stepOut: 0,
575  }
576  await update($, runs, list =>
577    (list.some(isRun) ? list : keepRuns([...list, newRun({ id: wf.runId, callId: e.tool_use_id, name: 'workflow', phases: [], startedAt: asked })])).map(r =>
578      isRun(r) ? { ...r, id: wf.runId, endedAt: null, outcome: '', ...enlist(healRun(r), agent, e.prompt) } : r,
579    ),
580  )
581  tick($)
582  const got = await next(e)
583  const at = await $.clock.now()
584  await quiet(
585    update($, runs, list =>
586      patchAgent(list, a => a.key === key, (a, r) =>
587        got.deny !== undefined
588          ? [{ ...a, status: 'failed', endedAt: at }, { failed: r.failed + 1 }]
589          : [{ ...a, id: got.agentId ?? '', model: got.model, status: 'running', startedAt: at }],
590      ),
591    ),
592  )
593
594  return got
595}
596
597// Every tool call, for two things (one hook: an event takes a single hook without a matcher).
598// A run's agent calls a tool: count it, show it while it runs, and keep how it ended. An agent runs several at once.
599// A file call under .scratch/<effort>/ says which effort the session is on, and one that may have written there has the map read again.
600const called: Part<'tool.call'> = async ($, e, next) => {
601  const input: Record<string, unknown> = e
602  const tool = String(e.tool)
603  const path = [input.file_path, input.notebook_path, input.path].find(p => typeof p === 'string')
604  const hit = typeof path === 'string' ? scratchOf(path) : null
605  const isMap = hit !== null || (kindOf(tool) === 'shell' && typeof input.command === 'string' && input.command.includes('.scratch'))
606  const id = e.agentId
607  const isAgent = (a: Agent) => a.id === id
608  const isRun = id !== undefined && (await read($, runs)).some(r => r.agents.some(isAgent))
609  // Not a run's agent: a plain subagent the pane tracks, or one it meets here for the first time.
610  const isSolo = id !== undefined && !isRun && ((await read($, solo)).some(isAgent) || (await adopt($, id).catch(() => false)))
611  if (!isMap && !isRun && !isSolo) return next(e)
612  const call: Call = { id: e.tool_use_id, tool, what: brief(tool, input), at: isRun || isSolo ? await $.clock.now() : 0 }
613  const started = (a: Agent): Agent => ({ ...a, tools: a.tools + 1, calls: [...(a.calls ?? []), call].slice(-MAX_CALLS) })
614  if (isRun) {
615    await quiet(update($, runs, list => patchAgent(list, isAgent, a => [started(a)])))
616    tick($)
617  }
618  if (isSolo) {
619    // A finished agent that works again (a message woke it) runs again.
620    await quiet(update($, solo, list => list.map(a => (isAgent(a) ? { ...started(heal(a)), status: 'running' as const, endedAt: 0 } : a))))
621    tick($)
622  }
623  let failed = true
624  try {
625    const got = await next(e)
626    failed = got.deny !== undefined || got.isError === true
627    return got
628  } finally {
629    if (isRun || isSolo) {
630      const at = await $.clock.now().catch(() => call.at)
631      const done = { tool, what: call.what, ms: at - call.at, failed }
632      const ended = (a: Agent): Agent => ({ ...a, calls: (a.calls ?? []).filter(c => c.id !== call.id), log: [...(a.log ?? []), done].slice(-5) })
633      if (isRun) await quiet(update($, runs, list => patchAgent(list, isAgent, a => [ended(a)])))
634      // The card's border is lit for a moment: green, red for a call that failed.
635      else await quiet(update($, solo, list => list.map(a => (isAgent(a) ? { ...ended(a), flashAt: at, flashBad: failed } : a))))
636      tick($)
637    }
638    // After the call: a Write that creates the effort's map.md has made it one by now.
639    await quiet(touch($, hit))
640    if (isMap) await quiet(refresh($))
641  }
642}
643
644// A run's agent finished its turn.
645const turnCompleted: Part<'turn.complete'> = async ($, e, next) => {
646  const id = e.agentId
647  if (id !== undefined && (await read($, runs)).some(r => r.agents.some(a => a.id === id))) {
648    const at = await $.clock.now()
649    const u = e.usage
650    const facts: [string, number][] = []
651    await update($, runs, list =>
652      patchAgent(list, a => a.id === id, (a, r) => {
653        // The turn's usage is its whole cost: what this turn's steps already counted is not counted twice.
654        // An agent may run several turns, so only the steps since its last turn ended are taken off.
655        const tokIn = Math.max(0, (u === undefined ? 0 : u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens) - (a.stepIn ?? 0))
656        const tokOut = Math.max(0, (u?.output_tokens ?? 0) - (a.stepOut ?? 0))
657        const spent = { tokIn: r.tokIn + tokIn, tokOut: r.tokOut + tokOut }
658        const mine = { ...a, tokIn: a.tokIn + tokIn, tokOut: a.tokOut + tokOut, stepIn: 0, stepOut: 0 }
659        if (a.status !== 'running') return [mine, spent]
660        const isDone = e.reason === 'answer'
661        if (isDone) facts.push([`a:${histKey(a.label)}`, at - a.startedAt])
662        return [
663          { ...mine, status: isDone ? 'done' : 'failed', endedAt: at, calls: [] },
664          isDone ? { ...spent, done: r.done + 1, doneMs: r.doneMs + (at - a.startedAt) } : { ...spent, failed: r.failed + 1 },
665        ]
666      }),
667    )
668    // The last write's facts: an update that ran twice on a miss gathered them twice.
669    await quiet(learn($, facts.slice(-1)))
670  }
671  if (id === undefined) isWorking = false
672  // A plain subagent's turn ended: answered (done), interrupted (stopped), or dead on an error or a refusal (failed).
673  else if ((await read($, solo)).some(a => a.id === id)) {
674    const at = await $.clock.now()
675    const u = e.usage
676    await quiet(update($, solo, list => list.map(a => {
677      if (a.id !== id) return a
678      const tokIn = Math.max(0, (u === undefined ? 0 : u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens) - a.stepIn)
679      const mine = { ...a, tokIn: a.tokIn + tokIn, tokOut: a.tokOut + Math.max(0, (u?.output_tokens ?? 0) - a.stepOut), stepIn: 0, stepOut: 0 }
680      return a.status !== 'running' ? mine : { ...mine, status: e.reason === 'answer' ? 'done' as const : 'failed' as const, stopped: e.reason === 'aborted', endedAt: Math.max(at, a.startedAt), calls: [] }
681    })))
682    tick($)
683  }
684
685  return next(e)
686}
687
688export const register: Register = on => {
689  // Each under `{}`, any input: an event takes a single hook without a matcher in a module, and the parts share these.
690  on('session.start', {}, ($, e, next) => alone(e, under => next(under), under => started($, e, under)))
691
692  on('command.run', { command: 'flow' }, async $ => ({ text: `${await openFlow($)}\nAlso: /loadout flow.` }))
693
694  // The theme changed in the config menu: the palette follows at once.
695  on('config.set', async ($, e, next) => {
696    const got = await next(e)
697    if (e.key === 'theme') {
698      await quiet(syncTheme($))
699      await quiet(loadPacks($))
700    }
701    return got
702  })
703
704  on('turn.start', {}, ($, e, next) => alone(e, under => next(under), under => turnStarted($, e, under)))
705
706  // The ring moved onto an agent's row (Tab, a click, or the pane's own j and k): that agent is the selection.
707  on('ui.focus', async ($, e, next) => {
708    const got = await next(e)
709    const key = e.element
710    if (e.requestId !== PANE || key === undefined || got.deny !== undefined) return got
711    await quiet(update($, view, v => {
712      const old = healView(v)
713      const sel = key.startsWith('ag:') ? key : old.sel
714      // Leaving the steer field or the stop question for another element drops it.
715      const isKept = old.mode === '' || key.startsWith(old.mode === 'steer' ? 'steer' : 'stop:')
716      return sel === old.sel && isKept ? v : { ...old, sel, mode: isKept ? old.mode : '' }
717    }))
718    return got
719  })
720
721  on('prompt.compose', async ($, e, next) => {
722    const got = await next(e)
723
724    return e.tools.includes(TOOL) ? { sections: [...got.sections, SECTION] } : got
725  })
726
727  // B. STEPS: the model posts its plan.
728  on('tool.call', { tool: TOOL }, async ($, e) => {
729    if (e.agentId !== undefined) return { result: 'Not shown: only the main conversation posts the plan.' }
730    const next = toPlan(e)
731    if (typeof next === 'string') return { deny: next }
732    await update($, plan, () => next)
733    const at = next.steps.findIndex(s => s.status === 'active')
734
735    return { result: at < 0 ? `Plan shown: ${next.steps.length} steps.` : `Plan shown: step ${at + 1} of ${next.steps.length}.` }
736  })
737
738  // C. WAYFINDER: the skill is used, by the model's Skill call, its slash command, or its prompt being expanded.
739  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
740    if (isWayfinder(e.skill)) await quiet(wake($, e.args ?? ''))
741    return next(e)
742  })
743  on('command.run', async ($, e, next) => {
744    if (isWayfinder(e.command)) await quiet(wake($, e.args))
745    return next(e)
746  })
747  on('skill.prompt', async ($, e, next) => {
748    if (isWayfinder(e.skill)) await quiet(wake($, ''))
749    return next(e)
750  })
751
752  // A. WORKFLOWS: a run starts.
753  on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
754    const callId = e.tool_use_id
755    const startedAt = await $.clock.now()
756    // The script as the call carries it, or the file it names: its meta gives the phases in order, its agent() calls say which phase each agent is in.
757    // A scriptPath wins over a script, as the tool itself reads them.
758    const filed = e.scriptPath === undefined ? '' : await $.fs.read(e.scriptPath).catch(() => '')
759    const source = filed !== '' ? filed : e.script ?? ''
760    const meta = parseMeta(source)
761    const sites = parseSites(source)
762    const resumed = e.resumeFromRunId
763    const begunBefore = begunAt
764    begunAt = startedAt
765    // What the resumed run was, to put back if the resume does not start.
766    let before = undefined as Pick<Run, 'callId' | 'startedAt' | 'endedAt' | 'outcome' | 'agents' | 'done' | 'failed' | 'stop' | 'sites'> | undefined
767    await update($, runs, list => {
768      const old = resumed === undefined ? undefined : list.map(healRun).find(r => r.id === resumed)
769      before = old && { callId: old.callId, startedAt: old.startedAt, endedAt: old.endedAt, outcome: old.outcome, agents: old.agents, done: old.done, failed: old.failed, stop: old.stop, sites: old.sites }
770      return old !== undefined
771        // The start moves forward by the time the run was not running, so ELAPSED counts the work alone.
772        ? list.map(r => (r.id === old.id ? { ...settled(old), callId, startedAt: old.startedAt + Math.max(0, startedAt - (old.endedAt ?? startedAt)), endedAt: null, outcome: '', stop: 0, phases: declared(meta.phases, old.phases), sites: sites.length > 0 ? sites : old.sites } : r))
773        : keepRuns([...list, newRun({ callId, name: meta.name ?? e.name ?? 'workflow', phases: meta.phases, sites, startedAt })])
774    })
775    tick($)
776    const ran = await next(e)
777    const endedAt = await $.clock.now()
778    const out = ran.deny === undefined && ran.isError !== true ? ran.result : undefined
779    if (out === undefined || out.error !== undefined) {
780      const back = before ?? { endedAt, outcome: 'not started' }
781      // It did not start: the finished run it sent away is back.
782      if (begunAt === startedAt) begunAt = begunBefore
783      await quiet(update($, runs, list => list.map(r => (r.callId === callId ? { ...r, ...back } : r))))
784      return ran
785    }
786    // The engine saves every script and names the file: where a named run's phases and call sites are read.
787    // ponytail: an agent such a run spawns before this read lands is seated by the waves, not by the script.
788    const path = out.scriptPath ?? e.scriptPath
789    const text = source === '' && path !== undefined ? await $.fs.read(path).catch(() => '') : ''
790    const saved = text === '' ? { meta, sites } : { meta: parseMeta(text), sites: parseSites(text) }
791    const isRemote = out.status === 'remote_launched'
792    await quiet(
793      update($, runs, list => {
794        const mine = list.find(r => r.callId === callId)
795        if (mine === undefined) return list
796        // An agent.spawn that outran this answer and carried no call id made the run already: it keeps its agents and takes what this call knows.
797        const early = list.find(r => r.id === out.runId && r.callId !== callId)
798        const kept = early ?? mine
799        const known = {
800          id: out.runId ?? mine.id,
801          callId,
802          taskId: out.taskId,
803          name: out.workflowName ?? saved.meta.name ?? mine.name,
804          // The declared phases, and what the run holds past them: it may have added one a phase() call named.
805          phases: declared(saved.meta.phases, kept.phases),
806          sites: saved.sites.length > 0 ? saved.sites : (kept.sites ?? []),
807          startedAt: mine.startedAt,
808          // A cloud run has no local loop: nothing of it can be watched from here.
809          ...(isRemote ? { endedAt, outcome: 'remote' } : {}),
810        }
811        return list.filter(r => early === undefined || r !== mine).map(r => (r === kept ? { ...r, ...known } : r))
812      }),
813    )
814
815    return ran
816  })
817
818  on('agent.spawn', {}, ($, e, next) => alone(e, under => next(under), under => spawned($, e, under)))
819
820  on('tool.call', {}, ($, e, next) => alone(e, under => next(under), under => called($, e, under)))
821
822  // A model request of a run's agent answered: its tokens count at once, so the card's figure moves while the agent runs.
823  on('turn.step', async function* ($, e, next) {
824    const got = yield* next(e)
825    const id = e.agentId
826    const u = got.usage
827    if (id !== undefined && u !== null && (await read($, runs).catch(() => [])).some(r => r.agents.some(a => a.id === id && a.status === 'running'))) {
828      const tokIn = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
829      await quiet(update($, runs, list =>
830        patchAgent(list, a => a.id === id, (a, r) => [{ ...a, tokIn: a.tokIn + tokIn, tokOut: a.tokOut + u.output_tokens, stepIn: (a.stepIn ?? 0) + tokIn, stepOut: (a.stepOut ?? 0) + u.output_tokens }, { tokIn: r.tokIn + tokIn, tokOut: r.tokOut + u.output_tokens }]),
831      ))
832    }
833    if (id !== undefined && u !== null && (await read($, solo).catch(() => [])).some(a => a.id === id && a.status === 'running')) {
834      const tokIn = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
835      await quiet(update($, solo, list => list.map(a => (a.id === id ? { ...a, tokIn: a.tokIn + tokIn, tokOut: a.tokOut + u.output_tokens, stepIn: a.stepIn + tokIn, stepOut: a.stepOut + u.output_tokens } : a))))
836    }
837    return got
838  })
839
840  on('turn.complete', {}, ($, e, next) => alone(e, under => next(under), under => turnCompleted($, e, under)))
841
842  // A run ended: its background task's notification names the task id.
843  // ponytail: the id and status are read out of the notification's text, no
844  // typed field carries them here; classic.Stop below is the typed backstop.
845  on('prompt.submit', async ($, e, next) => {
846    // ponytail: a typed /wayfinder is caught by command.run or skill.prompt where the engine raises them; this is the text's own word for it.
847    const typed = e.origin.kind === 'composer' ? e.text.match(/^\/(\S+)[ \t]*(.*)/) : null
848    if (typed !== null && isWayfinder(typed[1])) await quiet(wake($, typed[2] ?? ''))
849    if (e.origin.kind === 'task-notification') {
850      const at = await $.clock.now()
851      const outcome = e.text.match(/<status>\s*([a-z_]+)\s*<\/status>/i)?.[1] ?? 'ended'
852      // 'ended' is the Stop backstop's guess: the notification that names the real status replaces it.
853      const isMine = (r: Run) => (r.endedAt === null || r.outcome === 'ended') && r.taskId !== '' && e.text.includes(r.taskId)
854      const closed = (await read($, runs)).filter(isMine).map(r => ({ ...r, endedAt: r.endedAt ?? at, outcome }))
855      await update($, runs, list => list.map(r => (isMine(r) ? { ...r, endedAt: r.endedAt ?? at, outcome } : r)))
856      const known = await read($, hist)
857      await quiet(learn($, closed.filter(r => r.outcome === 'completed').flatMap(r => phaseFacts(r, known))))
858    }
859
860    return next(e)
861  })
862
863  // The main turn stopped: a run whose task is no longer in flight has ended.
864  on('classic.Stop', async ($, e, next) => {
865    const flying = e.background_tasks
866    if (flying !== undefined) {
867      const at = await $.clock.now()
868      await update($, runs, list =>
869        list.map(r => (r.endedAt === null && r.taskId !== '' && !flying.some(t => t.id === r.taskId) ? { ...r, endedAt: at, outcome: 'ended' } : r)),
870      )
871    }
872
873    return next(e)
874  })
875
876  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
877    const els = $.ui.resolve(e)
878    const { Box } = els
879    const { P, S } = lookOf(await read($, theme), (await read($, packs)).list)
880    const w = Math.max(24, e.props.bodyColumns)
881    const rows = e.props.scroll.bodyRows
882    const clock = await read($, now)
883    const known = await read($, hist)
884    const seen = healView(await read($, view))
885    const list = (await read($, runs)).map(r => settled(healRun(r)))
886    const shown = (await onScreen($, list, clock)).map(r => runView(r, clock, known))
887    // The stop question stands only while the pane holds the keyboard and for a few seconds: after that the hints are back.
888    const isAsking = seen.mode !== 'confirm' || (e.props.isFocused && clock - seen.at <= CONFIRM_MS)
889    // Docked, the pane is the dock's height and paints all of it; inline above the prompt the frame fits the tree.
890    const isInline = e.props.placement === 'inline'
891    const drawn = compose({
892      w, rows, inline: isInline, P, now: clock, runs: shown, view: isAsking ? seen : { ...seen, mode: '' },
893      plan: await read($, plan), way: await read($, way), map: await read($, wayMap), S, solo: (await read($, solo)).map(heal),
894    })
895    const ctx: Ctx = { order: drawn.order, target: drawn.steer, stop: drawn.stoppable === undefined ? '' : runKey(drawn.stoppable), cols: w }
896
897    return (
898      <Box flexDirection="column" width={w} {...(isInline ? {} : { minHeight: rows })} backgroundColor={P.bg}>
899        {paint($, els, drawn.lines, w, P.bg, P.fg, ctx)}
900      </Box>
901    )
902  })
903
904  // Folded with z: the pane is closed and one tab stands above the prompt, over whatever else draws there.
905  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
906    const seen = healView(await read($, view))
907    if (!seen.hidden || e.props.hasSurvey) return next(e)
908    tick($)
909    const els = $.ui.resolve(e)
910    const { Box } = els
911    const { P } = lookOf(await read($, theme), (await read($, packs)).list)
912    const clock = await read($, now)
913    const known = await read($, hist)
914    const list = (await read($, runs)).map(r => settled(healRun(r)))
915    const [run] = await onScreen($, list, clock)
916    const lines = tab(P, run === undefined ? undefined : runView(run, clock, known), clock, (await read($, solo)).map(heal))
917    const below = await next(e).catch(() => null)
918
919    return (
920      <Box flexDirection="column">
921        {paint($, els, lines, e.props.bodyColumns, undefined, P.fg, { order: [], target: '', stop: '', cols: seen.cols || COLUMNS })}
922        {below}
923      </Box>
924    )
925  })
926}
927
frames/hooks/register.tsx 273 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3import { alone } from '../../hooks/part'
4import type { Part } from '../../hooks/part'
5
6import { packOf } from '../../flow/hooks/palette'
7import type { ColorRowsKind } from '../../types'
8
9const isOn = atom({ plugin: 'loadout', key: 'isOn' } as const, true)
10// The working line's icon turns on this count, and only that line reads it: a beat draws nothing else again.
11const spin = atom({ plugin: 'loadout', key: 'spin' } as const, 0)
12// Read from the part that writes them: the theme and the packs are the Flow map's (flow/hooks/register.tsx).
13const theme = atom({ plugin: 'loadout', key: 'flowTheme' } as const, '')
14const packs = atom({ plugin: 'loadout', key: 'packs' } as const, { sig: '', list: [] })
15
16/** What a ribbon shows: a glyph (or '' for a bare bar) on its hue. */
17type Mark = readonly [glyph: string, hue: string]
18
19// The one palette: a kind of action, its glyph, its hue and what it covers. Every hue is a
20// theme key, so the frames take the colours of whichever theme is active.
21const KIND: Readonly<Record<ColorRowsKind, readonly [glyph: string, hue: string, covers: string]>> = {
22  shell: ['$', 'bashBorder', 'Bash, Monitor'],
23  read: ['◇', 'suggestion', 'Read, Grep, Glob, ToolSearch'],
24  edit: ['±', 'autoAccept', 'Edit, Write, NotebookEdit'],
25  web: ['@', 'planMode', 'WebFetch, WebSearch, browser tools'],
26  agent: ['◈', 'permission', 'Agent, Workflow, SendMessage, Task*; a message from another agent'],
27  skill: ['✦', 'remember', 'Skill'],
28  ask: ['?', 'merged', 'AskUserQuestion'],
29  mcp: ['◆', 'ide', 'any other MCP tool'],
30  other: ['•', 'inactive', 'anything else'],
31}
32// Who is speaking, and how a call or a task ended. The three status hues are
33// the theme's own, and are used for nothing else.
34const YOU: Mark = ['❯', 'text']
35const CLAUDE: Mark = ['', 'claude']
36const DONE: Mark = ['✔', 'success']
37const ERRORED: Mark = ['✖', 'error']
38const STOPPED: Mark = ['■', 'warning']
39
40const TOOLS: Readonly<Record<string, ColorRowsKind>> = {
41  Bash: 'shell', PowerShell: 'shell', Monitor: 'shell', BashOutput: 'shell', KillShell: 'shell',
42  Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', ToolSearch: 'read',
43  ListMcpResourcesTool: 'read', ReadMcpResourceTool: 'read', ReadMcpResourceDirTool: 'read',
44  Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit',
45  WebFetch: 'web', WebSearch: 'web',
46  Agent: 'agent', Workflow: 'agent', SendMessage: 'agent', ListAgents: 'agent',
47  Skill: 'skill',
48  AskUserQuestion: 'ask',
49}
50const SPINNER: Readonly<Record<string, Mark>> = {
51  thinking: ['∴', CLAUDE[1]],
52  responding: ['»', CLAUDE[1]],
53  requesting: ['↑', KIND.other[1]],
54  'tool-input': ['▸', KIND.other[1]],
55  'tool-use': ['▸', KIND.other[1]],
56}
57
58function kindOf(tool: string): ColorRowsKind {
59  if (tool.startsWith('mcp__')) return /chrome|browser|playwright/i.test(tool.split('__')[1] ?? '') ? 'web' : 'mcp'
60  if (tool.startsWith('Task')) return 'agent'
61
62  // Own keys only: a tool named `constructor` is 'other', not Object's.
63  return (Object.hasOwn(TOOLS, tool) ? TOOLS[tool] : undefined) ?? 'other'
64}
65
66const markOf = (kind: ColorRowsKind): Mark => [KIND[kind][0], KIND[kind][1]]
67
68type Call = { tool: unknown; isErrored: unknown; isInterrupted: unknown }
69
70/** A call's mark: red when it errored, whatever its kind; then interrupted; then its kind. */
71const callMark = (call: Call): Mark =>
72  call.isErrored === true ? ERRORED : call.isInterrupted === true ? STOPPED : markOf(kindOf(String(call.tool)))
73
74/** The engine's own drawing with a ribbon at its left: nothing of the row is redrawn. */
75function ribbon({ Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>, marks: readonly Mark[], inner: RenderElement) {
76  return (
77    <Box flexDirection="row">
78      {marks.map(([glyph, hue]) => (
79        // The Box stretches to the row's height; its Text paints the first line whatever the Box fills.
80        <Box flexShrink={0} backgroundColor={hue}>
81          <Text color="inverseText" backgroundColor={hue} bold>{glyph === '' ? ' ' : glyph}</Text>
82        </Box>
83      ))}
84      <Box flexDirection="column" flexGrow={1} flexShrink={1} marginLeft={1}>{inner}</Box>
85    </Box>
86  )
87}
88
89/**
90 * The engine's own drawing inside a rounded frame in the hue of its first
91 * mark, the marks and a label sitting on the frame's top edge. Nothing of the
92 * row is redrawn; the frame costs two rows and four columns.
93 */
94function frame({ Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>, marks: readonly Mark[], label: string, inner: RenderElement) {
95  const hue = marks[0]?.[1] ?? KIND.other[1]
96
97  return (
98    <Box flexDirection="column" borderStyle="round" borderColor={hue} paddingX={1}>
99      <Box position="absolute" top={-1} left={1}>
100        {marks.map(([glyph, markHue]) => (
101          <Text color="inverseText" backgroundColor={markHue} bold>{glyph === '' ? ' ' : glyph}</Text>
102        ))}
103        <Text color={hue} bold>{` ${label} `}</Text>
104      </Box>
105      {inner}
106    </Box>
107  )
108}
109
110/** A call's label: its kind, and how it ended when that was not well. */
111const callLabel = (call: Call): string =>
112  `${kindOf(String(call.tool)).toUpperCase()}${call.isErrored === true ? ' FAILED' : call.isInterrupted === true ? ' STOPPED' : ''}`
113
114const LEGEND = `Colours on. ${Object.entries(KIND).map(([kind, [glyph]]) => `${glyph} ${kind}`).join('  ')}  ${YOU[0]} you  ${DONE[0]} done  ${ERRORED[0]} failed  ${STOPPED[0]} stopped`
115const OFF = 'Colours off: every row is drawn as Claude Code draws it. /colors turns them back on.'
116const HINT = 'Also: /loadout frames on|off.'
117
118/** Turns the frames on or off (no word: the other way) and keeps the choice; undefined for any other word, nothing changed. */
119async function setFrames($: EngineInterface, word: string): Promise<boolean | undefined> {
120  if (word !== '' && word !== 'on' && word !== 'off') return undefined
121  const now = await update($, isOn, was => (word === '' ? !was : word === 'on'))
122  await $.store.set('isOn', now)
123
124  return now
125}
126
127// --- the events Loadout's parts share (hooks/part.ts): this part's work on each, kept from costing the others ---
128
129// The icon's beats run only while the working line is drawn: each draw asks for the next beat, and
130// a beat that finds the line was not drawn since (for MISSES beats: a slow redraw is not the line gone) is the last.
131const MISSES = 8
132let beatMs = 0 // 0: no beat is due
133let undrawn = 0
134
135/** The working line was drawn with an icon of `ms` a frame: the beats start, or go on. */
136function drew($: EngineInterface, ms: number) {
137  undrawn = 0
138  if (beatMs !== 0) return void (beatMs = ms)
139  beatMs = ms
140  try {
141    $.clock.after(ms, () => void beat($).catch(() => void (beatMs = 0)))
142  } catch {
143    beatMs = 0
144  }
145}
146
147async function beat($: EngineInterface) {
148  if (++undrawn > MISSES) return void (beatMs = 0)
149  await update($, spin, n => (n + 1) % 1_000_000)
150  $.clock.after(beatMs, () => void beat($).catch(() => void (beatMs = 0)))
151}
152
153/** Which of a theme's words stands for the engine's: the same one as long as the engine keeps its own, so a turn says one thing. */
154export const wordOf = (verbs: readonly string[], word: string): string =>
155  verbs[[...word].reduce((sum, letter) => (sum * 31 + letter.charCodeAt(0)) >>> 0, 7) % verbs.length] ?? word
156
157const started: Part<'session.start'> = async ($, e, next) => {
158  await $.command.register({
159    name: 'colors',
160    description: 'Turn the coloured row frames on or off',
161    argumentHint: '[on|off]',
162    immediate: true,
163  })
164  const kept = await $.store.get('isOn')
165  if (typeof kept === 'boolean') await update($, isOn, () => kept)
166
167  return next(e)
168}
169
170export const register: Register = on => {
171  // Each under `{}`, any input: an event takes a single hook without a matcher in a module, and the parts share these.
172  on('session.start', {}, ($, e, next) => alone(e, under => next(under), under => started($, e, under)))
173
174  on('command.run', { command: 'colors' }, async ($, e) => {
175    const now = await setFrames($, e.args.trim().toLowerCase())
176    if (now === undefined) return { text: `Usage: /colors [on|off]. ${HINT}` }
177
178    return { text: `${now ? LEGEND : OFF}\n${HINT}` }
179  })
180
181  // The legend in its own hues, in place of the plain line above.
182  on('ui.render', { component: 'CommandOutput', props: { command: 'colors' } }, ($, e, next) => {
183    if (e.surface !== 'terminal' || e.props.isErrored || !e.props.text.startsWith(LEGEND)) return next(e)
184    const { Box, Text } = $.ui.resolve(e)
185    const row = ([glyph, hue]: Mark, name: string, covers: string) => (
186      <Text wrap="truncate-end">
187        <Text color="inverseText" backgroundColor={hue} bold>{glyph === '' ? ' ' : glyph}</Text>
188        <Text color={hue} bold>{` ${name.padEnd(8)}`}</Text>
189        <Text dimColor>{covers}</Text>
190      </Text>
191    )
192
193    return (
194      <Box flexDirection="column">
195        <Text bold>Colours on. The frame around a row says what it is:</Text>
196        {row(YOU, 'you', 'your own prompt')}
197        {row(CLAUDE, 'claude', 'Claude speaking')}
198        {Object.entries(KIND).map(([kind, [glyph, hue, covers]]) => row([glyph, hue], kind, covers))}
199        {row(DONE, 'done', 'a background task completed')}
200        {row(ERRORED, 'failed', 'a call or a task that errored: red wins over the kind')}
201        {row(STOPPED, 'stopped', 'interrupted or killed')}
202        <Text dimColor>/colors off turns the frames off. {HINT}</Text>
203      </Box>
204    )
205  })
206
207  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
208    if (e.surface !== 'terminal' || typeof e.props.tool !== 'string' || !(await read($, isOn))) return next(e)
209
210    return frame($.ui.resolve(e), [callMark(e.props)], callLabel(e.props), await next(e))
211  })
212
213  // The folded count line: one mark per kind it folds, a red one first when a call errored.
214  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
215    // Unfolded, each call is a ToolUse row with a ribbon of its own.
216    if (e.surface !== 'terminal' || e.props.isExpanded || !Array.isArray(e.props.calls) || !(await read($, isOn))) return next(e)
217    if (e.props.calls.some(call => typeof call !== 'object' || call === null)) return next(e)
218    const byHue = new Map(e.props.calls.map(call => [callMark(call)[1], callMark(call)] as const))
219    const marks = [...byHue.values()].sort((a, b) => Number(b === ERRORED) - Number(a === ERRORED))
220    if (marks.length === 0) return next(e)
221
222    return frame($.ui.resolve(e), marks, `${e.props.calls.length} CALLS`, await next(e))
223  })
224
225  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
226    // ctrl+o shows the row in full, as the engine draws it.
227    if (e.surface !== 'terminal' || e.props.isExpanded !== false || !(await read($, isOn))) return next(e)
228    const kind = e.props.origin?.kind
229    const status = e.props.task?.status
230    const mark =
231      kind === 'task-notification'
232        ? status === 'completed' ? DONE : status === 'failed' ? ERRORED : status === 'killed' ? STOPPED : markOf('other')
233        : e.props.from !== undefined || kind === 'peer' || kind === 'peer-send-message' ? markOf('agent')
234        : kind === 'composer' || kind === 'bridge' ? YOU
235        : undefined
236    if (mark === undefined) return next(e)
237    const label = mark === YOU ? 'YOU' : kind === 'task-notification' ? `TASK ${(status ?? 'update').toUpperCase()}` : (e.props.from?.name ?? 'AGENT').toUpperCase()
238
239    return frame($.ui.resolve(e), [mark], label, await next(e))
240  })
241
242  // Claude speaking: a bare bar, the text beside it the engine's own markdown.
243  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
244    if (e.surface !== 'terminal' || !(await read($, isOn))) return next(e)
245
246    return ribbon($.ui.resolve(e), [CLAUDE], await next(e))
247  })
248
249  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
250    if (e.surface !== 'terminal') return next(e)
251    const framed = await read($, isOn)
252    const themed = packOf(await read($, theme), (await read($, packs)).list)?.spinner ?? null
253    if (themed === null) {
254      const mark = Object.hasOwn(SPINNER, e.props.mode) ? SPINNER[e.props.mode] : undefined
255
256      return mark === undefined || !framed ? next(e) : ribbon($.ui.resolve(e), [mark], await next(e))
257    }
258    // The theme's own line: its word for the engine's, and its icon turning before it (on the ribbon with the frames on, bare with them off).
259    const icon = themed.frames[(await read($, spin)) % themed.frames.length] ?? ''
260    drew($, themed.ms)
261    const inner = await next({ ...e, props: { ...e.props, word: wordOf(themed.verbs, e.props.word) } })
262    const { Box, Text } = $.ui.resolve(e)
263    if (framed) return ribbon({ Box, Text }, [[icon, CLAUDE[1]]], inner)
264
265    return (
266      <Box flexDirection="row">
267        <Box flexShrink={0} marginRight={1}><Text color={CLAUDE[1]} bold>{icon}</Text></Box>
268        <Box flexDirection="column" flexGrow={1} flexShrink={1}>{inner}</Box>
269      </Box>
270    )
271  })
272}
273
avatar/hooks/canvas.ts 126 lines
1// A picture in true pixels: what the stage and the mascot are composed on where the terminal
2// draws images. Pure: no engine import, so previews and tests compose what the pane shows.
3import type { Pixels } from './look'
4
5/** `width * height` pixels, row-major: 0xRRGGBB, or CLEAR where nothing is drawn (alpha 0). */
6export type Canvas = { width: number; height: number; data: Int32Array }
7export const CLEAR = -1
8
9/** A sprite and its anchor: the column of its body's middle and the row under its feet. */
10export type Sprite = { px: Pixels; ax: number; ay: number }
11
12/** [from, to) in pixels, on either axis. */
13export type Span = readonly [from: number, to: number]
14
15export const blank = (width: number, height: number): Canvas => ({ width, height, data: new Int32Array(width * height).fill(CLEAR) })
16
17export const sprite = (px: Pixels, ax = px[0]!.length >> 1, ay = px.length): Sprite => ({ px, ax, ay })
18
19/** `px` with its top-left at (x, y), each pixel `scale` wide and tall, mirrored when `flip`; nothing lands outside `columns`, `rows` or the canvas. */
20export function paint(canvas: Canvas, px: Pixels, x: number, y: number, { flip = false, scale = 1, columns = [0, canvas.width] as Span, rows = [0, canvas.height] as Span, tint = CLEAR } = {}) {
21  const [left, right] = [Math.max(0, columns[0]), Math.min(canvas.width, columns[1])]
22  const [top, bottom] = [Math.max(0, rows[0]), Math.min(canvas.height, rows[1])]
23  px.forEach((row, j) => row.forEach((color, i) => {
24    if (color === null) return
25    const [x0, y0] = [x + (flip ? row.length - 1 - i : i) * scale, y + j * scale]
26    for (let dy = 0; dy < scale; dy++) {
27      for (let dx = 0; dx < scale; dx++) {
28        if (x0 + dx >= left && x0 + dx < right && y0 + dy >= top && y0 + dy < bottom) canvas.data[(y0 + dy) * canvas.width + x0 + dx] = tint === CLEAR ? color : tint
29      }
30    }
31  }))
32}
33
34type Clip = { flip?: boolean; columns?: Span; rows?: Span; tint?: number }
35
36/** A sprite with its anchor at (x, y): its feet on row y - 1, its middle on column x. */
37export const stand = (canvas: Canvas, { px, ax, ay }: Sprite, x: number, y: number, clip: Clip = {}) =>
38  paint(canvas, px, Math.round(x) - (clip.flip === true ? px[0]!.length - ax : ax), Math.round(y) - ay, clip)
39
40/** The canvas as rows of pixels, null where it is clear. */
41export const pixelsOf = ({ width, height, data }: Canvas): Pixels =>
42  Array.from({ length: height }, (_row, y) => Array.from({ length: width }, (_cell, x) => (data[y * width + x] === CLEAR ? null : data[y * width + x]!)))
43
44/** `px` with each pixel `n` wide and tall. */
45export const scaled = (px: Pixels, n: number): Pixels => px.flatMap(row => Array.from({ length: n }, () => row.flatMap(color => Array<number | null>(n).fill(color))))
46
47export const mirrored = (px: Pixels): Pixels => px.map(row => [...row].reverse())
48
49/** `base` with `add` drawn over it, its top-left `dx`, `dy` from the anchor: the anchor stays where the body's was. */
50export function joined(base: Sprite, add: Pixels, dx: number, dy: number): Sprite {
51  const [left, top] = [Math.min(0, base.ax + dx), Math.min(0, base.ay + dy)]
52  const canvas = blank(Math.max(base.px[0]!.length, base.ax + dx + add[0]!.length) - left, Math.max(base.px.length, base.ay + dy + add.length) - top)
53  paint(canvas, base.px, -left, -top)
54  paint(canvas, add, base.ax + dx - left, base.ay + dy - top)
55
56  return { px: pixelsOf(canvas), ax: base.ax - left, ay: base.ay - top }
57}
58
59/** The colours a canvas paints. */
60export const coloursOf = (canvas: Canvas): Set<number> => new Set([...canvas.data].filter(color => color !== CLEAR))
61
62const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
63
64function base64(bytes: Uint8Array): string {
65  let out = ''
66  for (let i = 0; i < bytes.length; i += 3) {
67    const [a, b, c] = [bytes[i]!, bytes[i + 1], bytes[i + 2]]
68    const word = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
69    out += BASE64[word >> 18]! + BASE64[(word >> 12) & 63]! + (b === undefined ? '=' : BASE64[(word >> 6) & 63]!) + (c === undefined ? '=' : BASE64[word & 63]!)
70  }
71
72  return out
73}
74
75/**
76 * The canvas as an Image's source: RGBA bytes, each pixel sent `scale` wide and tall. At 1 the
77 * terminal does the enlarging; at the art pixel's size in device pixels it has nothing left to scale.
78 */
79export function sourceOf(canvas: Canvas, scale = 1): { rgba: string; width: number; height: number } {
80  const [width, height] = [canvas.width * scale, canvas.height * scale]
81  const bytes = new Uint8Array(width * height * 4)
82  for (let y = 0; y < height; y++) {
83    for (let x = 0; x < width; x++) {
84      const color = canvas.data[Math.floor(y / scale) * canvas.width + Math.floor(x / scale)]!
85      if (color !== CLEAR) bytes.set([(color >> 16) & 255, (color >> 8) & 255, color & 255, 255], (y * width + x) * 4)
86    }
87  }
88
89  return { rgba: base64(bytes), width, height }
90}
91
92/** A terminal cell in device pixels. */
93export type Cell = { w: number; h: number }
94
95/** What a cell is taken for while nobody could measure it: one wide by two tall. */
96export const ASSUMED: Cell = { w: 9, h: 18 }
97
98/** Device pixels to an art pixel, each way: about a third of a column, so a pane is some hundred art pixels across. */
99export const unitOf = (cell: Cell): number => Math.max(1, Math.round(cell.w / 3))
100
101/**
102 * A box of cells and the art pixels that fill it exactly, so an art pixel is `unitOf(cell)` device
103 * pixels each way, square and whole: at least `width` by `height` art pixels, grown to the next
104 * whole cells that divide evenly.
105 */
106export type Box = { columns: number; rows: number; width: number; height: number }
107
108export function boxOf(cell: Cell, width: number, height: number): Box {
109  const unit = unitOf(cell)
110  let columns = Math.max(1, Math.ceil((width * unit) / cell.w))
111  while ((columns * cell.w) % unit !== 0) columns += 1
112  let rows = Math.max(1, Math.ceil((height * unit) / cell.h))
113  while ((rows * cell.h) % unit !== 0) rows += 1
114
115  return { columns, rows, width: (columns * cell.w) / unit, height: (rows * cell.h) / unit }
116}
117
118/** The widest box of whole art pixels in at most `columns` cells, `rows` tall as `boxOf` has them. */
119export function boxIn(cell: Cell, columns: number, height: number): Box {
120  const unit = unitOf(cell)
121  let fit = Math.max(1, columns)
122  while (fit > 1 && (fit * cell.w) % unit !== 0) fit -= 1
123
124  return { ...boxOf(cell, 1, height), columns: fit, width: Math.floor((fit * cell.w) / unit) }
125}
126
avatar/hooks/cyber.ts 1316 lines
1// The cyber family's drawn Opus characters: a cyber ninja (the Ghostrunner world, red) and a
2// cyber thief (the Edgerunners world, yellow). Each is one rig of eased joints moved by authored keys, a
3// channel at a time, and drawn two ways from the same pose: in true pixels (a Canvas) and in text
4// (a Grid of [glyph, fg, bg] cells). Pure: a frame is derived from (who, moment) alone, so previews
5// and tests draw what the pane draws.
6import { CLEAR, blank } from './canvas'
7import type { Canvas } from './canvas'
8import { NONE } from './hud'
9import type { Grid } from './hud'
10import type { Mood } from './mascot'
11
12export type Who = 'ninja' | 'thief'
13
14/** What each is called. */
15export const HERO_NAMES: Readonly<Record<Who, string>> = { ninja: 'Jack', thief: 'Lucy' }
16
17/** The art pixels a picture of one is tall at the least, and its text frame: rows (and the fewest it is drawn on), and the fewest columns. */
18export const HERO_PX = 96
19export const HERO_ROWS = 18
20export const HERO_ROWS_LEAST = 16
21export const HERO_COLUMNS = 29
22
23// --- the pose ------------------------------------------------------------------
24
25// A pose, a number a channel. Lengths are art pixels, angles degrees. The figure faces "forward"
26// (drawn to the left, at the conversation); x is forward, y up from the ground.
27//   x, y        the hip; lean, the torso off upright (forward positive); head, the head's own tilt
28//   turn        where the head looks: 1 forward in profile, 0 at the viewer, -1 back over the shoulder
29//   spin        the whole body turned about its middle: positive a backflip
30//   fx fy bx by the near and the far ankle, where they are (not offsets): legs are solved to them
31//   as ae bs be the near and the far arm: shoulder then elbow, from hanging down, forward positive
32//   p q         where the near and the far hand's thing points (0 down, 90 forward, 180 up); P Q what it is
33//   ox oy oa O  a thing in the air: where, turned how, and what
34//   rope holo fade alarm type glow   how far a line is out, a hologram's size, how much of the body is
35//               gone, the light blue turning to the alert, fingers typing, the visor flaring
36//   wink        the visor shut for a moment
37//   air nx ny mx my   off the ground: how far the ankles have left where fx.. put them for a place of
38//               their own under the hips (nx, ny the near one's, from the hip; mx, my the far one's)
39const NAMES = ['x', 'y', 'lean', 'head', 'turn', 'spin', 'fx', 'fy', 'bx', 'by', 'as', 'ae', 'bs', 'be', 'p', 'q', 'P', 'Q', 'ox', 'oy', 'oa', 'O', 'rope', 'holo', 'fade', 'alarm', 'type', 'glow', 'wink', 'air', 'nx', 'ny', 'mx', 'my'] as const
40type Name = (typeof NAMES)[number]
41export type Pose = Readonly<Record<Name, number>>
42/** Channels that hold what they were set to until the next key: nothing is half a katana. */
43const STEPPED: ReadonlySet<Name> = new Set<Name>(['P', 'Q', 'O', 'wink'])
44
45// What a hand holds (P, Q) and what flies (O).
46const [KATANA, DAGGER, PISTOL] = [1, 3, 4]
47const [SHURIKEN, SHARD, SHARD_HELD] = [1, 6, 7]
48
49const EASES = {
50  io: (u: number) => u * u * (3 - 2 * u),
51  in: (u: number) => u * u,
52  out: (u: number) => 1 - (1 - u) ** 2,
53  lin: (u: number) => u,
54  snap: (u: number) => 1 - (1 - u) ** 3,
55  // Past its mark and back: what follows through.
56  over: (u: number) => 1 + 2.7 * (u - 1) ** 3 + 1.7 * (u - 1) ** 2,
57} as const
58type Ease = keyof typeof EASES
59
60/** A key: at this many milliseconds these channels are at these values, each reached with this ease. Two keys at one time are a cut. */
61type Key = readonly [at: number, set: Partial<Pose>, ease?: Ease]
62type Track = { at: number[]; to: number[]; ease: Ease[] }
63type Clip = { length: number; loops: boolean; tracks: Readonly<Record<Name, Track>> }
64
65/**
66 * A clip from its keys: each channel has its own line of keys, so a hand may still be settling while
67 * the hips already move on. Keys may be written in any order (a move is written a limb at a time):
68 * they are put in time's order here, two at one time staying as written. A loop ends as it began.
69 */
70function clip(base: Pose, length: number, written: readonly Key[], loops = true): Clip {
71  const keys = [...written].sort((a, b) => a[0] - b[0])
72  const tracks = {} as Record<Name, Track>
73  for (const name of NAMES) {
74    const track: Track = { at: [0], to: [base[name]], ease: ['io'] }
75    for (const [at, set, ease = 'io'] of keys) {
76      const value = set[name]
77      if (value === undefined) continue
78      if (at === 0 && track.at.length === 1) track.to[0] = value
79      else {
80        track.at.push(at)
81        track.to.push(value)
82        track.ease.push(ease)
83      }
84    }
85    if (track.at[track.at.length - 1]! < length) {
86      track.at.push(length)
87      track.to.push(loops ? track.to[0]! : track.to[track.to.length - 1]!)
88      track.ease.push('io')
89    }
90    tracks[name] = track
91  }
92
93  return { length, loops, tracks }
94}
95
96const mod = (a: number, n: number) => ((a % n) + n) % n
97const lerp = (a: number, b: number, u: number) => a + (b - a) * u
98const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
99
100function sample({ length, loops, tracks }: Clip, time: number): Pose {
101  const t = loops ? mod(time, length) : clamp(time, 0, length)
102  const pose = {} as Record<Name, number>
103  for (const name of NAMES) {
104    const { at, to, ease } = tracks[name]
105    let i = at.length - 1
106    while (i > 0 && at[i]! > t) i -= 1
107    const span = i + 1 < at.length ? at[i + 1]! - at[i]! : 0
108    pose[name] = span <= 0 || STEPPED.has(name) ? to[i]! : lerp(to[i]!, to[i + 1]!, EASES[ease[i + 1]!]((t - at[i]!) / span))
109  }
110
111  return pose
112}
113
114const REST: Pose = {
115  x: 0, y: 20, lean: 0, head: 0, turn: 0.5, spin: 0, fx: 6, fy: 0, bx: -6, by: 0, as: 0, ae: 0, bs: 0, be: 0, p: 0, q: 0, P: 0, Q: 0,
116  ox: 0, oy: 0, oa: 0, O: 0, rope: 0, holo: 0, fade: 0, alarm: 0, type: 0, glow: 0, wink: 0, air: 0, nx: 5, ny: -8, mx: 2.5, my: -7.5,
117}
118
119/**
120 * A flip's flight: the feet leave at `at` with the hips at `from`, the body goes up and comes down a
121 * parabola (`air` milliseconds of it, `peak` high) turning once over: slowly off the toes for a frame,
122 * then at an even rate (an eighth of a turn a frame), and all but round two frames before the
123 * ground, so the last of the fall is seen upright, the legs let down and reaching for it. It meets
124 * the ground at `land`.
125 */
126const flight = (at: number, air: number, from: number, peak: number, land: number, spin: number): Key[] => [
127  [at, { y: from, spin: 0, air: 0 }],
128  [at + air / 2, { y: peak }, 'out'], [at + air, { y: land }, 'in'],
129  [at + 90, { spin: spin * 0.07 }, 'in'], [at + air - 200, { spin: spin * 0.955 }, 'lin'], [at + air, { spin }, 'out'], [at + air, { spin: 0 }],
130  [at + 180, { air: 1 }, 'out'], [at + air - 300, { air: 1 }], [at + air - 60, { air: 0 }],
131]
132/** How long a flip is in the air. */
133const AIR = 900
134
135// --- Kurenai's moves -------------------------------------------------------------
136//
137// At rest she waits as a swordsman does before a draw: low, the katana in its scabbard at the hip,
138// the near hand over its hilt. Every flourish starts from that and comes back to it, exactly.
139
140const N_STANCE = { x: 0, y: 19.5, lean: 8, head: -6, turn: 0.45, fx: 7, fy: 0, bx: -5, by: 0, as: 2, ae: 72, bs: -14, be: 20 }
141// (Off the ground her knees come up to her chest: a ball, the head leading.)
142const NINJA: Pose = { ...REST, ...N_STANCE, p: 100, nx: 4.5, ny: -4, mx: 3, my: -3.6 }
143// The blade as it lies in the scabbard: pointing back and a little down.
144const SHEATHED = 290
145// The hand back on the hilt, the blade home: how every move with the katana out ends. (`p`: the same lie of the blade, named
146// a turn lower for a blade that comes to it from in front and below rather than over the shoulder.)
147const sheathed = (at: number, p = SHEATHED): Key[] => [
148  [at, { as: 14, ae: 70, p, lean: 10, x: 0, y: 18.5, head: -4, bs: -14, be: 20 }],
149  [at + 270, { as: 2, ae: 72 }, 'in'], [at + 270, { P: 0 }],
150  [at + 540, N_STANCE],
151]
152
153const NINJA_IDLE = clip(NINJA, 5400, [
154  // Now and then the head turns from the viewer to the conversation, and stays on it a while.
155  [1500, { turn: 0.45, head: -6 }], [1950, { turn: 1, head: -10 }], [4200, { turn: 1, head: -10 }], [4700, { turn: 0.45, head: -6 }],
156  [2700, { x: 0.8, lean: 9.5 }], [5000, { x: 0, lean: 8 }],
157  // The fingers settle on the hilt.
158  [3000, { ae: 72 }], [3400, { ae: 67 }], [3900, { ae: 72 }],
159])
160
161const NINJA_FLOURISHES: readonly Clip[] = [
162  // A backflip: a coil, a drive up through straight legs, off the toes, a tuck through the turn, the feet let down to find the ground, a crouch that takes it and is held, and up.
163  clip(NINJA, 3600, [
164    [270, { y: 14, lean: 22, head: -14, as: -50, ae: 20, bs: -60, be: 15 }],
165    [360, { y: 13.5, lean: 24 }],
166    [450, { y: 21, lean: 2, as: 120, ae: 10, bs: 110, be: 10, head: 0 }, 'in'],
167    [540, { lean: -12, as: 165, ae: 5, bs: 155, be: 5, head: 8 }, 'lin'],
168    ...flight(540, AIR, 22.5, 40, 20.5, 360),
169    [720, { as: 62, ae: 96, bs: 54, be: 92, lean: 30, head: 6 }], [1170, { as: 62, ae: 96, bs: 54, be: 92, lean: 30, head: 6 }],
170    [1440, { lean: 12, as: 40, ae: 20, bs: -30, be: 10, head: -8, fx: 8, bx: -6 }, 'in'],
171    [1620, { y: 13.5, lean: 28, head: -22, as: 30, ae: 35, bs: -70, be: -10 }, 'out'],
172    [1710, { y: 13, lean: 29 }],
173    [2090, { y: 13.6, lean: 27, head: -18, as: 30, ae: 35, bs: -66, be: -10, fx: 8, bx: -6 }],
174    [2610, N_STANCE],
175  ], false),
176  // The katana drawn, spun three times in the hand, stopped high, and sheathed.
177  clip(NINJA, 3600, [
178    [270, { y: 18, lean: 12, as: 6, ae: 76 }],
179    [330, { P: KATANA, p: SHEATHED }],
180    [330, { as: 6, ae: 76, lean: 12 }], [450, { as: 40, ae: 50, lean: 6, p: 250 }, 'in'], [560, { as: 95, ae: 15, lean: 2, p: 200 }, 'out'],
181    [720, { as: 122, ae: 28, p: 180, y: 20 }],
182    [1530, { p: 180 + 1080 }], [1530, { p: 180 }],
183    [1100, { as: 104, ae: 48 }], [1530, { as: 122, ae: 28 }],
184    [1660, { as: 140, ae: 12, p: 206, lean: -2, head: -12 }, 'over'],
185    [1680, { glow: 0 }], [1770, { glow: 1 }, 'snap'], [2100, { glow: 0 }],
186    [2000, { as: 140, ae: 12, p: 206, lean: -2, head: -12, y: 20 }],
187    [2130, { as: 48, ae: 24, p: 62, lean: 10, head: -8, y: 18.5 }, 'in'],
188    ...sheathed(2400, SHEATHED - 360),
189  ], false),
190  // A draw that is already the cut: rising, its arc hanging in the air; a second cut down through it; the blade flicked clean and put away.
191  clip(NINJA, 3600, [
192    [300, { y: 15, lean: 25, x: -2, head: -18, as: -4, ae: 82, bs: -20, be: 30 }],
193    [390, { P: KATANA, p: SHEATHED }], [390, { as: -4, ae: 82, x: -2, y: 15, lean: 25, fx: 7 }],
194    [570, { as: 132, ae: 4, p: SHEATHED + 240, x: 3, y: 17.5, lean: 2, fx: 11, head: -8 }, 'out'], [570, { p: 170 }],
195    [900, { as: 140, ae: 8, p: 186, x: 3.5, y: 17.5, lean: 0 }, 'out'],
196    [1020, { as: 150, ae: 20, p: 206, lean: -3 }],
197    [1140, { as: 42, ae: 14, p: 56, y: 14, lean: 24, x: 0, head: -20 }, 'in'],
198    [1600, { as: 38, ae: 16, p: 52, y: 14, lean: 25, x: 0 }, 'out'],
199    [1780, { as: 30, ae: 70, p: 128, y: 16, lean: 14, fx: 11 }, 'snap'],
200    [2200, { fx: 7 }],
201    ...sheathed(2200),
202  ], false),
203  // Two throwing stars: the first from behind the shoulder, the arm wound back and whipped over; the second from low, off the hip.
204  clip(NINJA, 3600, [
205    [270, { as: -80, ae: -40, lean: -6, head: 0, turn: 1, x: -1 }],
206    [360, { as: -86, ae: -46, lean: -7, x: -1, y: 19.5 }],
207    [450, { as: 100, ae: 0, lean: 16, x: 2, y: 18 }, 'snap'],
208    [450, { O: SHURIKEN, ox: 16, oy: 35, oa: 0 }], [990, { ox: 54, oy: 37, oa: 270 }, 'lin'], [990, { O: 0 }],
209    [800, { as: 108, ae: 8, lean: 12 }, 'out'],
210    [1080, { as: -40, ae: -30, lean: 2, x: 0, y: 16.5, bs: 30, be: 40 }],
211    [1170, { as: -46, ae: -34, y: 16, x: 0, lean: 2 }],
212    [1260, { as: 62, ae: 6, lean: 20, x: 2.5, y: 15.5 }, 'snap'],
213    [1260, { O: SHURIKEN, ox: 15, oy: 21, oa: 0 }], [1800, { ox: 53, oy: 17, oa: 270 }, 'lin'], [1800, { O: 0 }],
214    [1650, { as: 70, ae: 10, lean: 16, x: 2.5, y: 15.5 }, 'out'],
215    [2340, N_STANCE],
216  ], false),
217  // Gone, and there: she comes apart where she stands, is a step ahead with the cut already made and the blade out behind her, holds it, and is back.
218  clip(NINJA, 3600, [
219    [300, { y: 14.5, lean: 26, head: -18, as: -6, ae: 84, turn: 1 }],
220    [390, { P: KATANA, p: SHEATHED }],
221    // (She goes out already moving, and comes back still sliding to her stop: what is left of her is seen to cross the ground between.)
222    [360, { fade: 0, x: 0, fx: 7, bx: -5 }], [630, { fade: 1, x: 8, fx: 15, bx: 3 }, 'in'],
223    [630, { y: 14.5, lean: 26, head: -18, as: -6, ae: 84, bs: -14, be: 20, p: SHEATHED }],
224    [810, { fade: 1 }], [810, { x: 9.5, fx: 17, bx: 3.5 }, 'lin'],
225    [810, { y: 13.5, lean: 30, as: -50, ae: -10, p: 300, bs: 60, be: 20, head: -20 }],
226    [1080, { fade: 0 }, 'lin'], [1080, { x: 13.5, fx: 21.5, bx: 6.5 }, 'out'],
227    [800, { glow: 0 }], [810, { glow: 1 }], [1500, { glow: 0 }],
228    [1530, { x: 13.5, fx: 21.5, bx: 6.5, y: 13.8, lean: 29, as: -46, ae: -8, p: 296, bs: 60, be: 20, head: -20 }],
229    [1530, { fade: 0 }], [1800, { fade: 1, x: 8, fx: 16, bx: 1 }, 'in'],
230    [1800, { y: 13.8, lean: 29, as: -46, ae: -8, p: 296, bs: 60, be: 20, head: -20 }],
231    [1980, { fade: 1 }], [1980, { x: 5, fx: 12, bx: 0 }, 'lin'],
232    [1980, { y: 17, lean: 12, as: 30, ae: 66, p: 130, bs: -14, be: 20, head: -8 }],
233    [2250, { fade: 0 }, 'lin'], [2250, { x: 0, fx: 7, bx: -5 }, 'out'],
234    [2400, { x: 0, y: 17, lean: 12, as: 30, ae: 66, p: 130, head: -8 }],
235    ...sheathed(2700),
236  ], false),
237]
238
239// A kata, the blade out all through it: a cut down from overhead, the blade swept back low, a rising
240// cut, a whirl, a step back and a thrust, and a last cut down into the guard it began in.
241const N_GUARD = { as: 52, ae: 40, p: 116, y: 18, lean: 6, x: 0, fx: 7, bx: -6, bs: -30, be: 45, head: -8 }
242const NINJA_WORKING = clip({ ...NINJA, ...N_GUARD, P: KATANA, turn: 1, glow: 1 }, 4320, [
243  [300, { as: 152, ae: 22, p: 205, y: 20, lean: -3, x: 0 }],
244  [420, { as: 54, ae: 14, p: 70, y: 15.5, lean: 18, x: -1 }, 'in'],
245  [720, { as: 50, ae: 16, p: 66, y: 15.5, lean: 19, x: -1 }, 'out'],
246  [1020, { as: -36, ae: -26, p: 306, y: 17, lean: 3, x: 3, head: -4 }], [1020, { p: -54 }],
247  [1260, { as: -38, ae: -28, p: -58, y: 16.5, lean: 5, x: 3 }],
248  // (The rising cut comes round under her arm, the hand lifted ahead of it so the point clears the floor.)
249  [1320, { as: 64, ae: 44, y: 18.5 }, 'out'], [1350, { as: 70, ae: 36 }, 'lin'],
250  [1350, { p: 30, lean: 2, x: 2 }, 'in'],
251  [1440, { as: 134, ae: 0, p: 168, y: 20.5, lean: -5, x: 1 }, 'lin'],
252  [1650, { as: 140, ae: 4, p: 180, y: 20.5, lean: -6, x: 1 }, 'out'],
253  [1800, { y: 17 }], [2160, { y: 18 }],
254  [1800, { p: 180 }], [2160, { p: 180 + 360 }], [2160, { p: 180 }], [2340, { p: 116 }],
255  [2340, { as: 52, ae: 40, lean: 6, x: -5, head: -8 }],
256  [2460, { as: 84, ae: 4, p: 92, x: -3.5, fx: 8, y: 15.5, lean: 20 }, 'snap'],
257  [2820, { as: 86, ae: 2, p: 91, x: -3.5, fx: 8, y: 15.5, lean: 21 }],
258  [3120, { as: 52, ae: 40, p: 116, x: 0, fx: 7, y: 18, lean: 6 }],
259  [3400, { as: 152, ae: 22, p: 205, y: 20, lean: -3, x: 0 }],
260  [3520, { as: 54, ae: 14, p: 70, y: 15.5, lean: 18, x: -1 }, 'in'],
261  [3870, { as: 50, ae: 16, p: 66, y: 15.5, lean: 19, x: -1 }, 'out'],
262])
263
264// A backflip, the drawn blade tucked along her arm; the landing taken low; then she rises with the katana wheeling in her hand and stops it over a lifted chin.
265const NINJA_HAPPY = clip(NINJA, 4000, [
266  [180, { P: KATANA, p: SHEATHED }], [180, { as: 2, ae: 72 }],
267  [360, { y: 14, lean: 22, as: -40, ae: 60, p: 250, bs: -55, be: 10, head: -14 }],
268  [450, { y: 21, lean: 2, as: 110, ae: 20, bs: 120, be: 10, head: 0 }, 'in'],
269  [540, { lean: -12, as: 150, ae: 30, bs: 155, be: 5, head: 8 }, 'lin'],
270  ...flight(540, AIR, 22.5, 40, 20.5, 360),
271  [720, { as: 62, ae: 96, bs: 54, be: 92, lean: 30, head: 6, p: 250 }], [1170, { as: 62, ae: 96, bs: 54, be: 92, lean: 30, head: 6, p: 250 }],
272  [1440, { lean: 12, as: 50, ae: 20, bs: -30, be: 10, head: -8, p: 110, fx: 8, bx: -6 }, 'in'],
273  [1620, { y: 13.5, lean: 28, head: -22, as: 40, ae: 50, p: 130, bs: -60, be: -10 }, 'out'],
274  [1800, { y: 13.8, lean: 27, as: 40, ae: 50, bs: -60, be: -10, head: -22, turn: 0.45 }],
275  [1800, { p: 130 }], [2340, { p: 186 + 720 }, 'out'], [2340, { p: 186 }],
276  [2250, { y: 21, lean: -4, as: 150, ae: 22, bs: -34, be: -30, head: -16, turn: 0.2, fx: 7, bx: -6 }, 'over'],
277  [2250, { glow: 0 }], [2340, { glow: 1 }, 'snap'], [2900, { glow: 0 }],
278  [3060, { y: 21, lean: -4, as: 150, ae: 22, p: 186, bs: -34, be: -30, head: -16, turn: 0.2, fx: 7, bx: -6 }],
279  // (Out of its high stop the blade is cut down in front of her, a quarter of a circle, and only then goes home.)
280  [3190, { as: 48, ae: 24, p: 62, lean: 10, head: -8, y: 18.5, turn: 0.45 }, 'in'],
281  ...sheathed(3440, SHEATHED - 360),
282], false)
283
284// A low guard, the blade up between her and whatever comes; she breathes, her grip works on the hilt, the head goes from the conversation to the viewer and back, then over her shoulder, and a foot shifts.
285const NINJA_WORRIED = clip({ ...NINJA, y: 14.5, lean: 20, fx: 10, bx: -9, as: 30, ae: 70, p: 125, bs: -40, be: 42, P: KATANA, alarm: 1, turn: 1, head: -14 }, 3600, [
286  [700, { turn: 1, head: -14 }], [900, { turn: 0, head: -10 }], [1500, { turn: 0, head: -10 }], [1700, { turn: 1, head: -14 }],
287  [2300, { turn: 1, head: -14 }], [2450, { turn: -0.6, head: -8 }], [2900, { turn: -0.6, head: -8 }], [3100, { turn: 1, head: -14 }],
288  [600, { y: 15.7, lean: 17 }], [1200, { y: 14.5, lean: 20 }], [1800, { y: 15.7, lean: 17 }], [2400, { y: 14.5, lean: 20, x: 0, bx: -9 }],
289  [2580, { x: -2, bx: -12, y: 14 }, 'out'], [3300, { x: -2, bx: -12, y: 14.3 }],
290  [900, { p: 131, as: 27, ae: 74 }], [1700, { p: 125, as: 30, ae: 70 }], [2600, { p: 133, as: 27, ae: 76 }], [3300, { p: 125, as: 30, ae: 70 }],
291])
292
293// --- Magpie's moves ---------------------------------------------------------------
294//
295// At rest she stands easy: weight on one leg, a hand on her hip, the deck on her other forearm
296// raised where she can read it.
297
298const T_STANCE = { x: 0, y: 20.5, lean: 3, head: -3, turn: 0.5, fx: 5, fy: 0, bx: -5, by: 0, as: -28, ae: 75, bs: 25, be: 95, holo: 0.3 }
299const THIEF: Pose = { ...REST, ...T_STANCE }
300
301const THIEF_IDLE = clip(THIEF, 5400, [
302  // Her weight goes from one leg to the other; in between she reads her deck, and taps at it.
303  [1300, { x: -1.5, lean: 1, y: 20.2 }], [2700, { x: 1.5, lean: 5, y: 20.5 }], [4000, { x: -1, lean: 2, y: 20.2 }],
304  [1700, { head: -3, turn: 0.5, as: -28, ae: 75, holo: 0.3 }], [2150, { head: 14, turn: 0.9, as: 36, ae: 86, holo: 0.5 }],
305  [2150, { type: 0 }], [2200, { type: 0.8 }], [3200, { type: 0.8, head: 14, turn: 0.9, as: 36, ae: 86, holo: 0.5 }], [3250, { type: 0 }],
306  [3700, { head: -3, turn: 0.5, as: -28, ae: 75, holo: 0.3 }],
307])
308
309const THIEF_FLOURISHES: readonly Clip[] = [
310  // An aerial: a dip, a drive up off the toes, over she goes forward with her legs split and her hair flying, lands a step ahead and takes it low with her arms spread, and hops back.
311  clip(THIEF, 3600, [
312    [270, { y: 15.5, lean: 20, head: 4, as: -55, ae: 10, bs: -45, be: 10, holo: 0 }],
313    [360, { y: 15, lean: 22, x: 0 }],
314    [450, { y: 21.5, lean: 14, as: 80, ae: 0, bs: 30, be: 0, x: 1 }, 'in'],
315    [540, { lean: 22, as: 104, ae: 0, bs: -96, be: 0, x: 2 }, 'lin'],
316    ...flight(540, AIR, 22.5, 38.5, 20.5, -360),
317    [540 + AIR, { x: 9 }, 'lin'],
318    // (Her body one line through the turn, her legs split wide: the turn is all that changes from a frame to the next.)
319    [540, { nx: 5, ny: -8, mx: 2.5, my: -7.5 }], [720, { nx: 14, ny: -10, mx: -13, my: -9 }], [1140, { nx: 14, ny: -10, mx: -13, my: -9 }],
320    [900, { fx: 5, bx: -5 }], [900, { fx: 14, bx: 4 }], [1380, { nx: 5, ny: -8, mx: 2.5, my: -7.5 }],
321    [720, { lean: 14, head: 0 }, 'lin'], [1230, { as: 104, ae: 0, bs: -96, be: 0, lean: 14, head: 0 }],
322    [1440, { lean: 12, as: -40, ae: -10, bs: 110, be: -10, head: -6 }, 'in'],
323    [1620, { y: 14.5, lean: 22, head: -14, as: -80, ae: -10, bs: 150, be: -10 }, 'out'],
324    [1710, { y: 14.2 }],
325    [1980, { y: 14.8, lean: 21, as: -76, ae: -10, bs: 146, be: -10, head: -14 }],
326    [1980, { x: 9, fx: 14, fy: 0, bx: 4, by: 0 }],
327    // (Up out of it over three frames, and a small hop back to where she stood.)
328    [2250, { y: 21.5, x: 7, lean: 8, as: -30, ae: 20, bs: 60, be: 40, head: -6 }, 'out'],
329    [2250, { fx: 14, fy: 0, bx: 4, by: 0 }],
330    [2430, { y: 23.5, x: 3.5, fx: 8.5, fy: 3.5, bx: -1.5, by: 3, lean: 5 }, 'out'],
331    [2610, { y: 19.6, x: 0, fx: 5, fy: 0, bx: -5, by: 0 }, 'in'],
332    [2700, { y: 18.6 }], [3060, T_STANCE],
333  ], false),
334  // A dagger off her thigh, twirled, tossed up end over end while she watches it, caught, pointed, put back.
335  clip(THIEF, 3600, [
336    [200, { as: -8, ae: 18 }],
337    [300, { P: DAGGER, p: 10 }],
338    [300, { as: -8, ae: 18, holo: 0.3 }], [520, { as: 62, ae: 58, p: 120, holo: 0, bs: 8, be: 40 }, 'out'],
339    [1000, { p: 120 + 720 }], [1000, { p: 120 }],
340    [1000, { as: 62, ae: 58, y: 20.5 }], [1090, { as: 42, ae: 40, y: 19.5 }],
341    [1180, { as: 124, ae: 16, y: 21 }, 'snap'],
342    [1180, { P: 0, O: DAGGER, ox: 10, oy: 43, oa: 180 }], [1560, { oy: 64 }, 'out'], [1940, { oy: 41, ox: 11 }, 'in'], [1940, { oa: 180 - 1080 }, 'lin'],
343    [1180, { head: -3, turn: 0.5 }], [1300, { head: -24, turn: 0.8 }], [1850, { head: -24, turn: 0.8 }], [2050, { head: -3, turn: 0.5 }],
344    [1850, { as: 110, ae: 30 }], [1940, { as: 100, ae: 36 }],
345    [1940, { P: DAGGER, O: 0, p: 180 }],
346    [2060, { as: 72, ae: 52, p: 160, y: 19.5 }, 'out'],
347    [2300, { as: 96, ae: 6, p: 94, lean: 6, y: 20.5 }, 'over'], [2700, { as: 96, ae: 6, p: 94, lean: 6 }],
348    [3000, { as: -8, ae: 18, p: 10, lean: 3 }], [3080, { P: 0 }], [3080, { as: -8, ae: 18 }],
349    [3400, T_STANCE],
350  ], false),
351  // Her pistol from the back of her belt, spun round a finger a quarter turn a frame, levelled, a shot that kicks, a wink, a last turn, and away.
352  clip(THIEF, 3600, [
353    [200, { as: -50, ae: 40 }],
354    [320, { P: PISTOL, p: 30 }],
355    [320, { as: -50, ae: 40, holo: 0.3 }], [600, { as: 66, ae: 56, p: 90, holo: 0, bs: -8, be: 30 }, 'out'],
356    [1680, { p: 90 - 1080 }, 'lin'], [1680, { p: 90 }],
357    [1100, { as: 54, ae: 72 }], [1680, { as: 72, ae: 44, lean: 3, head: -3, turn: 0.5, x: 0 }],
358    [1800, { as: 90, ae: 0, lean: 8, head: -6, turn: 1, x: 1 }, 'snap'],
359    [2070, { as: 90, ae: 0, p: 90, lean: 8, glow: 0 }],
360    [2160, { as: 106, ae: 8, p: 114, lean: 4, glow: 1 }, 'snap'], [2250, { glow: 0 }],
361    [2430, { as: 90, ae: 0, p: 90, lean: 8 }, 'out'],
362    [2430, { wink: 1 }], [2610, { wink: 0 }],
363    [2610, { as: 90, ae: 0, p: 90, lean: 8, head: -6, turn: 1, x: 1 }],
364    [2970, { p: 390, as: -50, ae: 40, lean: 3, x: 0 }], [2970, { p: 30 }],
365    [3060, { P: 0 }], [3060, { as: -50, ae: 40 }],
366    [3420, T_STANCE],
367  ], false),
368  // A line shot up and ahead: she swings out on it, legs trailing then kicking through, back past where she stood, and drops.
369  clip(THIEF, 3600, [
370    [250, { bs: 152, be: 8, head: -14, turn: 0.9, holo: 0 }],
371    [330, { rope: 0 }], [520, { rope: 1 }, 'lin'],
372    [650, { y: 19, lean: 8, x: 0, as: -40, ae: 30 }],
373    [650, { fx: 5, fy: 0, bx: -5, by: 0 }],
374    [1250, { x: 19 }], [1900, { x: -7 }], [2150, { x: 0 }, 'out'],
375    [950, { y: 27 }, 'out'], [1250, { y: 35 }, 'out'], [1600, { y: 27 }, 'in'], [1900, { y: 31 }, 'out'], [2150, { y: 19.5 }, 'in'],
376    [950, { lean: -4 }], [1250, { lean: -26, bs: 168, be: 4, as: -70, ae: 10 }], [1900, { lean: 22, bs: 150, be: 14 }], [2150, { lean: 6 }],
377    [950, { fx: 1, fy: 9, bx: -4, by: 8 }], [1250, { fx: 30, fy: 23, bx: 25, by: 20 }], [1600, { fx: 9, fy: 8, bx: 5, by: 7 }],
378    [1900, { fx: -17, fy: 14, bx: -14, by: 12 }], [2150, { fx: 5, fy: 0, bx: -5, by: 0 }, 'in'],
379    [2150, { rope: 1 }], [2260, { rope: 0 }],
380    [2300, { y: 17 }, 'out'], [2650, T_STANCE],
381  ], false),
382  // A data shard that is not hers: flipped off her fingers, caught, held up to the light, a wink, and into her jacket.
383  clip(THIEF, 3600, [
384    [300, { as: 40, ae: 70, holo: 0 }],
385    [300, { O: SHARD_HELD }],
386    [450, { O: SHARD, ox: 13, oy: 31, oa: 0 }], [450, { as: 52, ae: 78 }, 'snap'],
387    [750, { oy: 47 }, 'out'], [1050, { oy: 31 }, 'in'], [1050, { oa: 720 }, 'lin'],
388    [600, { head: -14 }], [1000, { head: -2 }],
389    [1050, { O: SHARD_HELD }], [1050, { as: 40, ae: 70, turn: 0.5, lean: 3 }],
390    [1320, { as: 78, ae: 92, head: 4, turn: 0.8, lean: 5 }],
391    [1700, { wink: 1 }], [1880, { wink: 0 }], [1620, { glow: 0 }], [1710, { glow: 1 }, 'snap'], [2100, { glow: 0 }],
392    [2000, { as: 78, ae: 92, head: 4, turn: 0.8, lean: 5 }],
393    [2320, { as: 12, ae: 124, head: 8, lean: 3 }],
394    [2400, { O: 0 }],
395    [2520, { ae: 114 }], [2640, { ae: 124, as: 12, head: 8, turn: 0.8 }],
396    [3050, T_STANCE],
397  ], false),
398]
399
400// Typing on a keyboard of light that hangs tilted at her waist, her elbows bent over it, code running
401// up a screen ahead of her; between two bursts she winds a dagger back off her thigh and flicks it
402// round, or feels for the pistol at her back with a look at who is watching.
403const T_TYPING = { as: 30, ae: 62, bs: 38, be: 58 }
404const THIEF_WORKING = clip({ ...THIEF, ...T_TYPING, holo: 1, type: 1, lean: 8, head: 8, turn: 0.9, y: 20, fx: 6, bx: -5 }, 4320, [
405  [1560, { type: 1 }], [1620, { type: 0 }], [2640, { type: 0 }], [2700, { type: 1 }], [3600, { type: 1 }], [3660, { type: 0 }], [4260, { type: 0 }],
406  [1620, { as: 30, ae: 62 }], [1800, { as: -8, ae: 18 }], [1830, { P: DAGGER, p: 10 }], [1830, { as: -8, ae: 18 }],
407  [2130, { as: -40, ae: -20, p: -60 }],
408  [2250, { as: 40, ae: 50, p: 150 }, 'snap'],
409  [2340, { as: 36, ae: 54, p: 170 }],
410  [2500, { as: -8, ae: 18, p: 10 }], [2530, { P: 0 }], [2530, { as: -8, ae: 18 }], [2700, { as: 30, ae: 62 }],
411  [3660, { bs: 38, be: 58, head: 8, turn: 0.9 }], [3840, { bs: -42, be: 36, head: -4, turn: 0.2 }], [4080, { bs: -42, be: 36, head: -4, turn: 0.2 }], [4290, { bs: 38, be: 58, head: 8, turn: 0.9 }],
412  [800, { x: 0.8 }], [2600, { x: -0.6 }],
413  ...[0, 2880].flatMap(from => [0, 1, 2, 3].flatMap((n): Key[] => [[from + n * 360 + 90, { y: 19.3 }], [from + n * 360 + 270, { y: 20 }]])),
414  [720, { as: 30, ae: 62 }], [840, { as: 50, ae: 34 }, 'out'], [990, { as: 50, ae: 34 }], [1110, { as: 30, ae: 62 }],
415  [400, { lean: 9.5, head: 11 }], [1400, { lean: 7.5, head: 7 }], [1620, { head: 4 }], [2700, { head: 8, lean: 8 }], [3000, { lean: 9.5, head: 11 }], [3600, { lean: 8, head: 8 }],
416])
417
418// A backflip with a dagger in each hand; the landing taken low; both spun as she comes up, and one held high over a cocked hip.
419const THIEF_HAPPY = clip(THIEF, 4000, [
420  [200, { P: DAGGER, Q: DAGGER, p: 20, q: 20 }],
421  [270, { y: 15.5, lean: 18, as: -40, ae: 20, bs: -30, be: 15, holo: 0 }],
422  [360, { y: 15, lean: 20 }],
423  [450, { y: 21.5, lean: 2, as: 110, ae: 10, bs: 120, be: 10, head: 0 }, 'in'],
424  [540, { lean: -12, as: 150, ae: 0, bs: 140, be: 0, head: 8 }, 'lin'],
425  ...flight(540, AIR, 22.5, 40, 20.5, 360),
426  [540, { nx: 5, ny: -8, mx: 2.5, my: -7.5 }], [720, { nx: 4.5, ny: -4.5, mx: 3, my: -4 }], [1140, { nx: 4.5, ny: -4.5, mx: 3, my: -4 }], [1380, { nx: 5, ny: -8, mx: 2.5, my: -7.5 }],
427  [720, { as: 64, ae: 92, bs: 56, be: 90, lean: 28, head: 4 }], [1170, { as: 64, ae: 92, bs: 56, be: 90, lean: 28, head: 4 }],
428  [1440, { lean: 12, as: -20, ae: 0, bs: -40, be: 0, head: -6, fx: 6, bx: -6 }, 'in'],
429  [1620, { y: 14.5, lean: 24, as: -30, ae: -10, bs: -55, be: 0, head: -18 }, 'out'],
430  [1800, { y: 14.8, lean: 23, as: -30, ae: -10, bs: -55, be: 0, head: -18 }],
431  [2070, { y: 21, lean: -2, as: 110, ae: 22, bs: 62, be: 42, head: -8 }],
432  // (Both spun as she rises, the near arm going up all the while: each stops where it will be held.)
433  [2070, { p: 180, q: 90 }], [2520, { p: 186 + 720, q: -50 - 720 }], [2520, { p: 186, q: -50 }],
434  [2520, { turn: 0.5, x: 0, fx: 6, bx: -6, as: 148, ae: 16, bs: 20, be: 60, head: -8, lean: -2 }],
435  [2700, { as: 160, ae: 10, p: 186, bs: -30, be: 80, q: -50, head: -12, turn: 0.2, lean: -3, x: 0.5, fx: 6, bx: -3 }, 'over'],
436  [2650, { glow: 0 }], [2740, { glow: 1 }, 'snap'], [3200, { glow: 0 }], [2900, { wink: 1 }], [3080, { wink: 0 }],
437  [3350, { as: 160, ae: 10, p: 186, bs: -30, be: 80, q: -50, head: -12, turn: 0.2, lean: -3, x: 0.5, y: 21 }],
438  [3650, { as: -8, ae: 18, bs: 10, be: 30, p: 10, q: 10, x: 0 }], [3740, { P: 0, Q: 0 }], [3740, { holo: 0 }],
439  [3980, T_STANCE],
440], false)
441
442// Down low, a dagger out; she breathes, looks ahead, then back over her shoulder, and flinches.
443const THIEF_WORRIED = clip({ ...THIEF, y: 12.5, lean: 24, fx: 9, bx: -8, as: 4, ae: 30, P: DAGGER, p: 70, bs: 30, be: 100, holo: 0.5, alarm: 1, head: -8, turn: 1 }, 3600, [
444  [900, { turn: 1, head: -8, lean: 24, x: 0 }], [1150, { turn: -1, head: -2, lean: 19, x: -1 }], [2300, { turn: -1, head: -2, lean: 19, x: -1 }], [2550, { turn: 1, head: -8, lean: 24, x: 0 }],
445  [600, { y: 13.1 }], [1200, { y: 12.5 }], [1800, { y: 13.1 }], [2400, { y: 12.5 }],
446  [2950, { y: 12.7 }], [3050, { y: 11 }, 'snap'], [3300, { y: 12.5 }],
447  [600, { p: 70, ae: 30 }], [900, { p: 82, ae: 36 }], [1300, { p: 66, ae: 28 }], [2400, { p: 74, ae: 34 }], [3300, { p: 70, ae: 30 }],
448])
449
450const CLIPS: Readonly<Record<Who, { idle: Clip; flourishes: readonly Clip[]; working: Clip; happy: Clip; worried: Clip }>> = {
451  ninja: { idle: NINJA_IDLE, flourishes: NINJA_FLOURISHES, working: NINJA_WORKING, happy: NINJA_HAPPY, worried: NINJA_WORRIED },
452  thief: { idle: THIEF_IDLE, flourishes: THIEF_FLOURISHES, working: THIEF_WORKING, happy: THIEF_HAPPY, worried: THIEF_WORRIED },
453}
454
455// --- when ------------------------------------------------------------------------
456
457const FRAME_MS = 90
458/** Idle is a cycle of this many milliseconds: the stance for CALM_MS of it, then one flourish. */
459export const CYCLE_MS = 9000
460export const CALM_MS = 5400
461export const FLOURISHES = 5
462/** How long a pose takes to become another mood's. */
463export const BLEND_MS = 270
464/** What breathes, sways and pulses at rest does it in this long, or a part of it: the stance comes round whole. */
465const BREATH_MS = 2700
466export const FLOURISH_NAMES: Readonly<Record<Who, readonly string[]>> = {
467  ninja: ['backflip', 'spin-sheathe', 'draw-slash', 'shuriken', 'vanish-dash'],
468  thief: ['aerial', 'dagger-toss', 'pistol-spin', 'grapple', 'shard'],
469}
470
471/** Which flourish plays at `now`, -1 for none: each cycle's is the next of the five, so it is known from the clock alone. */
472export const flourishOf = (now: number): number => (mod(now, CYCLE_MS) < CALM_MS ? -1 : Math.floor(now / CYCLE_MS) % FLOURISHES)
473
474/**
475 * The flourish a character idle since `since` plays at `now`, -1 for none. A flourish is played whole
476 * or not at all: one already under way when she came to rest (a cheer over, a worry gone) is left
477 * out, and she holds the stance till the next cycle. Joined part-way it would be a jump: half a flip in a frame.
478 */
479export const flourishAt = (now: number, since = 0): number => (since > now - mod(now, CYCLE_MS) + CALM_MS ? -1 : flourishOf(now))
480
481/**
482 * A moment of a character's life: its mood, the clock, since when the mood has lasted (a flip starts
483 * when the joy does), the working kata's own clock (it runs faster while a tool does) and the mood
484 * before, which the pose leaves over BLEND_MS rather than at once.
485 */
486export type Moment = { mood: Mood; now: number; since?: number; clock?: number; was?: { mood: Mood; since: number } }
487
488/** Where a character is in its moods: the one it is in, since when, and the one before. */
489export type Beat = { mood: Mood; since: number; was?: { mood: Mood; since: number } }
490
491/**
492 * The beat after `beat`, the mood being `mood` at `now`. A mood that has a cause (a cheer: a turn
493 * done, a pet) began at `began`; one struck again while it lasts starts its move over, from wherever
494 * the last one had got to.
495 */
496export function beatAfter(beat: Beat, mood: Mood, now: number, began?: number): Beat {
497  const isAgain = mood === beat.mood && began !== undefined && began > beat.since
498  if (mood === beat.mood && !isAgain) return beat
499
500  return { mood, since: Math.min(now, began ?? now), was: { mood: beat.mood, since: beat.since } }
501}
502
503function poseOf(who: Who, mood: Mood, now: number, since: number, clock: number): Pose {
504  const set = CLIPS[who]
505  const turn = mod(now, CYCLE_MS)
506  const flourish = flourishAt(now, since)
507  // (The stance held in a flourish's place is the one every flourish ends in, and every cycle begins in.)
508  const pose = mood === 'idle' ? (turn < CALM_MS ? sample(set.idle, turn) : flourish < 0 ? sample(set.idle, 0) : sample(set.flourishes[flourish]!, turn - CALM_MS))
509    : mood === 'working' ? sample(set.working, clock) : sample(set[mood], now - since)
510  // (The other moods breathe in their own keys: their loops close by themselves.)
511  if (mood !== 'idle') return pose
512  // Breathing, under everything at rest: the hips sink a little, the chest follows, the arms after it.
513  const breath = (lag: number) => Math.sin((2 * Math.PI * (now - lag)) / BREATH_MS)
514
515  return { ...pose, y: pose.y + (pose.air > 0 || pose.fy > 1 ? 0 : 0.45 * breath(0)), lean: pose.lean + 0.9 * breath(200), ae: pose.ae + 1.6 * breath(420), be: pose.be + 1.6 * breath(520) }
516}
517
518/** The pose at a moment, `lag` milliseconds ago: what trails (a scarf, hair, a blade's arc) is drawn from where the body just was. */
519export function heroPose(who: Who, m: Moment, lag = 0): Pose {
520  const [now, since] = [m.now - lag, m.since ?? 0]
521  const clock = (m.clock ?? m.now) - lag
522  if (m.was === undefined || now - since >= BLEND_MS) return poseOf(who, m.mood, now, since, clock)
523  const before = poseOf(who, m.was.mood, now, m.was.since, clock)
524  if (now <= since) return before
525  const [after, u] = [poseOf(who, m.mood, now, since, clock), EASES.io((now - since) / BLEND_MS)]
526  const pose = {} as Record<Name, number>
527  for (const name of NAMES) pose[name] = STEPPED.has(name) ? (u < 0.5 ? before[name] : after[name]) : lerp(before[name], after[name], u)
528
529  return pose
530}
531
532// How far a channel may go in a few frames and the figure still be at rest: a breath's worth.
533const STILL: Partial<Record<Name, number>> = { turn: 0.04, type: 0.05, holo: 0.04, glow: 0.2, fade: 0.01 }
534
535/**
536 * Whether nothing but breathing moves at a moment, nor will in the next few frames: the stance,
537 * between two of its own small moves, and not about to break into a flourish. Then a frame in a few
538 * is enough; whatever moves faster (a head turning, fingers on a deck, a flourish) is drawn every frame.
539 */
540export function isAtRest(who: Who, m: Moment): boolean {
541  const ahead = 3 * FRAME_MS
542  if (m.mood !== 'idle' || flourishAt(m.now, m.since) >= 0 || flourishAt(m.now + ahead + FRAME_MS, m.since) >= 0) return false
543  if (m.was !== undefined && m.now - (m.since ?? 0) < BLEND_MS + 400) return false
544  const [here, soon] = [heroPose(who, m), heroPose(who, { ...m, now: m.now + ahead })]
545
546  return here.type < 0.05 && NAMES.every(name => Math.abs(soon[name] - here[name]) <= (STILL[name] ?? 1.3))
547}
548
549// --- the rig -----------------------------------------------------------------------
550
551type V = readonly [number, number]
552const RAD = Math.PI / 180
553const add = (a: V, b: V): V => [a[0] + b[0], a[1] + b[1]]
554const sub = (a: V, b: V): V => [a[0] - b[0], a[1] - b[1]]
555const mul = (a: V, k: number): V => [a[0] * k, a[1] * k]
556const between = (a: V, b: V, u = 0.5): V => [lerp(a[0], b[0], u), lerp(a[1], b[1], u)]
557const far = (a: V, b: V) => Math.hypot(a[0] - b[0], a[1] - b[1])
558/** A length pointing up, turned forward by an angle; and one hanging down. */
559const up = (deg: number, len: number): V => [len * Math.sin(deg * RAD), len * Math.cos(deg * RAD)]
560const down = (deg: number, len: number): V => [len * Math.sin(deg * RAD), -len * Math.cos(deg * RAD)]
561const turned = (v: V, deg: number): V => [v[0] * Math.cos(deg * RAD) - v[1] * Math.sin(deg * RAD), v[0] * Math.sin(deg * RAD) + v[1] * Math.cos(deg * RAD)]
562/** `v` no longer than `most`. */
563const capped = (v: V, most: number): V => {
564  const length = Math.hypot(v[0], v[1])
565
566  return length > most ? mul(v, most / length) : v
567}
568
569// A limb is one length whatever it does: depth is shown by what overlaps what, never by a longer bone.
570const [THIGH, SHIN, TORSO, ARM] = [11, 11, 15, 8]
571/** From the shoulders' line to the middle of the head: hers is longer, a neck shows under the bob. */
572const NECK: Readonly<Record<Who, number>> = { ninja: 4.6, thief: 5.6 }
573
574/** The joints of a pose, where they are once the body has turned; `at` puts a point given in the head's own frame, `off` one in the body's. */
575type Rig = {
576  pose: Pose
577  hip: V; neck: V; head: V; shoulder: V; shoulderF: V; hipN: V; hipF: V
578  elbowN: V; handN: V; elbowF: V; handF: V
579  kneeN: V; ankleN: V; toeN: V; kneeF: V; ankleF: V; toeF: V
580  /** Where a held thing points, as drawn: its angle, the body's turn added. */
581  aim: (deg: number, len: number) => V
582  at: (x: number, y: number) => V
583  off: (x: number, y: number) => V
584}
585
586function rigOf(who: Who, pose: Pose): Rig {
587  const hip: V = [pose.x, pose.y]
588  const pivot = add(hip, up(pose.lean, 7))
589  const spun = (v: V): V => add(pivot, turned(sub(v, pivot), pose.spin))
590  const neck = add(hip, up(pose.lean, TORSO))
591  const head = add(neck, up(pose.lean + pose.head, NECK[who]))
592  // The body is seen from a little in front of its side: the far shoulder and hip show ahead of the near ones.
593  const across: V = [Math.cos(pose.lean * RAD), -Math.sin(pose.lean * RAD)]
594  const shoulder = add(add(hip, up(pose.lean, TORSO - 2.5)), mul(across, -1.4))
595  const shoulderF = add(add(hip, up(pose.lean, TORSO - 2.5)), mul(across, 1.8))
596  const [hipN, hipF] = [add(hip, mul(across, -0.8)), add(hip, mul(across, 1))]
597  const arm = (from: V, s: number, e: number): [V, V] => {
598    const elbow = add(from, down(s, ARM))
599
600    return [elbow, add(elbow, down(s + e, ARM))]
601  }
602  const leg = (ground: V, tucked: V): [V, V, V] => {
603    // Where the ankle is wanted: on its mark, or (off the ground) at its place under the hips. A mark out of the leg's reach is not reached: the foot leaves it.
604    const want = between([ground[0], ground[1] + 1.5], add(hip, tucked), pose.air)
605    const gap = Math.max(0.001, far(hip, want))
606    const reach = clamp(gap, 4, THIGH + SHIN - 0.2)
607    const toFoot = mul(sub(want, hip), 1 / gap)
608    const ankle = add(hip, mul(toFoot, reach))
609    const bend = Math.acos(clamp((THIGH * THIGH + reach * reach - SHIN * SHIN) / (2 * THIGH * reach), -1, 1)) / RAD
610    // The knee is the one of the two that is further forward.
611    const [a, b] = [add(hip, mul(turned(toFoot, bend), THIGH)), add(hip, mul(turned(toFoot, -bend), THIGH))]
612    const air = clamp(ankle[1] / 8, 0, 1)
613
614    return [a[0] > b[0] ? a : b, ankle, add(ankle, [lerp(3.4, 2.2, air), lerp(-0.6, -2.4, air)])]
615  }
616  const [elbowN, handN] = arm(shoulder, pose.as, pose.ae)
617  const [elbowF, handF] = arm(shoulderF, pose.bs, pose.be)
618  const [kneeN, ankleN, toeN] = leg([pose.fx, pose.fy], [pose.nx, pose.ny])
619  const [kneeF, ankleF, toeF] = leg([pose.bx, pose.by], [pose.mx, pose.my])
620  const tilt = pose.lean + pose.head
621  const headAt = spun(head)
622
623  return {
624    pose, hip: spun(hip), neck: spun(neck), head: headAt, shoulder: spun(shoulder), shoulderF: spun(shoulderF), hipN: spun(hipN), hipF: spun(hipF),
625    elbowN: spun(elbowN), handN: spun(handN), elbowF: spun(elbowF), handF: spun(handF),
626    kneeN: spun(kneeN), ankleN: spun(ankleN), toeN: spun(toeN), kneeF: spun(kneeF), ankleF: spun(ankleF), toeF: spun(toeF),
627    aim: (deg, len) => turned(down(deg, len), pose.spin),
628    at: (x, y) => add(headAt, turned(add(mul([Math.cos(tilt * RAD), -Math.sin(tilt * RAD)], x), up(tilt, y)), pose.spin)),
629    off: (x, y) => add(spun(hip), turned(add(mul([Math.cos(pose.lean * RAD), -Math.sin(pose.lean * RAD)], x), up(pose.lean, y)), pose.spin)),
630  }
631}
632
633// --- the brush -----------------------------------------------------------------------
634
635/** A material: its plain tone, where the light catches it, in its own shadow, and (where it is not the figure's) the line round it. */
636type Mat = readonly [base: number, light: number, shade: number, edge?: number]
637
638/** A glyph for the text frame, at a cell: over whatever the conversion put there. `band` keeps the cell's own colour as its ground. */
639type Stamp = { x: number; y: number; glyph: string; fg: number; band?: true; bg?: number }
640
641/** A head for the text frame, a row of cells a line, a cell [glyph, fg, bg]: drawn as written for a head upright and looking forward (to the left). */
642type Face = readonly (readonly (readonly [glyph: string, fg: number, bg?: number])[])[]
643// A block glyph seen in a mirror (left for right), and upside down.
644const MIRRORED: Readonly<Record<string, string>> = { '▌': '▐', '▐': '▌', '▟': '▙', '▙': '▟', '▜': '▛', '▛': '▜', '▗': '▖', '▖': '▗', '▝': '▘', '▘': '▝' }
645const UPENDED: Readonly<Record<string, string>> = { '▀': '▄', '▄': '▀', '▟': '▜', '▜': '▟', '▙': '▛', '▛': '▙', '▗': '▝', '▝': '▗', '▖': '▘', '▘': '▖' }
646
647/**
648 * What a character is drawn with. A solid shape goes on the body layer, outlined and lit from the
649 * upper left where the layer is pixels; a mass of light (an arc) on the layer over it; a fine line
650 * (a blade, a rope, a key of light) on the last, which the text frame draws in dots.
651 * Points are the rig's: forward to the left, up up.
652 */
653type Brush = {
654  isText: boolean
655  cap: (a: V, b: V, radius: number, mat: Mat) => void
656  poly: (points: readonly V[], mat: Mat) => void
657  line: (a: V, b: V, color: number) => void
658  /** A filled shape of light; `isFaint` thins it to every other dot: a trail's tail, going out. */
659  fan: (points: readonly V[], color: number, isFaint?: boolean) => void
660  dot: (a: V, color: number) => void
661  /** A blade, for the text frame: the quarters of the cells it crosses, so it has one weight at any angle and is seen from across the room. */
662  bar: (a: V, b: V, color: number) => void
663  /** Text for the cells where `a` is, a glyph a cell; in pixels, a dash a glyph. */
664  write: (a: V, text: string, color: number) => void
665  /** A band of light half a cell tall, for the text frame alone: a visor. */
666  band: (a: V, b: V, color: number) => void
667  /** A head, for the text frame alone: the same cells whatever the pose, centred where `a` is, mirrored for a look back and turned over for a head that is upside down. */
668  face: (a: V, cells: Face, isMirrored: boolean, isUpended: boolean) => void
669  /** What is on the body layer goes out, a band of scan lines at a time: the same bands whatever the frame, so more gone is only ever more. */
670  dissolve: (amount: number, tint: number) => void
671}
672
673const hash = (a: number, b: number, c = 0): number => {
674  let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 1274126177)
675  h = Math.imul(h ^ (h >>> 13), 1274126177)
676
677  return (h ^ (h >>> 16)) >>> 0
678}
679
680function brushOf(body: Canvas, lights: Canvas, strokes: Canvas, cx: number, ground: number, scale: number, edge: number | null, stamps: Stamp[]): Brush {
681  const isText = edge === null
682  const to = ([x, y]: V): V => [Math.round(cx - x * scale), Math.round(ground - y * scale)]
683  const set = (canvas: Canvas, x: number, y: number, color: number) => {
684    if (x >= 0 && y >= 0 && x < canvas.width && y < canvas.height) canvas.data[y * canvas.width + x] = color
685  }
686  /** A shape on the body layer: where `isIn` says, a ring of outline round it, a rim of light up and to the left, of shadow down and to the right. */
687  const shape = (x0: number, y0: number, x1: number, y1: number, isIn: (x: number, y: number) => boolean, [base, light, shade, line]: Mat) => {
688    const [left, top, right, bottom] = [Math.max(-1, x0 - 1), Math.max(-1, y0 - 1), Math.min(body.width, x1 + 1), Math.min(body.height, y1 + 1)]
689    const [w, h] = [right - left + 1, bottom - top + 1]
690    if (w <= 0 || h <= 0) return
691    const mask = new Uint8Array(w * h)
692    for (let y = top; y <= bottom; y++) for (let x = left; x <= right; x++) if (x >= x0 && x <= x1 && y >= y0 && y <= y1 && isIn(x, y)) mask[(y - top) * w + x - left] = 1
693    const on = (x: number, y: number) => x >= left && x <= right && y >= top && y <= bottom && mask[(y - top) * w + x - left] === 1
694    for (let y = top; y <= bottom; y++) {
695      for (let x = left; x <= right; x++) {
696        if (on(x, y)) set(body, x, y, isText ? base : !on(x - 1, y) || !on(x, y - 1) ? light : !on(x + 1, y) || !on(x, y + 1) ? shade : base)
697        else if (!isText && (on(x - 1, y) || on(x + 1, y) || on(x, y - 1) || on(x, y + 1))) set(body, x, y, line ?? edge)
698      }
699    }
700  }
701  const plot = (canvas: Canvas, a: V, b: V, color: number) => {
702    const steps = Math.max(1, Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))
703    for (let i = 0; i <= steps; i++) set(canvas, Math.round(lerp(a[0], b[0], i / steps)), Math.round(lerp(a[1], b[1], i / steps)), color)
704  }
705  const inside = (points: readonly V[], x: number, y: number) => {
706    let isIn = false
707    for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
708      const [a, b] = [points[i]!, points[j]!]
709      if (a[1] > y !== b[1] > y && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) isIn = !isIn
710    }
711
712    return isIn
713  }
714  const boxOf = (points: readonly V[], grow: number) => [
715    Math.floor(Math.min(...points.map(p => p[0])) - grow), Math.floor(Math.min(...points.map(p => p[1])) - grow),
716    Math.ceil(Math.max(...points.map(p => p[0])) + grow), Math.ceil(Math.max(...points.map(p => p[1])) + grow),
717  ] as const
718
719  return {
720    isText,
721    cap: (a, b, radius, mat) => {
722      const [p, q] = [to(a), to(b)]
723      const r = Math.max(0.5, radius * scale)
724      const [dx, dy] = [q[0] - p[0], q[1] - p[1]]
725      const length = dx * dx + dy * dy
726      const [x0, y0, x1, y1] = boxOf([p, q], r)
727      shape(x0, y0, x1, y1, (x, y) => {
728        const u = length === 0 ? 0 : clamp(((x - p[0]) * dx + (y - p[1]) * dy) / length, 0, 1)
729
730        return Math.hypot(x - p[0] - u * dx, y - p[1] - u * dy) <= r + 0.05
731      }, mat)
732    },
733    poly: (points, mat) => {
734      const at = points.map(to)
735      const [x0, y0, x1, y1] = boxOf(at, 0)
736      shape(x0, y0, x1, y1, (x, y) => inside(at, x, y) || at.some(p => p[0] === x && p[1] === y), mat)
737    },
738    line: (a, b, color) => plot(strokes, to(a), to(b), color),
739    fan: (points, color, isFaint = false) => {
740      const at = points.map(to)
741      const [x0, y0, x1, y1] = boxOf(at, 0)
742      const canvas = isFaint ? strokes : lights
743      const put = (x: number, y: number) => {
744        if (!isFaint || ((x + y) & 1) === 0) set(canvas, x, y, color)
745      }
746      for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (inside(at, x + 0.01, y + 0.01)) put(x, y)
747      if (!isFaint) at.forEach((p, i) => plot(lights, p, at[(i + 1) % at.length]!, color))
748    },
749    dot: (a, color) => set(strokes, to(a)[0], to(a)[1], color),
750    bar: (a, b, color) => {
751      const [p, q] = [to(a), to(b)]
752      const steps = Math.max(1, Math.abs(q[0] - p[0]), Math.abs(q[1] - p[1]))
753      for (let i = 0; i <= steps; i++) {
754        const [x, y] = [Math.round(lerp(p[0], q[0], i / steps)), Math.round(lerp(p[1], q[1], i / steps)) & ~1]
755        set(lights, x, y, color)
756        set(lights, x, y + 1, color)
757      }
758    },
759    write: (a, text, color) => {
760      const [x, y] = to(a)
761      ;[...text].forEach((glyph, i) => {
762        if (glyph === ' ') return
763        if (isText) stamps.push({ x: (x >> 1) + i, y: y >> 2, glyph, fg: color })
764        else set(strokes, x + i * 2, y, color)
765      })
766    },
767    band: (a, b, color) => {
768      if (!isText) return
769      const [p, q] = [to(a), to(b)]
770      const glyph = mod(p[1], 4) < 2 ? '▀' : '▄'
771      for (let x = Math.min(p[0], q[0]) >> 1; x <= Math.max(p[0], q[0]) >> 1; x++) stamps.push({ x, y: p[1] >> 2, glyph, fg: color, band: true })
772    },
773    face: (a, cells, isMirrored, isUpended) => {
774      if (!isText) return
775      const [x, y] = to(a)
776      const rows = isUpended ? [...cells].reverse() : cells
777      rows.forEach((row, j) => {
778        const line = isMirrored ? [...row].reverse() : row
779        line.forEach(([written, fg, bg], i) => {
780          if (written === ' ') return
781          const turned = isUpended ? UPENDED[written] ?? written : written
782          stamps.push({ x: (x >> 1) - (line.length >> 1) + i, y: (y >> 2) - 1 + j, glyph: isMirrored ? MIRRORED[turned] ?? turned : turned, fg, bg: bg ?? NONE })
783        })
784      })
785    },
786    dissolve: (amount, tint) => {
787      if (amount <= 0) return
788      for (let y = 0; y < body.height; y++) {
789        const rank = hash(y >> 1, 7) % 100
790        if (rank >= amount * 104 + 20) continue
791        for (let x = 0; x < body.width; x++) {
792          const at = y * body.width + x
793          for (const canvas of [body, lights, strokes]) if (canvas.data[at] !== CLEAR) canvas.data[at] = rank < amount * 104 ? CLEAR : tint
794        }
795      }
796    },
797  }
798}
799
800// --- the two of them ---------------------------------------------------------------
801
802const WHITE = 0xffffff
803// The light blue, its pale step, and a deeper one for what glows less: a hologram's far rows, a blade seen through its scabbard.
804const CYAN = 0x5ef6ff
805const CYAN_LIT = 0xc2fbff
806const CYAN_DEEP = 0x2fb8c4
807
808/**
809 * A character's colours. Kurenai's are reds, a pale red, near-black and the light blue, and nothing
810 * else: a near-black suit whose leading edge catches a red rim of light, red plates over it, a red
811 * cloak lined in dark red. Magpie's are the HUD's yellow and its dark, white hair and the light blue.
812 */
813type Inks = {
814  edge: number
815  alert: number
816  shadow: number
817  pale: number
818  /** What is worn next to the skin, and the same on the far side of the body; `dim` stands for it in text, where no rim of light tells a dark limb from the dark behind it. */
819  suit: Mat; back: Mat; dim: Mat
820  /** Armour (hers: the jacket and the boots), cloth, a cloak's outside and its lining, a grip. */
821  plate: Mat; cloth: Mat; cape: Mat; lining: Mat; grip: Mat
822  hair: Mat; skin: Mat; steel: Mat
823  /** The brightest point of light: hers is white; Kurenai has no white, hers is the light blue's pale step. */
824  glint: number
825}
826
827const INKS: Readonly<Record<Who, Inks>> = {
828  ninja: {
829    edge: 0x0a0204, alert: 0xff1f47, shadow: 0x34101a, pale: 0xffd2cb,
830    // (Dark, but never as dark as the ground behind her: a leg is a mid red with a rim of light, near-black only in its own shadow.)
831    suit: [0x55141b, 0x8a1f2a, 0x2e0c12], back: [0x3a0f16, 0x6a1a22, 0x24090d], dim: [0x55141b, 0x8a232a, 0x2e0c12],
832    plate: [0xc0303a, 0xff6158, 0x7a1c26], cloth: [0xe04848, 0xff8a80, 0x8a232a], cape: [0x8a1f2a, 0xc8343c, 0x55141b], lining: [0x3a0f16, 0x55141b, 0x2a0a10],
833    grip: [0x55141b, 0x8a232a, 0x2e0c12], hair: [0x8a1f2a, 0xc8343c, 0x55141b], skin: [0x1c070b, 0x3a0f16, 0x0a0204], steel: [CYAN_LIT, CYAN_LIT, CYAN],
834    glint: CYAN_LIT,
835  },
836  thief: {
837    edge: 0x121100, alert: 0xff3d3d, shadow: 0x2a2802, pale: 0xfffbb5,
838    // (A fitted suit of olive, a cropped jacket and boots of the HUD's yellow over it, a dark harness at the waist: three tones, so she is not one yellow shape.)
839    suit: [0x8a8206, 0xc8bc08, 0x5a5504], back: [0x5a5504, 0x7d7605, 0x343104], dim: [0x8a8206, 0xc8bc08, 0x5a5504],
840    plate: [0xfcee0a, 0xfffbb5, 0xb0a707], cloth: [0xc8bc08, 0xfcee0a, 0x8a8206], cape: [0x343104, 0x5a5504, 0x1c1a00], lining: [0x343104, 0x5a5504, 0x1c1a00],
841    grip: [0x343104, 0x7d7605, 0x1c1a00], hair: [WHITE, WHITE, 0xd6d2b8, WHITE], skin: [0xffe08a, 0xfff0c2, 0xd9b84a], steel: [0xd6d2b8, WHITE, 0x8a8206],
842    glint: WHITE,
843  },
844}
845
846/** How far back the rigs a frame is drawn from go, a step apart: what trails is hung on them. */
847const LAGS = 8
848const LAG_MS = 40
849/** A blade's arc is the ground its tip covered in the last frames, sampled this fine. */
850const SWEEPS = 12
851const SWEEP_MS = 18
852const CODE = '01<>{}[]/=;:+*#$%&0110'
853// Fingers on keys, a frame at a time, each hand to its own beat: 1 is a key struck.
854const TAPS = [[1, 0, 0, 1, 0, 1, 0, 0], [0, 1, 0, 0, 1, 0, 0, 1]] as const
855
856function drawn(g: Brush, who: Who, m: Moment) {
857  const ink = INKS[who]
858  const rigs = Array.from({ length: LAGS }, (_rig, i) => rigOf(who, heroPose(who, m, i * LAG_MS)))
859  const rig = rigs[0]!
860  const { pose } = rig
861  const frame = Math.floor(m.now / FRAME_MS)
862  const isNinja = who === 'ninja'
863  // What sways by itself does it only at rest (in the other moods the body's own motion moves it), coming in and going out with the mood.
864  const blend = m.was === undefined ? 1 : clamp((m.now - (m.since ?? 0)) / BLEND_MS, 0, 1)
865  const still = (mood: Mood | undefined) => (mood === 'idle' ? 1 : mood === 'worried' ? 0.7 : 0)
866  const calm = still(m.mood) * blend + still(m.was?.mood) * (1 - blend)
867  const sway = (phase: number, turns = 1) => calm * Math.sin((2 * Math.PI * turns * m.now) / BREATH_MS + phase)
868  // Under an alarm the light blue of what she holds and reads goes to the alert's red and back, four frames of each: a pulse, not a strobe. The visor stays lit, so the face is never lost.
869  // (Three frames of the alert, one of the pale between, three of the light blue, one of the pale: it goes over, it does not blink.)
870  const isAlarmed = pose.alarm > 0.5
871  const isAlert = isAlarmed && frame % 8 < 3
872  const energy = isAlert ? ink.alert : isAlarmed && frame % 4 === 3 ? ink.pale : CYAN
873  const pulse = 0.5 + 0.5 * Math.sin((2 * Math.PI * m.now) / 1800)
874  const visor: Mat = pose.wink > 0.5 ? ink.suit : pose.glow > 0.4 ? [CYAN_LIT, ink.glint, CYAN] : pulse > 0.72 ? [CYAN_LIT, CYAN_LIT, CYAN] : [CYAN, CYAN_LIT, CYAN]
875  const edge: Mat = energy === CYAN ? [CYAN, CYAN_LIT, CYAN] : [energy, energy, energy]
876  const dark = g.isText ? ink.dim : ink.suit
877  const tip = (r: Rig, hand: V, deg: number, len: number) => add(hand, r.aim(deg, len))
878
879  /** A blade from a hand: in pixels a lit edge with an outline, in text a fine line over the cells. */
880  const blade = (hand: V, deg: number, from: number, len: number) => {
881    const [a, b] = [tip(rig, hand, deg, from), tip(rig, hand, deg, from + len)]
882    if (g.isText) g.bar(a, b, edge[0])
883    else g.cap(a, b, 0.5, edge)
884  }
885  /**
886   * The arc a blade has just cut: the ground its tip covered in the last two frames, along the path
887   * it took, widest at the blade it is still joined to and drawn down to a point at its tail, the
888   * tail going out. It ends where the tip slowed, turned back or was somewhere else altogether.
889   */
890  const sweeps = pose.P === 0 && pose.Q === 0 ? [] : Array.from({ length: SWEEPS + 1 }, (_rig, i) => (i === 0 ? rig : rigOf(who, heroPose(who, m, i * SWEEP_MS))))
891  const arc = (has: (r: Rig) => boolean, hands: (r: Rig) => V, angle: (r: Rig) => number, len: number, slowest: number, longest = SWEEPS) => {
892    const tips = sweeps.map(r => tip(r, hands(r), angle(r), len))
893    const roots = sweeps.map(r => tip(r, hands(r), angle(r), len * 0.7))
894    // How far back the arc goes, and how much of that the tip was fast for: a cut that has just stopped keeps its arc a frame (the first steps may be slow), a blade only carried has none.
895    let [n, fast, last] = [0, 0, 0]
896    while (n < longest && has(sweeps[n + 1]!)) {
897      const [step, was] = [sub(tips[n + 1]!, tips[n]!), n === 0 ? null : sub(tips[n]!, tips[n - 1]!)]
898      const length = Math.hypot(step[0], step[1])
899      if (length > 26 || (length < slowest && (fast > 0 || n >= 5))) break
900      if (was !== null && length > 0.2 && Math.hypot(was[0], was[1]) > 0.2 && (was[0] * step[0] + was[1] * step[1]) / (length * Math.hypot(was[0], was[1])) < 0.55) break
901      n += 1
902      if (length >= slowest) [fast, last] = [fast + 1, n]
903    }
904    n = last
905    if (fast < 3) return
906    // (Never under the floor.)
907    const above = (v: V): V => [v[0], Math.max(0.6, v[1])]
908    const inner = (i: number) => above(between(tips[i]!, roots[i]!, 1 - i / n))
909    for (let i = n - 1; i >= 0; i--) g.fan([above(tips[i]!), inner(i), inner(i + 1), above(tips[i + 1]!)], energy !== CYAN ? energy : i < n / 3 ? CYAN_LIT : CYAN, i >= (2 * n) / 3)
910  }
911  const held = (hand: V, what: number, deg: number, has: (r: Rig) => boolean, hands: (r: Rig) => V, angle: (r: Rig) => number) => {
912    if (what === KATANA) {
913      arc(has, hands, angle, 21, 5.5)
914      g.cap(tip(rig, hand, deg, -3.2), tip(rig, hand, deg, 1.2), 0.8, ink.grip)
915      if (!g.isText) g.cap(add(tip(rig, hand, deg, 1.9), rig.aim(deg + 90, 1.3)), add(tip(rig, hand, deg, 1.9), rig.aim(deg - 90, 1.3)), 0.4, ink.steel)
916      blade(hand, deg, 2.6, 18.4)
917    } else if (what === DAGGER) {
918      arc(has, hands, angle, 10, 3.4, 7)
919      g.cap(tip(rig, hand, deg, -1.5), tip(rig, hand, deg, 1), 0.7, ink.grip)
920      blade(hand, deg, 1.6, 8.4)
921    } else if (what === PISTOL) {
922      // Spun, it leaves a ring of light round the finger it turns on.
923      const spin = Math.abs(rig.pose.p - rigs[1]!.pose.p)
924      if (spin > 20 && spin < 200) for (let a = (frame % 2) * 15; a < 360; a += 30) g.dot(add(hand, turned([5.6, 0], a)), CYAN)
925      g.cap(hand, tip(rig, hand, deg - 105, 3), 0.9, ink.grip)
926      g.cap(tip(rig, hand, deg, -0.6), tip(rig, hand, deg, 6.4), 1.1, ink.steel)
927      g.line(add(tip(rig, hand, deg, 1), rig.aim(deg + 90, 1.2)), add(tip(rig, hand, deg, 6.2), rig.aim(deg + 90, 1.2)), CYAN)
928      // The shot: a star of light at the muzzle.
929      if (pose.glow > 0.4) {
930        const muzzle = tip(rig, hand, deg, 9)
931        for (const a of [0, 45, 90, 135]) g.line(add(muzzle, turned([a % 90 === 0 ? 3.2 : 2, 0], a)), add(muzzle, turned([a % 90 === 0 ? -3.2 : -2, 0], a)), a % 90 === 0 ? CYAN_LIT : CYAN)
932      }
933    }
934  }
935
936  // The floor's shadow, narrower the higher she is.
937  const reach = clamp(9 - (pose.y - 20) * 0.22, 3, 10)
938  if (pose.fade < 0.5) for (let x = -reach; x <= reach; x += 1) if (g.isText ? true : Math.abs(x) % 2 < 1 || Math.abs(x) < reach - 2) g.dot([pose.x + 1 + x, -1.2], ink.shadow)
939
940  /** A point of the body, where it was `lag` rigs ago (no further behind than `most`), a little adrift: what hangs from her is hung on these. */
941  const trailed = (lag: number, x: number, y: number, most: number, drift: V = [0, 0]): V => {
942    const [here, then] = [rig.off(x, y), rigs[lag]!.off(x, y)]
943
944    return add(add(here, capped(sub(then, here), most)), drift)
945  }
946
947  if (isNinja) {
948    // The scarf: a tail from the back of the neck, each point where the neck was a moment ago, sinking as it trails.
949    const [points, sink, thick] = [7, 1.25, 1.7]
950    const tail: V[] = [rig.off(-2.4, TORSO - 0.4)]
951    for (let i = 1; i < points; i++) {
952      const want = add(rigs[Math.min(LAGS - 1, i)]!.off(-2.4, TORSO - 0.4), [-1.7 * i - 0.5, -sink * i + sway(i * 0.8 + 1.3, 2) * 0.15 * i])
953      tail.push(add(tail[i - 1]!, capped(sub(want, tail[i - 1]!), 3.4)))
954    }
955    // A ribbon round that line, wide at the neck and down to a point.
956    const side = (way: number) => tail.map((at, i) => {
957      const along = sub(tail[Math.min(points - 1, i + 1)]!, tail[Math.max(0, i - 1)]!)
958      const wide = (way * thick * (1 - i / (points - 0.5))) / Math.max(0.001, Math.hypot(along[0], along[1]))
959
960      return add(at, [-along[1] * wide, along[0] * wide])
961    })
962    g.poly([...side(1), ...side(-1).reverse()], ink.cloth)
963
964    // The cloak: hung from the shoulders to the knee, its hem where the hips were a moment ago (so it trails a turn and settles after a landing) and swaying a little by itself. Its lining shows under the hem.
965    const hem = (lag: number, x: number, y: number, loose: number): V => trailed(lag, x, y, 8 + 4 * pose.air, [-1.5 * loose * sway(1.1 + y * 0.2), 0.5 * loose * sway(2.3 + x)])
966    // Off the ground it is gathered up behind her shoulders: a short wedge that trails the turn, not a slab that turns with her.
967    const gathered = (x: number, y: number, ax: number, ay: number): [number, number] => [lerp(x, ax, pose.air), lerp(y, ay, pose.air)]
968    const [top, nape] = [rig.off(1.2, TORSO - 0.4), rig.off(-3.2, TORSO - 0.4)]
969    const [bulge, low, front, inner] = [hem(2, ...gathered(-7.2, 8.5, -6, 12), 0.4), hem(6, ...gathered(-11, -6.5, -16, 9), 1), hem(4, ...gathered(-2.6, -9.5, -8, 6.5), 0.8), hem(2, ...gathered(-0.6, 1, -1.5, 8), 0.3)]
970    g.poly([nape, bulge, add(low, [0.6, -1.8]), add(front, [1.8, -1.6]), inner], ink.lining)
971    g.poly([top, nape, bulge, low, front, inner, rig.off(-0.4, 10)], ink.cape)
972  }
973
974  /** A plate on a limb: over its leading side, from `from` to `till` of the way along it. */
975  const plated = (a: V, b: V, from: number, till: number, radius: number, mat: Mat) => {
976    const along = sub(b, a)
977    const length = Math.max(0.001, Math.hypot(along[0], along[1]))
978    const out: V = mul(along[1] < 0 ? [-along[1], along[0]] : [along[1], -along[0]], 0.45 / length)
979    g.cap(add(between(a, b, from), out), add(between(a, b, till), out), radius, mat)
980  }
981
982  // The far arm and what its hand holds, behind the body.
983  if (isNinja) {
984    g.cap(rig.shoulderF, rig.shoulderF, 2.1, ink.back)
985    g.cap(rig.shoulderF, rig.elbowF, 1.5, ink.back)
986    g.cap(rig.elbowF, rig.handF, 1.4, ink.back)
987    plated(rig.elbowF, rig.handF, 0.25, 0.8, 1.3, ink.lining)
988  } else {
989    g.cap(rig.shoulderF, rig.elbowF, 1.3, ink.cloth)
990    g.cap(rig.elbowF, rig.handF, 1, ink.back)
991    // (Her deck: a cuff on the forearm, a line of light along it.)
992    g.cap(between(rig.elbowF, rig.handF, 0.3), between(rig.elbowF, rig.handF, 0.8), 1.5, ink.cape)
993    if (!g.isText) g.line(between(rig.elbowF, rig.handF, 0.3), between(rig.elbowF, rig.handF, 0.8), energy)
994    g.cap(rig.handF, rig.handF, 1, ink.skin)
995  }
996  held(rig.handF, pose.Q, pose.q, r => r.pose.Q === pose.Q, r => r.handF, r => r.pose.q)
997
998  // The far leg.
999  if (isNinja) {
1000    g.cap(rig.hipF, rig.kneeF, 1.9, ink.back)
1001    g.cap(rig.kneeF, rig.ankleF, 1.6, ink.back)
1002    plated(rig.kneeF, rig.ankleF, 0.2, 0.9, 1.4, ink.lining)
1003    g.cap(rig.ankleF, rig.toeF, 1.3, ink.back)
1004  } else {
1005    g.cap(rig.hipF, rig.kneeF, 1.7, ink.back)
1006    g.cap(rig.kneeF, rig.ankleF, 1.15, ink.back)
1007    g.cap(between(rig.kneeF, rig.ankleF, 0.5), rig.ankleF, 1.5, ink.cloth)
1008    g.cap(rig.ankleF, rig.toeF, 1.15, ink.cloth)
1009  }
1010
1011  // The torso.
1012  const spine = (u: number) => between(rig.hip, rig.neck, u)
1013  if (isNinja) {
1014    // A dark waist, a red chest plate over it with a point of the light blue at its heart, a pale belt.
1015    g.cap(rig.hip, spine(0.5), 2.7, dark)
1016    g.cap(spine(0.6), spine(0.86), 3.3, ink.plate)
1017    g.cap(rig.off(-2.2, 2), rig.off(2.2, 2), 0.8, ink.cloth)
1018    g.dot(rig.off(2.2, 9.8), CYAN)
1019  } else {
1020    // Hips, a narrow dark waist in its harness, a belt with a pouch at the back; over it a cropped jacket, square at the shoulders and cut off above the waist, its collar up, a line of the light blue down its front.
1021    g.cap(rig.off(-1, 0.3), rig.off(1.2, 0.3), 2.5, ink.suit)
1022    g.cap(spine(0.26), spine(0.5), 1.6, ink.cape)
1023    g.cap(rig.off(-3.5, 2.4), rig.off(-3.5, 3.4), 1.1, ink.cape)
1024    g.cap(rig.off(-2.4, 2.3), rig.off(2.5, 2.3), 0.5, ink.plate)
1025    g.poly([rig.off(-3.5, 13.4), rig.off(3.1, 13.6), rig.off(3.6, 9.6), rig.off(1.8, 7.9), rig.off(-2.9, 8.1)], ink.plate)
1026    g.cap(rig.off(-1.8, 14.3), rig.off(0.9, 14.7), 0.9, ink.cloth)
1027    if (!g.isText) g.line(rig.off(2.5, 12.6), rig.off(2.6, 9.4), CYAN)
1028  }
1029
1030  // The near leg.
1031  if (isNinja) {
1032    g.cap(rig.hipN, rig.kneeN, 2.1, dark)
1033    g.cap(rig.kneeN, rig.ankleN, 1.7, dark)
1034    plated(rig.kneeN, rig.ankleN, 0.25, 0.9, 1.5, ink.plate)
1035    g.cap(rig.kneeN, rig.kneeN, 1.5, ink.plate)
1036    g.cap(rig.ankleN, rig.toeN, 1.4, dark)
1037    // The scabbard at the hip, dark, its mouth forward. While the katana is in, the blade's light shows faintly along it and the hilt stands
1038    // out of it, wrapped pale; drawn, the scabbard is dark but for a point at its mouth; the frame the blade goes home, it flares its whole length.
1039    const isHome = pose.P !== KATANA
1040    const isJustHome = isHome && (rigs[2]!.pose.P === KATANA || rigs[3]!.pose.P === KATANA)
1041    g.cap(rig.off(4.4, 2.6), rig.off(-10.5, -3.4), g.isText ? 1.3 : 0.9, ink.grip)
1042    if (g.isText) g.bar(rig.off(4.4, 2.6), rig.off(isJustHome ? -9.8 : 4.4, isJustHome ? -2.4 : 2.6), isJustHome ? CYAN_LIT : CYAN)
1043    else if (isHome && (isJustHome || pose.air < 0.5)) g.line(rig.off(-3.4, 0.3), rig.off(-9.8, -2.4), isJustHome ? CYAN_LIT : CYAN_DEEP)
1044    if (!g.isText && isJustHome) g.line(rig.off(3, 2.1), rig.off(-3.4, 0.3), CYAN_LIT)
1045    if (!g.isText) g.dot(rig.off(4.6, 2.6), isHome ? CYAN : CYAN_DEEP)
1046    if (isHome) {
1047      g.cap(rig.off(5, 2.9), rig.off(9.8, 4.9), 0.8, ink.cloth)
1048      if (!g.isText) g.cap(rig.off(4.9, 1.5), rig.off(4.1, 4.1), 0.4, ink.steel)
1049    }
1050  } else {
1051    // A thigh that narrows to the knee, a slim shin, a tall boot; one pale stripe down the side of the leg.
1052    g.cap(rig.hipN, between(rig.hipN, rig.kneeN, 0.5), 2.1, ink.suit)
1053    g.cap(between(rig.hipN, rig.kneeN, 0.4), rig.kneeN, 1.6, ink.suit)
1054    g.cap(rig.kneeN, rig.ankleN, 1.25, ink.suit)
1055    if (!g.isText) g.line(between(rig.hipN, rig.kneeN, 0.12), between(rig.hipN, rig.kneeN, 0.92), ink.steel[0])
1056    // (Her dagger's sheath, strapped behind the thigh: its hilt shows while the dagger is in.)
1057    const thigh = sub(rig.kneeN, rig.hipN)
1058    const behind = mul(thigh[1] < 0 ? [thigh[1], -thigh[0]] : [-thigh[1], thigh[0]], 2.1 / Math.max(0.001, Math.hypot(thigh[0], thigh[1])))
1059    const sheath = (u: number) => add(between(rig.hipN, rig.kneeN, u), behind)
1060    g.cap(sheath(0.34), sheath(0.82), 0.8, ink.cape)
1061    if (!(pose.P === DAGGER || pose.Q === DAGGER || pose.O === DAGGER)) g.cap(sheath(0.14), sheath(0.26), 0.5, ink.steel)
1062    g.cap(between(rig.kneeN, rig.ankleN, 0.45), rig.ankleN, 1.7, ink.plate)
1063    g.cap(rig.ankleN, rig.toeN, 1.3, ink.plate)
1064  }
1065
1066  // The head.
1067  const side = pose.turn < -0.2 ? -1 : 1
1068  // (Whether it is upside down: past a quarter of a turn over.)
1069  const upended = Math.cos(pose.spin * RAD) < 0
1070  // In text a head is not converted from its drawing, which gives another head each pose: it is the same few cells every frame, laid where the head is.
1071  const isStamped = g.isText && pose.fade < 0.3
1072  if (isNinja) {
1073    // A hood: its back falls to the cloak, a short peak over the brow; in its opening a dark faceplate, and the visor's slit of light across it.
1074    g.cap(rig.neck, rig.head, 1.4, dark)
1075    g.poly([rig.at(-1.5 * side, 3.8), rig.at(-5.6 * side, 0.6), trailed(2, -4.4, TORSO - 1.4, 3), rig.off(-0.5, TORSO - 1)], ink.hair)
1076    const [middle, half] = [1.9 * pose.turn, 3 - Math.abs(pose.turn)]
1077    const [from, till] = [clamp(middle - half, -3.6, 3.6), clamp(middle + half, -3.6, 3.6)]
1078    if (!isStamped) {
1079      g.cap(rig.head, rig.head, 4.4, ink.hair)
1080      g.poly([rig.at(1.6 * side, 4.3), rig.at(5 * side, 2.9), rig.at(3.4 * side, 1.6)], ink.hair)
1081      g.poly([rig.at(from, 1.9), rig.at(till, 1.9), rig.at(till + 0.3, -1.4), rig.at(till - 0.9, -3.8), rig.at(from + 0.6, -3.8), rig.at(from - 0.2, -1.4)], ink.skin)
1082      if (g.isText) g.band(rig.at(from, 0.5), rig.at(till, 0.5), visor[0])
1083      else g.cap(rig.at(from + 0.7, 0.4), rig.at(till - 0.4, 0.4), 0.7, visor)
1084    } else {
1085      // One head whatever she does: a hood four cells wide, a dark face under it, the visor a bar of light two cells long across the face.
1086      const [hood, face, lit] = [ink.hair[0], ink.skin[0], visor[0]]
1087      const isFacing = pose.turn > -0.2 && pose.turn < 0.3
1088      g.face(rig.head, [
1089        [['▗', hood], ['█', hood], ['█', hood], ['▖', hood]],
1090        isFacing ? [['▐', hood], ['▀', lit, face], ['▀', lit, face], ['▌', hood]] : [['▀', lit, face], ['▀', lit, face], ['█', hood], ['▌', hood]],
1091      ], side < 0 !== upended, upended)
1092    }
1093  } else {
1094    // A bob: white hair cut straight at the chin, a fringe straight across the brow, the neck bare under it.
1095    g.cap(rig.neck, between(rig.neck, rig.head, 0.55), 1, ink.skin)
1096    // The hair is one mass, the same in every pose: only the hem of its panels lags what the head does, a pixel or two, and settles after.
1097    const hem = (x: number, y: number, lag: number): V => add(rig.at(x, y), add(capped(mul(sub(rigs[lag]!.at(x, y), rig.at(x, y)), 0.6), 1.3), [sway(1.7 + x * 0.3) * 0.4, 0]))
1098    const middle = 1.7 * pose.turn
1099    const [from, till] = [clamp(middle - 2.3, -3.5, 3.5), clamp(middle + 2.3, -3.5, 3.5)]
1100    if (isStamped) {
1101      // One head whatever she does, four cells by two (a cell is twice as tall as it is wide): the crown and the fringe straight across under it;
1102      // then the visor over the jaw, and behind them the bob's panel, down to the chin and cut off flat there.
1103      const [hair, skin, lit] = [ink.hair[0], ink.skin[0], visor[0]]
1104      const isFacing = pose.turn > -0.2 && pose.turn < 0.3
1105      g.face(rig.head, [
1106        [['▟', hair], ['█', hair], ['█', hair], ['▙', hair]],
1107        isFacing ? [['▐', hair], ['▀', lit, skin], ['▀', lit, skin], ['▌', hair]] : [['▀', lit, skin], ['▀', lit, skin], ['█', hair], ['▌', hair]],
1108      ], side < 0 !== upended, upended)
1109    } else {
1110    // The crown, and the panel that falls straight from it behind the face and is cut off flat at the chin.
1111    g.cap(rig.at(-0.2, 0.9), rig.at(-0.2, 0.9), 3.2, ink.hair)
1112    if (side > 0) g.poly([rig.at(-3.4, 1.4), rig.at(from, 1.4), hem(from, -2.9, 2), hem(-3.6, -2.9, 4)], ink.hair)
1113    else g.poly([rig.at(3.4, 1.4), rig.at(till, 1.4), hem(till, -2.9, 2), hem(3.6, -2.9, 4)], ink.hair)
1114    // The face: one block of skin, the jaw cut in under it, a mouth.
1115    g.poly([rig.at(from, 0.9), rig.at(till, 0.9), rig.at(till, -2.1), rig.at(till - 0.9, -3.3), rig.at(from + 0.6, -3.3), rig.at(from, -2.4)], ink.skin)
1116    // The fringe, straight across the brow, and the lock that frames the face on its other side (unless she is seen from the side).
1117    g.poly([rig.at(from + 0.2, 2.2), rig.at(till - 0.1, 2.2), rig.at(till - 0.1, 1.5), rig.at(from + 0.2, 1.5)], ink.hair)
1118    if (Math.abs(pose.turn) < 0.75) g.poly(side > 0 ? [rig.at(till + 0.8, 1), rig.at(till + 1, 1), hem(till + 1, -1.6, 3), hem(till + 0.8, -1.6, 3)] : [rig.at(from - 0.8, 1), rig.at(from - 1, 1), hem(from - 1, -1.6, 3), hem(from - 0.8, -1.6, 3)], ink.hair)
1119    if (!g.isText) {
1120      // Under the fringe its line of shadow, then the visor, two pixels of light with nothing between it and the skin; a mouth.
1121      const row = (y: number, color: number) => g.line(rig.at(from + 0.3, y), rig.at(till - 0.2, y), color)
1122      row(0.55, ink.hair[2])
1123      // (Her visor's own pulse is too pale to read at two pixels: it is the light blue, paler only when it flares.)
1124      const lit = pose.wink > 0.5 ? ink.cape[0] : pose.glow > 0.4 ? CYAN_LIT : CYAN
1125      row(-0.2, lit)
1126      row(-0.95, lit)
1127      g.dot(rig.at(side > 0 ? till - 1.1 : from + 1.1, -2.5), ink.skin[2])
1128    } else g.band(rig.at(from, -0.2), rig.at(till, -0.2), visor[0])
1129    // (Her earpiece, a point of light where the hair parts.)
1130    g.dot(side > 0 ? rig.at(from - 0.6, -1.2) : rig.at(till + 0.6, -1.2), energy)
1131    }
1132  }
1133
1134  // The near arm, over everything of the body; a hand at keys strikes them to its own beat.
1135  const beat = Math.floor((m.clock ?? m.now) / FRAME_MS)
1136  const tap = (hand: 0 | 1): V => [0, pose.type > 0.4 ? (TAPS[hand][beat % 8] === 1 ? -1.1 : 1) : 0]
1137  if (isNinja) {
1138    g.cap(rig.shoulder, rig.elbowN, 1.7, dark)
1139    g.cap(rig.shoulder, rig.shoulder, 2.4, ink.plate)
1140    g.cap(rig.elbowN, rig.handN, 1.5, dark)
1141    plated(rig.elbowN, rig.handN, 0.3, 0.8, 1.4, ink.plate)
1142    g.cap(rig.handN, rig.handN, 1.2, dark)
1143  } else {
1144    g.cap(rig.shoulder, rig.shoulder, 1.8, ink.plate)
1145    g.cap(rig.shoulder, rig.elbowN, 1.35, ink.plate)
1146    g.cap(rig.elbowN, add(rig.handN, tap(0)), 1, ink.suit)
1147    g.cap(add(rig.handN, tap(0)), add(rig.handN, tap(0)), 1, ink.skin)
1148    // (The far hand again, over the body, while it types: both hands show at the keys.)
1149    if (pose.type > 0.4 && pose.holo > 0.75) g.cap(add(rig.handF, tap(1)), add(rig.handF, tap(1)), 1, ink.skin)
1150  }
1151
1152  held(rig.handN, pose.P, pose.p, r => r.pose.P === pose.P, r => r.handN, r => r.pose.p)
1153
1154  // What is left of her while she is gone, and of what she holds: scan lines, in the light blue.
1155  g.dissolve(pose.fade, CYAN)
1156  if (pose.fade > 0.15) {
1157    for (let i = 0; i < Math.round(5 * pose.fade); i++) {
1158      const y = pose.y - 9 + i * 5.5
1159      const from = pose.x - 12 + (hash(i, frame, 11) % 10)
1160      g.line([from, y], [from + 8 + (hash(i, frame, 13) % 14), y], i % 2 === 0 ? CYAN : CYAN_LIT)
1161    }
1162  }
1163
1164  // The line: from the far hand up to where it bit, out of the frame.
1165  if (pose.rope > 0.02) {
1166    const hook = between(rig.handF, [27, 96], pose.rope)
1167    g.line(rig.handF, hook, energy)
1168    g.dot(add(hook, [0.8, 0.6]), CYAN_LIT)
1169  }
1170
1171  // What is in the air.
1172  const flying: V = pose.O === SHARD_HELD ? add(rig.handN, [0.5, 2.4]) : [pose.ox, pose.oy]
1173  if (pose.O === SHURIKEN) {
1174    // A star that turns an eighth a frame (a cross, then a saltire), a tail of light behind it going out.
1175    g.fan([add(flying, [-3.4, 0.5]), add(flying, [-9.5, 0]), add(flying, [-3.4, -0.5])], CYAN)
1176    g.fan([add(flying, [-9.5, 0.5]), add(flying, [-15, 0]), add(flying, [-9.5, -0.5])], CYAN, true)
1177    for (const quarter of [0, 90]) g.line(add(flying, turned([3, 0], pose.oa + quarter)), add(flying, turned([-3, 0], pose.oa + quarter)), ink.pale)
1178    g.dot(flying, CYAN)
1179  } else if (pose.O === DAGGER) {
1180    const along = turned([0, 1], pose.oa)
1181    g.line(add(flying, mul(along, -3)), add(flying, mul(along, -0.5)), ink.cloth[0])
1182    g.line(add(flying, mul(along, 0.5)), add(flying, mul(along, 6)), CYAN)
1183  } else if (pose.O === SHARD || pose.O === SHARD_HELD) {
1184    const [long, wide] = [turned([0, 2.6], pose.oa), turned([1.5, 0], pose.oa)]
1185    g.fan([add(flying, long), add(flying, wide), sub(flying, long), sub(flying, wide)], CYAN)
1186    g.dot(flying, CYAN_LIT)
1187    if ((frame & 3) === 0) g.dot(add(flying, [2.6, 2.6]), ink.glint)
1188  }
1189
1190  // The hologram: a small read-out over her forearm that opens into a keyboard hung tilted at her waist, and a screen ahead of her, clear of her head, code running up it.
1191  if (pose.holo > 0.05) {
1192    const open = clamp((pose.holo - 0.5) / 0.5, 0, 1)
1193    const middle = between(add(rig.handF, [1, 3.6]), [pose.x + 13.5, pose.y + 4.6], EASES.io(open))
1194    const half = lerp(2.6, 6.5, open)
1195    const [lit, dim] = isAlert ? [ink.alert, ink.shadow] : [CYAN, CYAN_DEEP]
1196    const struck = [rig.handN, rig.handF].map((hand, n) => (pose.type > 0.4 && TAPS[n as 0 | 1][beat % 8] === 1 ? hand[0] : NaN))
1197    // Three rows of keys, each further and higher than the one before: a plane seen from its side. A struck key flares.
1198    for (let j = 0; j < 3; j++) {
1199      const y = middle[1] + j * lerp(1.1, 1.5, open)
1200      for (let x = -half; x <= half; x += g.isText ? 1.25 : 1.6) {
avatar/hooks/reel.ts 89 lines
1import type { Form } from './look'
2import { FORMS } from './look'
3
4/** The two worlds a cyber theme can be set in, and the eight characters' places: a slot is a world and a model's form. */
5export type World = 'ghostrunner' | 'edgerunners'
6export type Slot = `${World}-${Form}`
7export const WORLDS: readonly World[] = ['ghostrunner', 'edgerunners']
8export const slotOf = (world: World, form: Form): Slot => `${world}-${form}`
9export const SLOTS: readonly Slot[] = WORLDS.flatMap(world => FORMS.map(form => slotOf(world, form)))
10
11/** Who each slot is: the name its footage plays under where its manifest gives none it can use (never the mask's: the mask is not who is on screen). */
12export const CAST: Readonly<Record<Slot, string>> = {
13  'ghostrunner-opus': 'JACK', 'ghostrunner-sonnet': 'HEL', 'ghostrunner-haiku': 'MITRA', 'ghostrunner-fable': 'MARA',
14  'edgerunners-opus': 'LUCY', 'edgerunners-sonnet': 'DAVID', 'edgerunners-haiku': 'REBECCA', 'edgerunners-fable': 'ADAM SMASHER',
15}
16
17/** Every picture of the camera feed is this shape (400 by 340), so its box is one size whoever is in it. */
18export const FEED_SHAPE = 400 / 340
19
20const SPOKEN = ['idle', 'working', 'happy', 'worried'] as const
21
22/**
23 * Footage of a character, cut into loops: `<slot>/<clip>/NNN.png`, and which loops play in which mood.
24 * `name` is the character's, and `speech` what its banner says per mood, where the manifest brings them.
25 */
26export type Reel = {
27  name?: string
28  speech?: Readonly<Partial<Record<(typeof SPOKEN)[number], string>>>
29  clips: Readonly<Record<string, { frames: number; ms: number }>>
30  moods: Readonly<Record<string, readonly string[]>>
31}
32
33/** A manifest's words as plain text on one line, `max` characters at the most: Latin letters, digits and common punctuation; no control or direction characters. */
34const plain = (value: unknown, max: number): string =>
35  typeof value === 'string' ? value.replace(/[^\x20-\x7e\u00a0-\u024f\u2010-\u2027]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max).trim() : ''
36
37/** A loop stays on screen about this long before the next one of its mood takes over. */
38const HOLD_MS = 7000
39
40/** A manifest as read from disk, or null where it is not one: clips with frames, only moods that name them, and idle loops among them (what every other mood falls back to). */
41export function reelOf(text: string): Reel | null {
42  try {
43    const raw = JSON.parse(text) as { name?: unknown; speech?: unknown; clips?: Record<string, { frames?: unknown; ms?: unknown }>; moods?: Record<string, unknown> }
44    const clips: Record<string, { frames: number; ms: number }> = {}
45    for (const [name, clip] of Object.entries(raw.clips ?? {})) {
46      const frames = Number(clip?.frames)
47      const ms = Number(clip?.ms)
48      if (/^[a-z0-9-]+$/.test(name) && Number.isInteger(frames) && frames > 0 && frames < 1000) clips[name] = { frames, ms: ms >= 30 && ms <= 1000 ? ms : 90 }
49    }
50    const moods: Record<string, string[]> = {}
51    for (const [mood, names] of Object.entries(raw.moods ?? {})) {
52      const known = Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string' && Object.hasOwn(clips, name)) : []
53      if (known.length > 0) moods[mood] = known
54    }
55
56    if (!Object.hasOwn(moods, 'idle')) return null
57    const name = plain(raw.name, 24)
58    const lines = typeof raw.speech === 'object' && raw.speech !== null ? (raw.speech as Record<string, unknown>) : {}
59    const speech = Object.fromEntries(SPOKEN.map(mood => [mood, plain(Object.hasOwn(lines, mood) ? lines[mood] : '', 60)] as const).filter(([, line]) => line !== ''))
60
61    return { ...(name === '' ? {} : { name }), ...(Object.keys(speech).length === 0 ? {} : { speech }), clips, moods }
62  } catch {
63    return null
64  }
65}
66
67/**
68 * The frame to show `elapsed` ms into a mood: its loops in rotation, each repeated whole until it has
69 * held about HOLD_MS. A mood the reel lacks plays the idle loops (a reel read by `reelOf` has them); null where there are none either.
70 */
71export function frameAt(reel: Reel, mood: string, elapsed: number): { clip: string; frame: number } | null {
72  const names = reel.moods[mood] ?? reel.moods['idle']
73  if (names === undefined) return null
74  const spans = names.map(name => {
75    const { frames, ms } = reel.clips[name]!
76    const once = frames * ms
77
78    return { name, frames, ms, span: once * Math.max(1, Math.round(HOLD_MS / once)) }
79  })
80  const total = spans.reduce((sum, one) => sum + one.span, 0)
81  let at = ((Math.floor(elapsed) % total) + total) % total
82  for (const one of spans) {
83    if (at < one.span) return { clip: one.name, frame: Math.floor(at / one.ms) % one.frames }
84    at -= one.span
85  }
86
87  return null
88}
89
avatar/hooks/families.ts 281 lines
1// The mascot families: which one a theme calls for, and what each brings. A family is one entry of
2// FAMILIES; the pane draws whatever the entry holds, so a new family is a new entry and its art,
3// nothing else. A family is drawn in pixels (sprites, and a stage of minis) or, with a `skin`, in text.
4import type { Canvas } from './canvas'
5import { CLAWD, clawdFrame, isClawdAtRest } from './clawd'
6import type { ClawdMoment } from './clawd'
7import { HERO_NAMES } from './cyber'
8import type { Who } from './cyber'
9import { FORM_SPRITES } from './forms'
10import type { FormSprite } from './forms'
11import { clawdMini, kirbyMini } from './hires'
12import type { MiniSet } from './hires'
13import { NAMES, SKINS, hex } from './hud'
14import type { Skin } from './hud'
15import type { Family, Form, Pixels } from './look'
16import { faceAt } from './mascot'
17import type { Mood } from './mascot'
18import { norm, resolve } from './packs'
19import type { Pack } from './packs'
20import type { World } from './reel'
21import { KIRBY } from './sprite'
22import { TOUGE } from './touge'
23
24export type FamilyName = 'kirby' | 'clawd' | 'ghostrunner' | 'cyberpunk-edgerunners' | 'initial-d'
25
26/** What worries a mascot, in its own words: a context getting full, and a limit (named) almost used up. */
27export type Worry = { full: string; near: (limit: string) => string }
28
29/** What the bubble says per mood, and in which colour: a key of the theme's, or a skin's own. */
30type Speech = Readonly<Record<Mood, readonly [text: string, color: string]>>
31
32/**
33 * A family drawn in pixels:
34 * - `forms`: the main mascot per model, its name and three frames (eyes open, half shut, shut);
35 * - `painted`: optionally, a form's pixels drawn for the mood instead of picked from its frames;
36 * - `mini`: the stage's sprites for an agent's colour slot and its model's form (hires.ts says which poses);
37 * - `blocks`: which of the cell-block stage's two mini sets stands in where the terminal draws no pictures;
38 * - `acted`: optionally, the mascot in true pixels drawn anew for each moment (from its parts, not a frame pushed
39 *   about): a `frame` of `width` by `height` art pixels in the box its sprite has, and whether a moment is one of rest.
40 */
41export type SpriteFamily = {
42  key: FamilyName
43  forms: Readonly<Record<Form, FormSprite>>
44  painted?: (form: Form, mood: Mood, isMouthShut: boolean, isBlink: boolean) => Pixels | undefined
45  mini: (slot: number, form?: Form) => MiniSet
46  blocks: Family
47  speech: Speech
48  acted?: { frame: (form: Form, moment: Acting, width: number, height: number, scale: number, isLight?: boolean) => Canvas; isAtRest: (moment: Acting) => boolean }
49}
50
51/** A moment of a mascot that acts: its mood, the clock and, while the mood is fresh, the one before and since when this one lasts. */
52export type Acting = ClawdMoment
53
54/**
55 * A family drawn in text (hud.ts): its `skin` is the palette the whole pane takes, as a HUD, with
56 * the mask and the agents' blocks in characters; `names` is the mask's name per model, `worry` how it puts what worries it.
57 * `world` is where its characters come from: with a model's form it names the slot whose footage the camera feed plays (reel.ts).
58 */
59export type TextFamily = { key: FamilyName; skin: Skin; world: World; names: Readonly<Record<Form, string>>; speech: Speech; worry: Worry; hero: Hero }
60
61/** A text family's Opus: a whole character (cyber.ts) in place of the mask, with its own name and voice. Drawn where the pane has its rows; the mask elsewhere. */
62export type Hero = { who: Who; name: string; speech: Speech; worry: Worry }
63
64export type FamilyDef = SpriteFamily | TextFamily
65
66const BODY = 0xffa2de
67const DARK = 0x000000
68const MOUTH = 0xb61f36
69const BLUSH = 0xff1784
70const SWEAT = 0x5ac8fa
71const EYE_COLS = [9, 10, 12, 13]
72
73// [row, column, color] over the user's Kirby. Its eyes are rows 4-8 of EYE_COLS,
74// its blush row 8 beside them, its mouth the one pixel at (10, 12).
75const PAINT: Record<Mood, readonly (readonly [number, number, number])[]> = {
76  idle: [],
77  working: [[10, 11, MOUTH], [11, 11, MOUTH], [11, 12, MOUTH]],
78  worried: [
79    [8, 7, BODY], [8, 8, BODY], [8, 14, BODY], [8, 15, BODY],
80    [10, 11, MOUTH], [10, 13, MOUTH],
81    [4, 14, SWEAT], [5, 14, SWEAT],
82  ],
83  happy: [
84    [8, 6, BLUSH], [9, 7, BLUSH], [9, 8, BLUSH], [9, 14, BLUSH], [9, 15, BLUSH], [8, 16, BLUSH],
85    [10, 12, BODY], [10, 11, MOUTH], [10, 13, MOUTH], [11, 12, MOUTH],
86  ],
87}
88
89/** The user's Kirby with the mood's face painted on. */
90function paintedKirby(mood: Mood, isMouthShut: boolean, isBlink: boolean): Pixels {
91  const px = KIRBY.map(row => [...row])
92  const put = (row: number, col: number, color: number) => {
93    const line = px[row]
94    if (line) line[col] = color
95  }
96  if (!isMouthShut) for (const [row, col, color] of PAINT[mood]) put(row, col, color)
97  if (isBlink) {
98    for (const col of EYE_COLS) {
99      for (let row = 4; row <= 8; row++) put(row, col, row === 7 ? DARK : BODY)
100    }
101  }
102
103  return px
104}
105
106const KIRBY_FAMILY: SpriteFamily = {
107  key: 'kirby',
108  forms: { ...FORM_SPRITES, opus: { name: 'Kirby', frames: [KIRBY.map(row => [...row])] } },
109  painted: (form, mood, isMouthShut, isBlink) => (form === 'opus' ? paintedKirby(mood, isMouthShut, isBlink) : undefined),
110  mini: kirbyMini,
111  blocks: 'kirby',
112  speech: { idle: ['poyo!', 'claude'], working: ['thinking…', 'suggestion'], worried: ['', 'error'], happy: ['poyo~ ♥', 'success'] },
113}
114
115const CLAWD_FAMILY: SpriteFamily = {
116  key: 'clawd',
117  forms: CLAWD,
118  mini: clawdMini,
119  blocks: 'clawd',
120  acted: { frame: clawdFrame, isAtRest: isClawdAtRest },
121  speech: { idle: ['hi!', 'claude'], working: ['thinking…', 'suggestion'], worried: ['', 'error'], happy: ['clack clack ♥', 'success'] },
122}
123
124// The cars of a night mountain pass, one per model, seen from the front: their headlights are their eyes. The helpers on stage are Clawd's minis.
125const TOUGE_FAMILY: SpriteFamily = {
126  key: 'initial-d',
127  forms: TOUGE,
128  mini: clawdMini,
129  blocks: 'clawd',
130  speech: { idle: ['tofu loaded. engine warm.', 'claude'], working: ['drifting…', 'suggestion'], worried: ['', 'error'], happy: ['not a drop spilled ♥', 'success'] },
131}
132
133/** The cyber family in one of its two palettes and worlds: Raijin, terse as a netrunner. The drawn ninja is Ghostrunner's Opus, the drawn thief Edgerunners'. */
134const cyber = (key: FamilyName, skin: Skin, who: Who): TextFamily => ({
135  key,
136  skin,
137  world: who === 'ninja' ? 'ghostrunner' : 'edgerunners',
138  hero: {
139    who,
140    name: HERO_NAMES[who],
141    ...(who === 'ninja' ? {
142      // Kurenai: few words, all of them about the blade.
143      speech: { idle: ['BLADE SHEATHED. WATCHING.', hex(skin.primary)], working: ['CUTTING THROUGH ICE...', hex(skin.cyan)], worried: ['', hex(skin.alert)], happy: ['ONE CUT. CLEAN.', hex(skin.value)] },
144      worry: { full: 'NO ROOM LEFT. /clear', near: limit => `${limit.toUpperCase()} LIMIT CLOSING IN` },
145    } : {
146      // Magpie: a thief's shop talk.
147      speech: { idle: ['CASING THE JOINT. QUIET.', hex(skin.primary)], working: ['FINGERS IN THE VAULT...', hex(skin.cyan)], worried: ['', hex(skin.alert)], happy: ['GOT IT. NEVER HERE.', hex(skin.value)] },
148      worry: { full: 'POCKETS FULL. /clear', near: limit => `${limit.toUpperCase()} HEAT RISING. EASY` },
149    }),
150  },
151  names: NAMES,
152  speech: {
153    idle: ['ICE HOLDING. STANDING BY.', hex(skin.primary)],
154    working: ['JACKED IN. TRACING...', hex(skin.cyan)],
155    worried: ['', hex(skin.alert)],
156    happy: ['CLEAN RUN. ZERO TRACE.', hex(skin.value)],
157  },
158  // (Each fits the alarm banner's one line on the narrowest pane.)
159  worry: { full: 'BUFFER NEAR FULL. /clear', near: limit => `${limit.toUpperCase()} QUOTA RUNNING DRY` },
160})
161
162/** The families that exist. One a theme calls for that is not here is played by Kirby. */
163export const FAMILIES: Partial<Record<FamilyName, FamilyDef>> = {
164  kirby: KIRBY_FAMILY, clawd: CLAWD_FAMILY, ghostrunner: cyber('ghostrunner', SKINS.red, 'ninja'), 'cyberpunk-edgerunners': cyber('cyberpunk-edgerunners', SKINS.yellow, 'thief'), 'initial-d': TOUGE_FAMILY,
165}
166
167/**
168 * The family a theme calls for: Kirby for a kirby theme (and while the theme is unknown); the cyber
169 * family in red for a Ghostrunner theme, in yellow for an Edgerunners one; Clawd for the rest.
170 */
171export const wantedOf = (theme: string): FamilyName =>
172  theme === '' || /kirby/i.test(theme) ? 'kirby' : /ghostrunner/i.test(theme) ? 'ghostrunner' : /edgerunners/i.test(theme) ? 'cyberpunk-edgerunners' : 'clawd'
173
174export const familyOf = (theme: string): FamilyDef => FAMILIES[wantedOf(theme)] ?? KIRBY_FAMILY
175
176/** The mascot's name: the model's form of the family. */
177export const nameOf = ({ family, form }: { family: FamilyDef; form: Form }): string => ('skin' in family ? family.names[form] : family.forms[form].name)
178
179/** A mascot in pixels: its family and the model's form. */
180export type Look = { family: SpriteFamily; form: Form }
181
182/** Eyes open, half shut, shut; a painted form may have one frame alone. */
183export const framesOf = ({ family, form }: Look): readonly Pixels[] => family.forms[form].frames
184
185/** The look's pixels for a mood: painted where the family paints the form, else its frame for the eyes (0 open, 1 half shut, 2 shut). */
186export function pixelsOf(look: Look, mood: Mood, eyes: 0 | 1 | 2, isMouthShut: boolean): Pixels {
187  const frames = framesOf(look)
188
189  return look.family.painted?.(look.form, mood, isMouthShut, eyes === 2) ?? frames[Math.min(eyes, frames.length - 1)]!
190}
191
192/**
193 * The look's pixels at `now`, in true pixels: its frame for the face of that moment, exactly as
194 * drawn (the user's Kirby among them: never rounded off), for the pane to enlarge pixel for pixel.
195 */
196export function frameOf(look: Look, mood: Mood, now: number): Pixels {
197  const { eyes, isMouthShut } = faceAt(mood, now)
198
199  return pixelsOf(look, mood, eyes, isMouthShut)
200}
201
202/**
203 * What a box of `maxWidth` by `maxHeight` pixels shows of a look in cell blocks, a pixel of the form
204 * a pixel of the box, never scaled: the form whole where it fits, else the family's plain form,
205 * whole. A model's form is never cut: a body sliced flat under its cheeks reads worse than the plain
206 * one (seen at every height from 24 to 60 rows). Only the plain form, on a box too short even for
207 * it, loses its feet. Returns the look drawn and how many of its rows show.
208 */
209export function fitOf(look: Look, maxWidth: number, maxHeight: number): { look: Look; height: number } {
210  const whole = framesOf(look)[0]!
211  if (whole[0]!.length <= maxWidth && whole.length <= maxHeight) return { look, height: whole.length }
212
213  return look.form === 'opus' ? { look, height: Math.min(whole.length, maxHeight) } : fitOf({ family: look.family, form: 'opus' }, maxWidth, maxHeight)
214}
215
216// --- the grounds ---------------------------------------------------------------
217
218/** A theme's grounds, 0xRRGGBB: the terminal's own background, and the pane's, a step off it so the pane reads as a panel; `isLight` for a light theme's. */
219export type Ground = { terminal: number; pane: number; isLight?: true }
220
221/**
222 * The themes that bring a ground, by name: a dark green with a yellow cast for Kirby, a deep
223 * berry for Kirby Pink, a red-black for Ghostrunner and a yellow-black for Cyberpunk Edgerunners.
224 * Each theme's `text` colour reads on both at 4.5 to 1 or better (the tests hold them to it).
225 */
226export const GROUNDS: Readonly<Record<string, Ground>> = {
227  kirby: { terminal: 0x1b2414, pane: 0x1b2414 },
228  // (The pane only a little darker: the theme's small text, green, grey and magenta, is near 4.5 to 1 on the terminal's tone itself.)
229  'kirby-pink': { terminal: 0x2b1724, pane: 0x2b1724 },
230  ghostrunner: { terminal: 0x120608, pane: 0x120608 },
231  'cyberpunk-edgerunners': { terminal: 0x0b0b06, pane: 0x0b0b06 },
232}
233
234/** The ground of a theme as the settings name it (`custom:kirby-pink`, `Kirby Pink`); undefined for a theme that brings none: a built-in one, or somebody else's. */
235export const groundOf = (theme: string): Ground | undefined => {
236  const name = theme.trim().toLowerCase().replace(/^custom:/, '').replace(/[\s_]+/g, '-')
237
238  return Object.hasOwn(GROUNDS, name) ? GROUNDS[name] : undefined
239}
240
241/** Whether a theme is a light one: ours that says so, or a built-in one named so. On it the stage is by day, and what is drawn in light colours for a dark ground is drawn darker. */
242export const isLightOf = (theme: string): boolean => groundOf(theme)?.isLight === true || /^light/i.test(theme.trim())
243
244// --- a theme's pack ------------------------------------------------------------
245
246// The text family in a pack's own colours, made once per palette: a pane drawn twice draws from the same family.
247const skinned = new Map<string, TextFamily>()
248
249/** The family a pack calls for: a sprite family by its name; the text family in the pack's variant (red: the Ghostrunner world, yellow: Edgerunners), its colours the pack's where it brings them. */
250function packFamily(pack: Pack): FamilyDef {
251  if (pack.family !== 'cyber') return FAMILIES[pack.family] ?? CLAWD_FAMILY
252  const [key, who] = pack.variant === 'yellow' ? (['cyberpunk-edgerunners', 'thief'] as const) : (['ghostrunner', 'ninja'] as const)
253  const stock = FAMILIES[key] ?? KIRBY_FAMILY
254  const mark = JSON.stringify(pack.skin)
255  if (pack.skin === undefined || ('skin' in stock && JSON.stringify(stock.skin) === mark)) return stock
256  const made = skinned.get(mark) ?? cyber(key, pack.skin, who)
257  skinned.set(mark, made)
258
259  return made
260}
261
262/** What a theme makes of the pane: its family, its grounds (undefined: the terminal's own) and whether it is a light one; `pack` is the pack that said so, absent where the tables above did. */
263export type Themed = { family: FamilyDef; ground: Ground | undefined; isLight: boolean; pack?: Pack }
264
265/**
266 * A theme as the packs read have it. The tables above stay the answer where no pack is the theme's
267 * own: while the theme is not read yet, with no packs at all, and for one of our themes whose pack
268 * was skipped or is gone. Neither the `default` pack nor a sibling's `match` takes it from them:
269 * without kirby-pink.json, `kirby` would give Kirby Pink the green ground.
270 */
271export function themed(theme: string, packs: readonly Pack[] = []): Themed {
272  const pack = resolve(theme, packs)
273  const isOurs = pack !== undefined && pack.slug !== norm(theme) && groundOf(theme) !== undefined
274  if (pack === undefined || isOurs || (pack.slug === 'default' && wantedOf(theme) !== 'clawd')) return { family: familyOf(theme), ground: groundOf(theme), isLight: isLightOf(theme) }
275  // (A built-in light theme is served by the default pack, which is not a light one: its name says it.)
276  const isLight = pack.isLight || (pack.slug === 'default' && /^light/i.test(theme.trim()))
277  const ground: Ground | undefined = pack.background === null ? undefined : { terminal: pack.background, pane: pack.background, ...(isLight ? { isLight: true as const } : {}) }
278
279  return { family: packFamily(pack), ground, isLight, pack }
280}
281
avatar/hooks/hud.ts 577 lines
1// The cyber family (the Ghostrunner and Cyberpunk Edgerunners themes), in text: Raijin, a cyber-oni mask drawn in characters, and the agents as
2// HUD blocks. Both are grids of [glyph, foreground, background] cells for a Raster, swapped in
3// place a frame at a time. Pure: a grid is derived from (skin, mood or agents, now) alone, so
4// previews and tests draw what the pane draws.
5import { FORMS } from './look'
6import type { Form } from './look'
7import type { Mood } from './mascot'
8import { LEAVE_MS, actOf } from './stage'
9import type { Act, Agent } from './stage'
10
11/** The terminal's default colour, in a cell. */
12export const NONE = 0x01000000
13
14export type Variant = 'red' | 'yellow'
15
16/**
17 * One skin's palette, 0xRRGGBB: the interface's one colour (`primary`), its lit edge, the mask's
18 * `body` and `shadow`, the `dim` of captions and thin lines, `cyan` for what is live or selected,
19 * `alert` (what must not be missed: never the skin's own hue), the colour of a `value` (money, a
20 * count, what went well), and the near-black `tint` of a recess and `ink` of text on a filled bar.
21 */
22export type Skin = { variant: Variant; primary: number; bright: number; body: number; shadow: number; dim: number; cyan: number; alert: number; value: number; tint: number; ink: number }
23
24/**
25 * Red on red-black as the game's inventory is, and nothing but red, dark red and its light blue: a
26 * count is a pale red, an alarm a hotter one (told by its stripes and its filled tag too, not by its
27 * hue alone). Yellow on black as its HUD is, what one may do in its blue and an alarm in red. Cyan is the same in both.
28 */
29export const SKINS: Readonly<Record<Variant, Skin>> = {
30  red: { variant: 'red', primary: 0xff6158, bright: 0xffb9ad, body: 0x6a1d22, shadow: 0x34101a, dim: 0xc0706a, cyan: 0x5ef6ff, alert: 0xff1f47, value: 0xffd2cb, tint: 0x1c080c, ink: 0x0b0306 },
31  yellow: { variant: 'yellow', primary: 0xfcee0a, bright: 0xfffbb5, body: 0x7d7605, shadow: 0x3a3702, dim: 0xb0a707, cyan: 0x5ef6ff, alert: 0xff3d3d, value: 0x3aa0ff, tint: 0x121100, ink: 0x060600 },
32}
33
34export const hex = (color: number): string => `#${color.toString(16).padStart(6, '0')}`
35
36/** `columns * rows` cells, row-major, three words each: a code point, a foreground, a background. */
37export type Grid = { columns: number; rows: number; words: Uint32Array }
38
39function blank(columns: number, rows: number): Grid {
40  const words = new Uint32Array(columns * rows * 3).fill(NONE)
41  for (let i = 0; i < words.length; i += 3) words[i] = 0x20
42
43  return { columns, rows, words }
44}
45
46/** One cell; nothing lands outside the grid. An absent `bg` keeps the cell's. */
47function put(grid: Grid, x: number, y: number, glyph: string, fg: number, bg?: number) {
48  if (x < 0 || y < 0 || x >= grid.columns || y >= grid.rows) return
49  const at = (y * grid.columns + x) * 3
50  grid.words[at] = glyph.codePointAt(0) ?? 0x20
51  grid.words[at + 1] = fg
52  if (bg !== undefined) grid.words[at + 2] = bg
53}
54
55const write = (grid: Grid, x: number, y: number, text: string, fg: number, bg?: number) => [...text].forEach((ch, i) => put(grid, x + i, y, ch, fg, bg))
56
57/** A grid's rows as text: what it says, colours aside. */
58export const textOf = ({ columns, rows, words }: Grid): string[] =>
59  Array.from({ length: rows }, (_row, y) => Array.from({ length: columns }, (_cell, x) => String.fromCodePoint(words[(y * columns + x) * 3]!)).join(''))
60
61const channels = (color: number) => [(color >> 16) & 255, (color >> 8) & 255, color & 255] as const
62
63/** `a` to `b`, `u` of the way; past 1 it stays `b`. */
64function mix(a: number, b: number, u: number): number {
65  const q = Math.max(0, Math.min(1, u))
66  const [x, y] = [channels(a), channels(b)]
67  const at = (i: 0 | 1 | 2) => Math.round(x[i] + (y[i] - x[i]) * q)
68
69  return (at(0) << 16) | (at(1) << 8) | at(2)
70}
71
72const WHITE = 0xffffff
73
74/** A whole number from three, the same every time: what is random here is a function of where and when. */
75function hash(a: number, b: number, c: number): number {
76  let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 1274126177)
77  h = Math.imul(h ^ (h >>> 13), 1274126177)
78
79  return (h ^ (h >>> 16)) >>> 0
80}
81
82const ease = (u: number) => u * u * (3 - 2 * u)
83const unit = (u: number) => Math.max(0, Math.min(1, u))
84const wave = (t: number, period: number) => Math.sin((2 * Math.PI * (t % period)) / period)
85
86// --- the mask ----------------------------------------------------------------
87
88export const MASK_COLUMNS = 29
89export const MASK_ROWS = 16
90/** The fewest rows a pane shows of it: the visor and what frames it. */
91export const MASK_LEAST = 6
92
93const HALF = 15 // a row is drawn as its left half, the middle column included, and mirrored
94const VISOR = { x: 5, y: 6, columns: 19, rows: 2 } as const
95
96/**
97 * A row of the mask's left half, three layers of HALF characters: the glyphs; their inks; their
98 * backgrounds. Inks: the armour's planes, k in shadow, m plain, l lit (each takes the light, from
99 * the upper left: a full block half way between two tones is a shade of the one on the other); t the dark of
100 * a recess, d a seam, r a rim, p primary, b bright, T bone; then what moves: V the visor, W the brow
101 * over it, J a vent, C a cable, E the form's own light.
102 */
103type Row = readonly [glyphs: string, inks: string, grounds: string]
104
105const row = (glyphs: string, inks: string, grounds = ''): Row => [glyphs.padEnd(HALF), inks.padEnd(HALF), grounds.padEnd(HALF)]
106
107// From the brow down, the same for every form but its two rows of mouth: the helmet's rim and its
108// wings, the visor under a brow that dips to the middle, its sill, the cheeks' vents either side of
109// the nose guard, the mouth, the jaw, the chin, then the neck's cables inside a high collar whose
110// wings rise at each side; a cable hangs from each cheek to it.
111const BROW: readonly Row[] = [
112  row('  ▗▄▄▄▄▄▄▄▄███┃', '  bbppppppplllb', '      mmmmm   l'),
113  row('  ▝▀█      ▀▀▀▀', '  rrmVVVVVVWWWW'),
114  row('    █', '    mVVVVVVVVVV'),
115  row('    ▜▀▀▀▀▀▀▀▀▀▀', '    mrrrrrrrrrr', '     mmmmmmmmmm'),
116  row('    ▐▜██≡≡≡█▗██', '    CmmmJJJklll', '        kkk k'),
117]
118const JAW: readonly Row[] = [
119  row(' ▄  ▐   ▜██████', ' b  C   mmmmmmm'),
120  row(' █▙▄▐    ▜█≡≡██', ' lmkC    mmJJmm', '           kk'),
121  row(' ▜████▙▄▄  ▐│┃│', ' lmmmmkkk  kCdC', '            ttt'),
122  row('  ▜██████▄▄▟███', '  mmmmmmkkkkrrr'),
123]
124
125// The mouth, two rows a form: what tells them apart below the visor. Where a form has teeth, `glad`
126// is its grin (every upper tooth bared) and `grit` the lower row its jaw chatters to when worried;
127// a grille has neither, and is told by its light alone.
128type Mouth = { rest: readonly [Row, Row]; glad?: readonly [Row, Row]; grit?: Row }
129const MOUTHS: Readonly<Record<Form, Mouth>> = {
130  // Teeth that lock, a tusk standing at each corner.
131  opus: {
132    rest: [row('    ▐ ▜█▲ ▼ ▼ ▼', '    C mmT T T T', '        ttttttt'), row('    ▐  ▜█  ▲ ▲', '    C  mT  T T', '         tttttt')],
133    glad: [row('    ▐ ▜█▲▼▼▼▼▼▼', '    C mmTTTTTTT', '        ttttttt'), row('    ▐  ▜█ ▲ ▲ ▲', '    C  mT T T T', '         tttttt')],
134    grit: row('    ▐  ▜█ ▲ ▲ ▲', '    C  mT T T T', '         tttttt'),
135  },
136  // A grille of bars.
137  sonnet: { rest: [row('    ▐ ▜██▌ ▌ ▌', '    C mmmT T T', '         tttttt'), row('    ▐  ▜█▌ ▌ ▌', '    C  mmT T T', '         tttttt')] },
138  // A speaker's mesh.
139  haiku: { rest: [row('    ▐ ▜██▚▚▚▚▚▚', '    C mmmdddddd', '         tttttt'), row('    ▐  ▜█▚▚▚▚▚▚', '    C  mmdddddd', '         tttttt')] },
140  // Two full rows of needles.
141  fable: {
142    rest: [row('    ▐ ▜█▲▼▼▼▼▼▼', '    C mmTTTTTTT', '        ttttttt'), row('    ▐  ▜█▲▲▲▲▲▲', '    C  mTTTTTTT', '         tttttt')],
143    grit: row('    ▐  ▜█▲ ▲ ▲', '    C  mTT T T', '         tttttt'),
144  },
145}
146const MOUTH_Y = 10 // the mouth's two rows: under the crown's five and the brow's five
147
148const DOME = row('      ▗▟·█╱█·█┃', '      lldmdmdmb', '        m m m l')
149
150// The crown, five rows a form: what tells them apart at a glance.
151const CROWNS: Readonly<Record<Form, readonly Row[]>> = {
152  // Two horns swept up and out.
153  opus: [
154    row(' ▄', ' b'),
155    row(' █▙', ' bp'),
156    row(' ▜█▙      ▗▄▄▄▄', ' bpp      lllll'),
157    row('  ▜██▙  ▗▟█╱██┃', '  bppr  llmdmmb', '           m  l'),
158    row('    ▜███·█╱█·█┃', '    prmmdmdmdmb', '        m m m l'),
159  ],
160  // A crescent blade across the brow, its points high over the helmet.
161  sonnet: [
162    row('     ▖', '     b'),
163    row('     ▜▙', '     bb'),
164    row('      ▜█▄ ▗▄▄▄▄', '      bbp lllll'),
165    row('        ▜██▄▄▄█', '        bppppbb', '           mmm'),
166    DOME,
167  ],
168  // No horns: an aerial at each temple, its tip a light.
169  haiku: [
170    row('       ▄', '       E'),
171    row('       ┃', '       d'),
172    row('       ┃  ▗▄▄▄▄', '       d  lllll'),
173    row('       ┃▗▟█╱██┃', '       dllmdmmb', '           m  l'),
174    DOME,
175  ],
176  // A halo over three spikes, and a third eye.
177  fable: [
178    row('        ▄▄▀▀▀▀▀', '        EEEEEEE'),
179    row('         ▄    ▄', '         p    b'),
180    row('         █▗▄▄▄█', '         pllllb'),
181    row('        ▗▟█╱██◆', '        llmdmmE', '           m  m'),
182    DOME,
183  ],
184}
185
186/** The mask's name, by model. */
187export const NAMES: Readonly<Record<Form, string>> = { opus: 'Raijin', sonnet: 'Raijin Tachi', haiku: 'Raijin Ping', fable: 'Raijin Oracle' }
188
189const MIRRORS = '╱╲▌▐▙▟▛▜▖▗▘▝▚▞▏▕'
190const mirrored = (glyph: string): string => {
191  const at = MIRRORS.indexOf(glyph)
192
193  return at < 0 ? glyph : MIRRORS[at ^ 1]!
194}
195
196/** An armour plane's tone, before the light: an index into the ramp from the recess's dark to the rim. */
197const LEVELS: Readonly<Record<string, number>> = { k: 1, m: 2, l: 3 }
198
199/** How much more (or less) lit a cell of the mask is than its plane: the light is up and to the left. */
200const lightAt = (x: number, y: number): number => 1.4 - 2.6 * (x / (MASK_COLUMNS - 1)) - 0.3 * (y / (MASK_ROWS - 1))
201
202// An eye, five cells over the visor's two rows, outer end first: the top row's glyphs, then the bottom's.
203type Eye = readonly [top: string, bottom: string]
204const EYES: Readonly<Record<'open' | 'half' | 'shut' | 'focus' | 'glad' | 'afraid', Eye>> = {
205  open: ['▆▅▃▂▁', '▀▀▀▀▀'], // a wedge, thick at the temple: a glare
206  half: ['▂▂▁  ', '▀▀▀▀▀'],
207  shut: ['     ', '▔▔▔▔▔'],
208  focus: ['▃▃▂▂▁', '▀▀▀▀▀'],
209  glad: ['▗▄▄▄▖', '▘   ▝'], // a thin arc, narrowed by a grin
210  afraid: ['▁▂▃▅▆', '▀▀▀▀▀'], // the wedge the other way: brows up
211}
212// Ping has one optic, seven cells, in the middle of its visor.
213const OPTICS: Readonly<Record<keyof typeof EYES, Eye>> = {
214  open: ['▂▄▆█▆▄▂', '▔▀▀▀▀▀▔'],
215  half: [' ▂▃▄▃▂ ', '▔▀▀▀▀▀▔'],
216  shut: ['       ', ' ▔▔▔▔▔ '],
217  focus: ['▁▃▅▆▅▃▁', '▔▀▀▀▀▀▔'],
218  glad: ['▗▄▄▄▄▄▖', '▘     ▝'],
219  afraid: ['▁▂▅█▅▂▁', ' ▀▀▀▀▀ '],
220}
221
222const FRAME_MS = 90
223const BLINK_EVERY = 4000
224const HEX = '0123456789ABCDEF'
225/**
226 * A tear: every so many frames (0 for never), for one frame, a band of `rows` rows slips `by` cells
227 * aside. A worried mask tears twice as often, and wider.
228 */
229const TEARS: Readonly<Record<Mood, { every: number; rows: number; by: number }>> = {
230  idle: { every: 64, rows: 3, by: 2 }, worried: { every: 32, rows: 4, by: 3 }, working: { every: 0, rows: 0, by: 0 }, happy: { every: 0, rows: 0, by: 0 },
231}
232/** Which frame of those it is: never the first, so no loop (and no still) opens on a tear. */
233const TEAR_AT = 11
234
235/** Which eye the mask has at `now`: the mood's, and idle a blink through half shut. */
236function eyeOf(mood: Mood, now: number): keyof typeof EYES {
237  const left = BLINK_EVERY - (now % BLINK_EVERY)
238  if (mood === 'idle' && left <= 4 * FRAME_MS) return left > 3 * FRAME_MS || left <= FRAME_MS ? 'half' : 'shut'
239
240  return mood === 'working' ? 'focus' : mood === 'happy' ? 'glad' : mood === 'worried' ? 'afraid' : 'open'
241}
242
243/** Where the mask looks, in cells from the middle of its eye: left, at the conversation, with a glance ahead now and then; the move is eased. */
244function gazeAt(mood: Mood, now: number): number {
245  if (mood !== 'idle') return mood === 'working' ? 1.2 * wave(now, 2400) : 0
246  const v = now % 7200
247  const ahead = v < 5200 ? 0 : v < 5600 ? ease((v - 5200) / 400) : v < 6800 ? 1 : 1 - ease((v - 6800) / 400)
248
249  return -1.4 + 1.6 * ahead
250}
251
252/** How many columns a row is torn aside at `now`, 0 for nearly all: the rows of one band slip together, for one frame. */
253function tearOf(mood: Mood, y: number, now: number): number {
254  const { every, rows, by } = TEARS[mood]
255  const k = Math.floor(now / FRAME_MS)
256  if (every === 0 || k % every !== TEAR_AT) return 0
257  const spell = Math.floor(k / every)
258  const first = hash(spell, 3, 5) % (MASK_ROWS - rows + 1)
259
260  return y >= first && y < first + rows ? (hash(spell, 7, 9) % 2 === 0 ? by : -by) : 0
261}
262
263// A shorter box drops rows: first the collar and the neck (the mask then ends on its jaw, as a
264// bust), then two rows of the crown its shape reads without (a horn keeps its tip and stays in one
265// piece) and the cheeks between them. Shorter still, a bust without its crown, each drawn whole:
266// the mouth is all there or not there, and the last row is always the jaw.
267const JAW_Y = 12
268const CROWN_DROPS: Readonly<Record<Form, readonly [number, number]>> = { opus: [1, 2], sonnet: [0, 1], haiku: [1, 2], fable: [1, 2] }
269const BUSTS: Readonly<Record<number, readonly number[]>> = {
270  9: [4, 5, 6, 7, 8, 9, 10, 11, JAW_Y], 8: [4, 5, 6, 7, 8, 10, 11, JAW_Y], 7: [5, 6, 7, 8, 10, 11, JAW_Y], 6: [4, 5, 6, 7, 8, JAW_Y],
271}
272
273/** The rows of the mask a box of `rows` shows of a form, top to bottom. */
274function keptOf(form: Form, rows: number): readonly number[] {
275  const drops = [15, 14, 13, CROWN_DROPS[form][0], 9, CROWN_DROPS[form][1]].slice(0, Math.max(0, MASK_ROWS - rows))
276
277  return rows >= 10 ? Array.from({ length: MASK_ROWS }, (_row, y) => y).filter(y => !drops.includes(y)) : BUSTS[rows] ?? [5, 6, 7, 8].slice(0, rows)
278}
279
280/**
281 * One frame of the mask, `columns` by `rows` cells: the mask in the middle (a bust, `keptOf`, where
282 * `rows` is fewer than its own), and in the columns left at each side, binary digits, one of
283 * which changes now and then, and plus marks, faint.
284 *
285 * Idle its glow breathes, a scan line sweeps the visor, its eyes glow, look left and blink, its
286 * vents shimmer, and a band of rows tears aside now and then. Working, hex runs between narrowed
287 * eyes, the vents, the cables and the mouth pulse and the scan quickens. Happy its edges and teeth
288 * flare in beats (the armour keeps its shading), its eyes narrow to arcs over a grin, sparks light
289 * up between the horns. Worried its eyes widen round a "!", its edges pulse in the alert colour,
290 * its jaw chatters, and it tears more often and wider.
291 */
292export function maskFrame(skin: Skin, form: Form, mood: Mood, now: number, columns = MASK_COLUMNS, rows = MASK_ROWS): Grid {
293  const whole = blank(MASK_COLUMNS, MASK_ROWS)
294  const k = Math.floor(now / FRAME_MS)
295  const breath = 0.5 + 0.5 * wave(now, mood === 'working' ? 1400 : 3200)
296  const beat = mood === 'happy' ? (1 - unit((now % 1440) / 900)) ** 2 : 0 // a flare that decays, every beat
297  const alarm = mood === 'worried' ? 0.5 + 0.5 * wave(now, 1440) : 0
298  const glow = (color: number) => mix(color, skin.primary, 0.1 * breath)
299  // A flare lights what is already lit (edges, bone), never the armour's planes: its shading stays.
300  const flare = (color: number) => mix(glow(color), skin.bright, 0.6 * beat)
301  // The armour's tones, dark to light; a plane's level and the light on it pick between two of them.
302  const ramp = [skin.tint, skin.shadow, skin.body, mix(skin.body, skin.primary, 0.42), mix(skin.body, skin.primary, 0.8)].map(glow)
303  const levelOf = (mark: string, light: number) => Math.max(0.75, Math.min(4, LEVELS[mark]! + light))
304  const toneAt = (level: number) => mix(ramp[Math.min(3, Math.floor(level))]!, ramp[Math.min(3, Math.floor(level)) + 1]!, level - Math.min(3, Math.floor(level)))
305  // An edge: its own colour, dimmer on the side away from the light, and the alert's as an alarm swells.
306  const edge = (color: number, light: number) => flare(mix(mix(color, skin.alert, 0.7 * alarm), skin.body, Math.max(0, -light) * 0.6))
307  const steady: Record<string, number> = { t: skin.tint, T: mix(glow(skin.bright), WHITE, 0.6 * beat), E: mix(skin.dim, mood === 'worried' ? skin.alert : skin.value, 0.35 + 0.65 * (0.5 + 0.5 * wave(now, 1100))) }
308  const edges: Record<string, number> = { d: mix(skin.shadow, skin.dim, 0.75), r: mix(skin.body, skin.primary, 0.7), p: skin.primary, b: skin.bright }
309
310  // The mask itself: each row's left half, and its mirror.
311  const mouth = MOUTHS[form]
312  const isGritted = mood === 'worried' && k % 6 < 2
313  const [mouthTop, mouthBottom] = mood === 'happy' && mouth.glad !== undefined ? mouth.glad : [mouth.rest[0], isGritted ? mouth.grit ?? mouth.rest[1] : mouth.rest[1]]
314  const rowsOf = [...CROWNS[form], ...BROW, mouthTop, mouthBottom, ...JAW]
315  // The dark of the mouth: a cyan pulse at work, the alert's as an alarm swells.
316  const throat = mood === 'working' ? mix(skin.tint, skin.cyan, 0.08 + 0.22 * (0.5 + 0.5 * wave(now, 720))) : mix(skin.tint, skin.alert, 0.16 * alarm)
317  rowsOf.forEach(([glyphs, marks, grounds], y) => {
318    for (let i = 0; i < HALF; i++) {
319      const [mark, ground] = [marks[i]!, grounds[i]!]
320      if ((mark === ' ' && ground === ' ') || mark === 'V' || mark === 'W') continue
321      for (const x of i === HALF - 1 ? [i] : [i, MASK_COLUMNS - 1 - i]) {
322        const glyph = x === i ? glyphs[i]! : mirrored(glyphs[i]!)
323        const light = lightAt(x, y)
324        const inkOf = (one: string): number | undefined => (one in LEVELS ? toneAt(levelOf(one, light)) : one in edges ? edge(edges[one]!, light) : steady[one])
325        const bg = ground === ' ' ? NONE : ground === 't' && (y === MOUTH_Y || y === MOUTH_Y + 1) ? throat : inkOf(ground) ?? NONE
326        if (glyph === '█' && mark in LEVELS) {
327          // A full block of armour: where its tone falls half way between two of the ramp's, a shade of the lighter on the darker.
328          const level = levelOf(mark, light)
329          const lo = Math.min(3, Math.floor(level))
330          const part = level - lo
331          put(whole, x, y, part < 0.3 || part >= 0.7 ? '█' : '▒', ramp[part < 0.3 ? lo : lo + 1]!, ramp[lo]!)
332          continue
333        }
334        // A vent's light runs outward along it at work, and shimmers otherwise, bar after bar.
335        const vent = mood === 'working' ? 0.5 + 0.5 * wave(now + i * 130, 620) : 0.3 + 0.5 * (0.5 + 0.5 * wave(now + i * 420 + y * 300, 2600))
336        // A cable carries a pulse down it: quick and bright at work, slow and faint idle.
337        const pulse = (mood === 'working' ? 1 : mood === 'idle' ? 0.45 : 0) * unit(1 - Math.abs(((now / (mood === 'working' ? 110 : 420) + i * 3) % 14) - (y - 4)) / 1.5)
338        const fg = mark === 'J' ? glow(mix(skin.shadow, mood === 'working' ? skin.cyan : skin.primary, vent)) : mark === 'C' ? mix(edge(edges.d!, light), skin.cyan, pulse) : inkOf(mark) ?? skin.primary
339        put(whole, x, y, glyph, fg, bg)
340      }
341    }
342  })
343
344  // The visor: a dark recess, darker still under the brow, a scan line's glow passing over it, and in it the eyes.
345  const sweep = mood === 'working' ? 900 : 2600
346  const pass = (now % (2 * sweep)) / sweep
347  const scanAt = VISOR.columns * ease(pass < 1 ? pass : 2 - pass) - 0.5
348  const recess = mood === 'worried' ? mix(skin.tint, skin.alert, 0.06 + 0.08 * alarm) : mix(skin.tint, skin.cyan, 0.06 + 0.1 * beat)
349  const nearAt = (i: number) => Math.exp(-((i - scanAt) ** 2) / 3)
350  for (let j = 0; j < VISOR.rows; j++) {
351    for (let i = 0; i < VISOR.columns; i++) put(whole, VISOR.x + i, VISOR.y + j, ' ', NONE, mix(mix(recess, skin.cyan, 0.3 * nearAt(i)), 0, j === 0 ? 0.35 : 0))
352  }
353  // At work, hex runs between the eyes (either side of Ping's one), a cell clear of them, a digit every other frame.
354  if (mood === 'working') {
355    for (const i of form === 'haiku' ? [1, 2, 3, 4, 14, 15, 16, 17] : [7, 8, 9, 10, 11]) {
356      put(whole, VISOR.x + i, VISOR.y + 1, HEX[hash(i + (k >> 1), 3, 17) % 16]!, mix(skin.tint, skin.cyan, 0.7 + 0.3 * nearAt(i)))
357    }
358  }
359  // The brow dips over the visor's middle: its glow shows under it.
360  rowsOf.forEach(([glyphs, marks], y) => {
361    for (let i = 0; i < HALF; i++) if (marks[i] === 'W') for (const x of [i, MASK_COLUMNS - 1 - i]) put(whole, x, y, glyphs[i]!, edge(skin.primary, lightAt(x, y)))
362  })
363  const eye = eyeOf(mood, now)
364  const gaze = gazeAt(mood, now)
365  const iris = mood === 'worried' ? mix(skin.alert, WHITE, 0.2 + 0.3 * alarm) : mood === 'happy' ? mix(skin.cyan, WHITE, 0.35 + 0.5 * beat) : skin.cyan
366  // Idle, the eyes' own light swells and ebbs.
367  const ember = mood === 'idle' ? 0.3 * (0.5 + 0.5 * wave(now, 1800)) : 0
368  const drawEye = ([top, bottom]: Eye, x: number, isMirrored: boolean) => {
369    const wide = top.length
370    ;[top, bottom].forEach((cells, j) => {
371      for (let i = 0; i < wide; i++) {
372        const glyph = cells[isMirrored ? wide - 1 - i : i]!
373        if (glyph === ' ') continue
374        // The pupil is the brightest of it, and glides between cells; an arch is lit all along.
375        const pupil = eye === 'glad' ? 0.7 : eye === 'shut' ? 0 : unit(1 - Math.abs(i - (wide - 1) / 2 - gaze) / 1.3)
376        put(whole, x + i, VISOR.y + j, isMirrored ? mirrored(glyph) : glyph, mix(mix(iris, skin.tint, eye === 'glad' ? 0 : 0.3), WHITE, unit(0.75 * pupil + ember)))
377      }
378    })
379  }
380  if (form === 'haiku') drawEye(OPTICS[eye], VISOR.x + 6, false)
381  else {
382    drawEye(EYES[eye], VISOR.x + 1, false)
383    drawEye(EYES[eye], VISOR.x + VISOR.columns - 6, true)
384  }
385  const middle = VISOR.x + (VISOR.columns >> 1)
386  // Between the eyes, afraid: a "!" in a filled box.
387  if (mood === 'worried' && form !== 'haiku') {
388    const isOn = k % 12 < 8
389    put(whole, middle, VISOR.y + 1, '!', isOn ? skin.ink : skin.alert, isOn ? skin.alert : undefined)
390  }
391
392  // Happy: sparks light up between the horns, over the crown, each held where it is while it swells and fades.
393  if (mood === 'happy') {
394    for (let n = 0; n < 6; n++) {
395      const [spell, age] = [Math.floor((k + n * 5) / 16), (k + n * 5) % 16]
396      const [x, y] = [3 + (hash(n, spell, 41) % (MASK_COLUMNS - 6)), hash(n, spell, 43) % 2]
397      const at = (y * MASK_COLUMNS + x) * 3
398      if (age < 12 && whole.words[at] === 0x20 && whole.words[at + 2] === NONE) put(whole, x, y, '·+*+'[Math.floor(age / 3)]!, mix(skin.cyan, WHITE, age >= 3 && age < 9 ? 0.75 : 0.2))
399    }
400  }
401
402  // A torn band: its rows slid aside together, as a signal splits. Where a row has moved onto empty
403  // cells it is cyan; where it has left the mask, the mask stays, in its plain colour: no hole opens in it.
404  const torn = blank(MASK_COLUMNS, MASK_ROWS)
405  const cellAt = (x: number, y: number) => {
406    const from = (y * MASK_COLUMNS + x) * 3
407    const [glyph, fg, bg] = [whole.words[from]!, whole.words[from + 1]!, whole.words[from + 2]!]
408
409    return x < 0 || x >= MASK_COLUMNS || (glyph === 0x20 && bg === NONE) ? undefined : { glyph: String.fromCodePoint(glyph), fg, bg }
410  }
411  for (let y = 0; y < MASK_ROWS; y++) {
412    const dx = tearOf(mood, y, now)
413    for (let x = 0; x < MASK_COLUMNS; x++) {
414      const [moved, stayed] = [cellAt(x - dx, y), cellAt(x, y)]
415      if (moved !== undefined) put(torn, x, y, moved.glyph, stayed === undefined ? skin.cyan : moved.fg, stayed === undefined ? NONE : moved.bg)
416      else if (stayed !== undefined) put(torn, x, y, stayed.glyph, skin.primary, stayed.bg)
417    }
418  }
419
420  // Into its box: the rows a box this tall shows, centred, the margins' digits beside it.
421  const grid = blank(columns, rows)
422  const kept = keptOf(form, rows)
423  const isBust = !kept.includes(JAW_Y + 2)
424  const left = (columns - MASK_COLUMNS) >> 1
425  kept.forEach((from, y) => {
426    for (let x = 0; x < MASK_COLUMNS; x++) {
427      // A bust ends on the jaw alone: what hangs beside it there (the cables, the collar's tips) goes with the collar.
428      if (isBust && from >= JAW_Y && (x < 8 || x > MASK_COLUMNS - 9)) continue
429      const at = (from * MASK_COLUMNS + x) * 3
430      put(grid, left + x, y, String.fromCodePoint(torn.words[at]!), torn.words[at + 1]!, torn.words[at + 2]!)
431    }
432  })
433  // Still, as the game's margins are: of all these digits one changes at a time, a little oftener at work.
434  const xs = left >= 4 ? [0, 1, columns - 2, columns - 1] : left >= 2 ? [0, columns - 1] : []
435  const count = xs.length * rows
436  const step = Math.floor(now / (mood === 'working' ? 360 : 810))
437  xs.forEach((x, n) => {
438    for (let y = 0; y < rows; y++) {
439      const turn = Math.floor((step + (((n * rows + y) * 7) % count)) / count)
440      const isMark = (y + (n >= xs.length / 2 ? 2 : 0)) % 5 === 2 && (x === 0 || x === columns - 1)
441      put(grid, x, y, isMark ? '+' : hash(x, y, turn) % 2 === 0 ? '0' : '1', mix(skin.tint, skin.dim, isMark ? 0.5 : 0.3))
442    }
443  })
444
445  return grid
446}
447
448// --- the agents --------------------------------------------------------------
449
450/** What a block says its agent is at: between calls, one per kind of tool, then a failed call, and its end, stopped or finished. */
451export type State = 'think' | 'scan' | 'edit' | 'exec' | 'net' | 'call' | 'load' | 'ask' | 'tool' | 'fail' | 'halt' | 'done'
452
453const STATES: Readonly<Record<Exclude<Act, 'exit'>, State>> = {
454  idle: 'think', inhale: 'scan', sword: 'edit', dash: 'exec', ride: 'net', call: 'call', sparkle: 'load', ask: 'ask', hop: 'tool',
455  hurt: 'fail', dazed: 'fail', dance: 'done', faint: 'halt',
456}
457
458/** The state of an agent at `now`, from what it really does: the stage's acts, told as a HUD tells them. */
459export function stateOf(agent: Agent, now: number): State {
460  const act = actOf(agent, now)
461
462  return act === 'exit' ? (agent.isLost === true ? 'halt' : 'done') : STATES[act]
463}
464
465export const TAGS: Readonly<Record<State, string>> = {
466  think: 'THNK', scan: 'SCAN', edit: 'EDIT', exec: 'EXEC', net: 'NET', call: 'CALL', load: 'LOAD', ask: 'ASK', tool: 'TOOL', fail: 'FAIL', halt: 'HALT', done: 'DONE',
467}
468
469// A state's little figure, three cells, a frame every two of the clock's: each state its own motion.
470// None fills its cell top to bottom: on a block of one row, a figure never runs into the one under it.
471// Every frame has a glyph lit, and what a figure is anchored on (a prompt, a dot, a "?") keeps its cell.
472const ICONS: Readonly<Record<State, readonly string[]>> = {
473  think: ['•··', '·•·', '··•', '·•·'], // a thought going to and fro
474  scan: ['■□□', '□■□', '□□■', '□■□'], // a head sweeping a track
475  edit: ['╺  ', '━╸ ', '━━╸', '━━━', '━━╸', '━╸ '], // a line written and taken back
476  exec: ['>_ ', '>  ', '>_ ', '>>_', '>>>', '>>·', '>··'], // a prompt, then it runs
477  net: ['·  ', '·) ', '·))', '•))', '• )', '•  '], // a signal going out
478  call: ['>--', '->-', '-->', '--<', '-<-', '<--'], // a message there and back
479  load: ['[|]', '[/]', '[-]', '[\\]'], // a chip spinning up
480  ask: [' ? ', '·?·', '•?•', '·?·'], // a question waiting
481  tool: ['▖··', '▘▖·', '·▘▖', '··▘', '···'], // a pulse stepping through
482  fail: ['×!×', '!×!', '×!×', '! !'], // broken up
483  halt: ['■■■', '■■□', '■□□', '□□□', '□□□', '□□□'], // going dark
484  done: ['▁  ', '▁▃ ', '▁▃▆', ' ✓ ', ' ✓ ', '·✓·', ' ✓ ', ' ✓ '], // bars rising to a tick
485}
486
487/** What the blocks need of a subagent besides its times: its task, and what each running call is about. */
488export type Runner = Omit<Agent, 'calls'> & { label: string; calls: readonly { tool: string; summary: string }[] }
489
490/** A model as a block tags it, three letters at the right end of the agent's line. */
491export const MODEL_TAGS: Readonly<Record<Form, string>> = { opus: 'OPS', sonnet: 'SNT', haiku: 'HKU', fable: 'FBL' }
492
493/** The tag of the model an agent runs on; none while nobody told, or for a model of no known family. */
494export const modelTag = (model: string | undefined): string => {
495  const form = FORMS.find(one => (model ?? '').toLowerCase().includes(one))
496
497  return form === undefined ? '' : MODEL_TAGS[form]
498}
499
500/** As many agents as get a block of their own; more are counted on a last line. */
501export const BLOCKS_MAX = 5
502
503/** How many rows the blocks of `alive` agents take of `room`: two a block where all fit so, else one, the last one counting those left out. */
504export const blockRowsOf = (alive: number, room: number): number =>
505  alive <= BLOCKS_MAX && room >= 2 * alive ? 2 * alive : Math.max(0, Math.min(Math.min(alive, BLOCKS_MAX) + (alive > BLOCKS_MAX ? 1 : 0), room))
506
507/** Text a cell can hold: a width-1 character each, anything else a "?". */
508const plain = (text: string): string =>
509  [...text.replace(/\s+/g, ' ')].map(ch => {
510    const code = ch.codePointAt(0)!
511
512    return (code >= 0x20 && code < 0x7f) || (code >= 0xa1 && code < 0x250 && code !== 0xad) ? ch : '?'
513  }).join('')
514
515const cut = (text: string, width: number): string => (text.length <= width ? text : width < 1 ? '' : `${text.slice(0, width - 1)}…`)
516
517/**
518 * The agents' blocks, `columns` by `rows` cells, as the game lays out what one may answer: down the
519 * left edge a bar, a three-cell figure acting out its state, the state as a bracketed tag, its task;
520 * all three in the state's colour (live, failed, stopped, finished), never one of the agent's own that
521 * could be taken for a state. Under it, where the rows allow two a block, the agent's number, the
522 * running tool and what it is about; on one row, the number first, and the tool at the right end,
523 * the task taking all the row leaves it. The model it runs on, where that is known, is a short tag at the right
524 * end of its first row (on a block of one row, where no tool is named there). An agent that ended fades out; agents past the rows are a "+N MORE"
525 * (on a single row, the first agent alone).
526 */
527export function agentRows(skin: Skin, crew: readonly Runner[], now: number, columns: number, rows: number): Grid {
528  const grid = blank(columns, rows)
529  const per = crew.length <= BLOCKS_MAX && rows >= 2 * crew.length ? 2 : 1
530  // More agents than rows: the last row counts those left out; a single row names the first instead (the rule over the blocks has the count).
531  const fit = per === 2 ? crew.length : crew.length > BLOCKS_MAX || crew.length > rows ? Math.max(Math.min(1, rows), Math.min(BLOCKS_MAX, rows - 1)) : crew.length
532  const shown = crew.slice(0, fit)
533  const toolOf = (agent: Runner) => plain((agent.calls[agent.calls.length - 1]?.tool ?? '').replace(/^mcp__/, ''))
534  shown.forEach((agent, n) => {
535    const y = n * per
536    const state = stateOf(agent, now)
537    // An ended agent goes out over its stay.
538    const gone = agent.doneAt === undefined ? 0 : 0.75 * ease(unit((now - agent.doneAt - LEAVE_MS / 2) / (LEAVE_MS / 2)))
539    const faded = (color: number) => mix(color, skin.tint, gone)
540    const frames = ICONS[state]
541    const since = state === 'done' && agent.doneAt !== undefined ? Math.min(frames.length - 1, Math.floor((now - agent.doneAt) / (2 * FRAME_MS))) : Math.floor((now + agent.slot * 370) / (2 * FRAME_MS)) % frames.length
542    const tone = state === 'fail' ? skin.alert : state === 'halt' ? skin.dim : state === 'done' ? skin.value : skin.cyan
543    const call = agent.calls[agent.calls.length - 1]
544    const tool = toolOf(agent)
545    for (let j = 0; j < per; j++) put(grid, 0, y + j, '▌', faded(tone))
546    // The agent's number: under its figure, or before it on a block of one row.
547    const number = String(agent.slot + 1).padStart(2, '0')
548    const at = per === 2 ? 2 : 5
549    write(grid, at, y, frames[since]!, faded(tone))
550    // A failed call's tag is filled, and blinks: it is the one to see.
551    const isFilled = state === 'fail' && Math.floor(now / (4 * FRAME_MS)) % 2 === 0
552    write(grid, at + 4, y, `[${TAGS[state]}]`, isFilled ? skin.ink : faded(tone), isFilled ? skin.alert : undefined)
553    const from = at + 6 + TAGS[state].length + 1
554    const label = plain(agent.label)
555    write(grid, 2, y + per - 1, number, faded(mix(skin.tint, skin.dim, 0.7)))
556    const model = modelTag(agent.model)
557    if (per === 2) {
558      write(grid, from, y, cut(label === '' ? 'agent' : label, columns - from - (model === '' ? 0 : model.length + 1)), faded(skin.bright))
559      write(grid, columns - model.length, y, model, faded(skin.dim))
560      const about = call === undefined ? (state === 'fail' ? 'a call failed' : state === 'done' ? 'finished' : state === 'halt' ? 'stopped' : 'thinking') : plain(call.summary)
561      write(grid, 6, y + 1, cut(tool, columns - 6), faded(skin.primary))
562      write(grid, 6 + (tool === '' ? 0 : tool.length + 1), y + 1, cut(about, columns - 6 - (tool === '' ? 0 : tool.length + 1)), faded(skin.dim))
563    } else {
564      // The tool at the right end, a third of the row at the most; the task has the rest, all of it where no tool runs.
565      const named = tool === '' ? model : cut(tool, Math.floor(columns / 3))
566      write(grid, from, y, cut(label === '' ? 'agent' : label, columns - from - (named === '' ? 0 : named.length + 1)), faded(skin.bright))
567      write(grid, columns - named.length, y, named, faded(tool === '' ? skin.dim : skin.primary))
568    }
569  })
570  if (fit < crew.length && fit * per < rows) {
571    put(grid, 0, fit * per, '▌', skin.dim)
572    write(grid, 2, fit * per, cut(`+${crew.length - fit} MORE`, columns - 2), skin.dim)
573  }
574
575  return grid
576}
577
avatar/hooks/hudpane.tsx 149 lines
1// The pane as a text family's skin draws it: a HUD in one colour on the dark, thin rules with a cut
2// corner (the block of meters closed by one at its foot), small uppercase captions that carry real
3// values, the speech as a filled banner beside a boxed sign, flat bars with their number at the right. What moves (the mask, the agents' blocks)
4// is handed in already mounted; everything here is redrawn only when what it says changes.
5import type { ElementTable, RenderElement } from 'claude-code'
6
7import { hex } from './hud'
8import type { Skin } from './hud'
9import type { Mood } from './mascot'
10
11/** The rows the HUD takes besides the mask and the agents: a caption, the banner's three, the name, the controls, a rule, the cost, three meters of two, the frame's foot. */
12export const HUD_ROWS = 15
13
14export type Meter = { label: string; percent: number | undefined; isAlert: boolean; detail: string }
15
16export type HudView = {
17  skin: Skin
18  /** The pane's own background and how many columns it fills, for a theme that brings one. */
19  ground?: { color: string; columns: number }
20  /** The columns everything is laid out in, and the pane's rows. */
21  columns: number
22  height: number
23  /** The session's id and model, and for how long it has run, in minutes: absent while unknown. */
24  session: string
25  model: string
26  minutes: number | undefined
27  name: string
28  /** What the banner tells: the turn's mood, `working` for all of a turn. */
29  mood: Mood
30  /** What the banner is headed and says (one line: what does not fit is cut); `isCall` when that is a running call. */
31  title: string
32  said: string
33  isCall: boolean
34  /** The agents' blocks and how many agents there are; the mask. Null for none. */
35  agents: RenderElement | null
36  alive: number
37  mask: RenderElement | null
38  petted: number
39  onPet: () => Promise<void>
40  cost: string
41  meters: readonly Meter[]
42  isFull: boolean
43}
44
45const pad2 = (n: number) => String(n).padStart(2, '0')
46
47/** A session id as the game writes an address: the first eight of its letters and digits, grouped. */
48const addressOf = (id: string): string => {
49  const plain = id.replace(/[^0-9a-z]/gi, '').toUpperCase()
50
51  return plain.length < 8 ? '' : ` ${plain.slice(0, 4)}.${plain.slice(4, 6)}.${plain.slice(6, 8)}`
52}
53
54const SIGNS: Readonly<Record<Mood, string>> = { idle: ':', working: '~', worried: '!', happy: '✓' }
55
56export function hudTree({ Box, Button, Text }: ElementTable, view: HudView): RenderElement {
57  const { skin, columns } = view
58  const [primary, dim, cyan, alert, value, ink, shadow] = [skin.primary, skin.dim, skin.cyan, skin.alert, skin.value, skin.ink, skin.shadow].map(hex) as [string, string, string, string, string, string, string]
59  // A rule that opens a block: cut at its left end, cornered at its right, the caption on it.
60  const rule = (caption: string) => (
61    <Text wrap="truncate-end">
62      <Text color={dim}>╱─ </Text>
63      <Text color={primary}>{caption}</Text>
64      <Text color={dim}> {'─'.repeat(Math.max(0, columns - 5 - caption.length))}┐</Text>
65    </Text>
66  )
67  const clock = view.minutes === undefined ? '' : `T+${pad2(Math.floor(view.minutes / 60))}:${pad2(view.minutes % 60)}`
68  const isAlarm = view.mood === 'worried' && !view.isCall
69  const tone = isAlarm ? alert : view.isCall || view.mood === 'working' ? cyan : view.mood === 'happy' ? value : primary
70  const inner = Math.max(1, columns - 4)
71  // The banner's line: cut to the bar, an alarm's bar ending in hazard stripes.
72  const room = Math.max(1, inner - 2 - (isAlarm ? 3 : 0))
73  const said = view.said.length > room ? `${view.said.slice(0, room - 1)}…` : view.said
74  // The model beside the name, its date left out, cut to what the line holds: '─┤ ' and ' // ' and ' ├─' are ten.
75  const id = view.model.replace(/^claude-/, '').replace(/-\d{8}$/, '').toUpperCase()
76  const fits = columns - view.name.length - 10
77  const model = id.length <= fits ? id : fits >= 4 ? `${id.slice(0, fits - 1)}…` : ''
78  const critical = ['[!] CONTEXT CRITICAL > /clear', '[!] CTX FULL > /clear', '[!] /clear'].find(text => text.length <= columns) ?? '[!]'
79  const bar = Math.max(4, columns - 8)
80
81  const meter = ({ label, percent, isAlert, detail }: Meter) => {
82    const filled = percent === undefined ? 0 : Math.round((Math.min(100, percent) / 100) * bar)
83
84    return (
85      <Box flexDirection="column">
86        <Text color={dim} wrap="truncate-end">{`${label} // ${detail}`.toUpperCase()}</Text>
87        <Text wrap="truncate-end">
88          {/* Past its threshold a bar is notched, as hazard tape, at the height of any bar, and its number a filled tag: an alarm is told by more than a colour. */}
89          <Text color={isAlert ? alert : primary}>{isAlert ? '▄▂'.repeat(filled).slice(0, filled) : '▄'.repeat(filled)}</Text>
90          <Text color={shadow}>{'▄'.repeat(bar - filled)}</Text>
91          {percent === undefined
92            ? <Text color={dim}>{'N/A'.padStart(8)}</Text>
93            : isAlert ? <Text> <Text color={ink} backgroundColor={alert} bold>{String(Math.round(percent)).padStart(3)}</Text></Text>
94            : <Text color={primary} bold>{String(Math.round(percent)).padStart(4)}</Text>}
95          {percent !== undefined && <Text color={dim}> 100</Text>}
96        </Text>
97      </Box>
98    )
99  }
100
101  return (
102    <Box flexDirection="column" height={view.height} justifyContent="flex-end" {...(view.ground === undefined ? {} : { width: view.ground.columns, backgroundColor: view.ground.color })}>
103      <Box width={columns} justifyContent="space-between">
104        <Text color={dim} wrap="truncate-end">CONNECTION{addressOf(view.session)}</Text>
105        {columns >= 30 && <Text color={dim}>{clock}</Text>}
106      </Box>
107      {view.agents !== null && rule(`SUBNET // ${pad2(view.alive)} PROC`)}
108      {view.agents}
109      <Text wrap="truncate-end">
110        <Text color={tone}>┌─┐ </Text>
111        <Text color={dim}>PROTOCOL // {view.title.toUpperCase()}</Text>
112      </Text>
113      <Text wrap="truncate-end">
114        <Text color={tone}>│</Text>
115        <Text color={tone} bold>{view.isCall ? '>' : SIGNS[view.mood]}</Text>
116        <Text color={tone}>│</Text>
117        <Text color={ink} backgroundColor={tone} bold>{` ${said.padEnd(room)} `}{isAlarm && '▞▞▞'}</Text>
118        <Text color={tone}>▛</Text>
119      </Text>
120      <Text wrap="truncate-end">
121        <Text color={tone}>└─┘ </Text>
122        <Text color={dim}>{'▔'.repeat(Math.max(0, inner - 6))} ▀▀ ▀▀</Text>
123      </Text>
124      {/* (In the middle: a picture is whole art pixels wide, which may be a column short of the pane.) */}
125      {view.mask !== null && <Box width={columns} justifyContent="center">{view.mask}</Box>}
126      <Box width={columns} justifyContent="center">
127        <Text wrap="truncate-end">
128          <Text color={dim}>─┤ </Text>
129          <Text color={primary} bold>{view.name.toUpperCase()}</Text>
130          {model !== '' && <Text color={dim}> // {model}</Text>}
131          <Text color={dim}> ├─</Text>
132        </Text>
133      </Box>
134      <Box width={columns} justifyContent="center" gap={1}>
135        <Button key="pet" label="PET" hotkey="p" variant="primary" onPress={view.onPet} />
136        <Text color={value}>[ ♥ {view.petted} ]</Text>
137      </Box>
138      {rule('MONITOR')}
139      <Box width={columns} justifyContent="space-between">
140        <Text color={dim}>SESSION COST</Text>
141        <Text color={value} bold>{view.cost}</Text>
142      </Box>
143      {view.meters.map(meter)}
144      {view.isFull && <Text color={alert} bold wrap="truncate-end">{critical}</Text>}
145      <Text color={dim} wrap="truncate-end">└{'─'.repeat(Math.max(0, columns - 2))}╱</Text>
146    </Box>
147  )
148}
149
avatar/hooks/layout.ts 215 lines
1// What a pane of a given size draws, and the cell-block mascot's pixels. Pure: no engine import, so
2// previews and tests lay out and compose what the pane shows. A mascot is never scaled by anything
3// but a whole number: in cell blocks a sprite pixel is a pixel (half a cell), in true pixels a
4// whole number of art pixels.
5import { boxIn, boxOf, unitOf } from './canvas'
6import type { Box, Cell } from './canvas'
7import { HERO_COLUMNS, HERO_PX, HERO_ROWS, HERO_ROWS_LEAST } from './cyber'
8import type { Who } from './cyber'
9import { fitOf, framesOf, pixelsOf } from './families'
10import type { Look } from './families'
11import { FEED_SHAPE } from './reel'
12import type { Slot } from './reel'
13import { MASK_COLUMNS, MASK_LEAST, MASK_ROWS, NONE, blockRowsOf } from './hud'
14import type { Skin } from './hud'
15import { HUD_ROWS } from './hudpane'
16import type { Form, Pixels } from './look'
17import { HEADROOM, MARGIN } from './mascot'
18import type { Mood } from './mascot'
19import { NARROWEST_PX, STAGE_PX } from './scene'
20import { KIRBY } from './sprite'
21import { NARROWEST as STAGE_NARROWEST, STAGE_ROWS } from './stage'
22
23const BLUSH = 0xff1784
24const BLINK_EVERY = 10 // of the cell-block mascot's steps
25
26// A raster is the sprite plus two pixels: a column each side to shake in, two rows to jump in.
27const sizeOf = (width: number, height: number) => ({ columns: width + 2, rows: Math.ceil((height + 2) / 2) })
28
29export const LEGEND_MAX = 5 // as many as the widest stage has minis: every mini on stage has its line
30export const CHROME_ROWS = 14 // the bubble's four, name, pet, a blank, cost, three meters of two
31const NARROWEST = KIRBY[0]!.length // no mascot is drawn narrower than plain Kirby
32const PLAIN_ROWS = sizeOf(NARROWEST, KIRBY.length).rows
33const TEXT_RESERVED = 2 // of a text family's pane: the agents' rule and one row of them
34const LEGEND_KEPT = 3 // the rows a model's form leaves above the bubble: two agents named and a count of the others
35
36/** A mascot in text: the family's skin and the model's form. */
37export type TextLook = { skin: Skin; form: Form }
38
39/**
40 * What a pane draws: the mascot's box in cells and, above it, the stage's (null for none) and how
41 * many lines of legend. In cell blocks `look` is the look drawn, which on a short or narrow pane is
42 * the family's plain form; in true pixels, `art` holds the two pictures' boxes and how many art
43 * pixels a sprite pixel is. A family in text has `text` in place of a `look`: its mask in `size`
44 * (no rows for none) and its agents' blocks in `stage`; with a `hero`, that box holds the family's
45 * character instead of the mask, in true pixels where there is an `art`; with a `feed`, it holds the
46 * camera feed of that slot's pictures (reel.ts), for any model.
47 */
48export type Layout = {
49  size: { columns: number; rows: number }
50  stage: { columns: number; rows: number } | null
51  lines: number
52  art?: { scale: number; mascot: Box; stage: Box | null }
53} & ({ look: Look } | { text: TextLook; hero?: Who; feed?: Slot })
54
55/** How many lines of legend `room` rows hold: at most LEGEND_MAX agents are named; a last line counts the others. */
56const linesOf = (alive: number, room: number) => Math.max(0, Math.min(Math.min(alive, LEGEND_MAX) + (alive > LEGEND_MAX ? 1 : 0), room))
57
58/**
59 * The layout of a pane of this size, in cell blocks. The mascot depends on the pane alone, never on
60 * the helpers, so it holds still as they come and go, and it is never shrunk: it has every row the
61 * meters and the bubble leave, the stage only the rows left over. Too short for the form, the pane
62 * shows what `fitOf` says: the plain form, whole. A model's form is "too short for" a pane it would
63 * fill to the last row: it is drawn only where LEGEND_KEPT rows are left above the bubble, so the
64 * agents are still told there, by name, when the stage has given up its rows. The stage and its
65 * legend take the rows left above the bubble; too few for the stage, the legend alone.
66 */
67export function laidOut(look: Look, alive: number, width: number, rows: number, canDraw: boolean): Layout & { look: Look } {
68  const budget = Math.max(PLAIN_ROWS, rows - CHROME_ROWS)
69  const wide = Math.max(NARROWEST, width - 2)
70  const whole = fitOf(look, wide, (budget - LEGEND_KEPT) * 2 - 2)
71  const fit = look.form !== 'opus' && whole.look === look ? whole : fitOf({ family: look.family, form: 'opus' }, wide, budget * 2 - 2)
72  const size = sizeOf(framesOf(fit.look)[0]![0]!.length, fit.height)
73  const room = rows - CHROME_ROWS - size.rows
74  const hasStage = canDraw && alive > 0 && width - 1 >= STAGE_NARROWEST && room > STAGE_ROWS
75
76  return { look: fit.look, size, stage: hasStage ? { columns: width - 1, rows: STAGE_ROWS } : null, lines: linesOf(alive, room - (hasStage ? STAGE_ROWS : 0)) }
77}
78
79/**
80 * The same in true pixels, for cells of `cell` device pixels. A sprite pixel is a whole number of
81 * art pixels, about a column wide at the most (the cell-block mascot's size): the mascot is never
82 * blurred, only smaller. It gives up one step of its size to leave the stage room, never more: on
83 * a pane too short for that it keeps the size it has alone, and the agents are lines of legend.
84 * Its box is as many columns off the bubble's width as keeps it centred under the bubble's tail.
85 */
86export function laidOutArt(look: Look, alive: number, width: number, rows: number, cell: Cell): Layout & { look: Look } {
87  const stage = boxIn(cell, width - 1, STAGE_PX)
88  const whole = framesOf(look)[0]!
89  const boxAt = (scale: number) => {
90    const box = boxOf(cell, whole[0]!.length * scale + 2 * MARGIN, whole.length * scale + HEADROOM)
91    const wider = boxOf(cell, box.width + 1, box.height)
92    const isOff = (columns: number) => (Math.max(8, width - 1) - columns) % 2 !== 0
93
94    return isOff(box.columns) && !isOff(wider.columns) && wider.columns <= width ? wider : box
95  }
96  const fits = (scale: number, budget: number) => boxAt(scale).rows <= budget && boxAt(scale).columns <= width
97  const top = Math.max(1, Math.round(cell.w / unitOf(cell)))
98  const scales = Array.from({ length: top }, (_scale, i) => top - i)
99  const alone = scales.find(s => fits(s, rows - CHROME_ROWS)) ?? 1
100  const roomy = scales.find(s => fits(s, rows - CHROME_ROWS - stage.rows - 1))
101  const scale = roomy !== undefined && roomy >= Math.max(alone - 1, Math.min(alone, 2)) ? roomy : alone
102  const mascot = boxAt(scale)
103  const room = rows - CHROME_ROWS - mascot.rows
104  const hasStage = alive > 0 && stage.width >= NARROWEST_PX && room > stage.rows
105
106  return {
107    look, size: mascot, stage: hasStage ? stage : null,
108    lines: linesOf(alive, room - (hasStage ? stage.rows : 0)), art: { scale, mascot, stage: hasStage ? stage : null },
109  }
110}
111
112/**
113 * The same for a family in text. The mask depends on the pane alone, so it holds still as the
114 * agents come and go: whole where the rows allow, cropped about its visor on a shorter pane, left
115 * out under MASK_LEAST rows or MASK_COLUMNS columns. It never takes the last TEXT_RESERVED rows:
116 * the agents' blocks take the rows left above the banner, one of them for their rule, so a running
117 * agent is told on any pane that holds the HUD and two rows more.
118 *
119 * A `hero` is the family's character, drawn in place of the mask where the pane has its rows: in
120 * true pixels, in a box of whole art pixels as wide as the pane, when a `cell` is given; in text
121 * otherwise, HERO_ROWS tall or a little less. Its box is one size whatever it does. On a pane too
122 * short or too narrow for it, the mask is drawn, as for any other model.
123 *
124 * A `feed` is a slot whose pictures there are (with a `cell`: pictures are true pixels): the box is
125 * the camera feed's, one shape and so one size whoever plays in it, with or without a drawn `who`.
126 */
127export function laidOutText(text: TextLook, alive: number, width: number, rows: number, canDraw: boolean, hero?: { who?: Who; cell?: Cell; feed?: Slot }): Layout {
128  const columns = Math.max(8, width - 1)
129  const spare = rows - HUD_ROWS
130  const room = spare - TEXT_RESERVED
131  const laid = (size: { columns: number; rows: number }, more: Partial<Layout> = {}): Layout => {
132    const blocks = canDraw && alive > 0 ? blockRowsOf(alive, spare - size.rows - 1) : 0
133
134    return { text, size, stage: blocks > 0 ? { columns, rows: blocks } : null, lines: 0, ...more }
135  }
136  if (hero !== undefined && canDraw && columns >= HERO_COLUMNS) {
137    const drawn = hero.who === undefined ? {} : { hero: hero.who }
138    const feed = hero.cell === undefined ? undefined : hero.feed
139    const box = hero.cell === undefined ? undefined : feed !== undefined ? portraitBox(hero.cell, columns, room, FEED_SHAPE) : hero.who !== undefined ? boxIn(hero.cell, columns, HERO_PX) : undefined
140    if (box !== undefined && box.rows <= room) return laid({ columns: box.columns, rows: box.rows }, { ...drawn, ...(feed === undefined ? {} : { feed }), art: { scale: 1, mascot: box, stage: null } })
141    if (hero.who !== undefined && room >= HERO_ROWS_LEAST) return laid({ columns, rows: Math.min(HERO_ROWS, room) }, drawn)
142  }
143
144  return laid({ columns, rows: canDraw && columns >= MASK_COLUMNS && room >= MASK_LEAST ? Math.min(MASK_ROWS, room) : 0 })
145}
146
147// --- the mascot in cell blocks ---------------------------------------------------
148
149const SPARK = [0x5fd7ff, 0xffd75f, 0xaf87ff]
150// Where the free corner above Kirby's face holds a spark, one per frame.
151const SPARK_AT = [[3, 1], [1, 3], [2, 0], [0, 2]] as const
152
153/**
154 * One step of the main mascot in cell blocks, as pixels, two to a cell's height: idle breathes and
155 * blinks, working bounces and chatters with sparks flying off, happy jumps under hearts, worried
156 * shakes. The sprite is drawn mirrored (the mascots look left, at the conversation), a pixel of it
157 * a pixel here; what the box is too short for is cut at its foot.
158 */
159export function mascotBlocks(drawn: Layout & { look: Look }, mood: Mood, frame: number): Pixels {
160  const { columns, rows } = drawn.size
161  const isBlink = mood === 'idle' && frame % BLINK_EVERY === BLINK_EVERY - 1
162  const isHalf = mood === 'worried' || (mood === 'working' && frame % 2 === 1)
163  const dy = mood === 'happy' ? [2, 1, 0, 1][frame % 4]! : mood === 'working' ? 1 + (frame % 2) : 1 + (Math.floor(frame / 4) % 2)
164  const dx = mood === 'worried' ? (frame % 2) * 2 : 1
165
166  const canvas = Array.from({ length: rows * 2 }, () => Array<number | null>(columns).fill(null))
167  pixelsOf(drawn.look, mood, isBlink ? 2 : isHalf ? 1 : 0, mood === 'working' && frame % 2 === 1).forEach((row, y) => row.forEach((color, x) => {
168    const line = canvas[y + dy]
169    if (line && color !== null) line[dx + row.length - 1 - x] = color
170  }))
171  if (mood === 'working' || mood === 'happy') {
172    const [row, col] = SPARK_AT[frame % SPARK_AT.length]!
173    const color = mood === 'happy' ? BLUSH : SPARK[frame % SPARK.length]!
174    canvas[row]![col] ??= color
175    if (mood === 'happy') canvas[row]![columns - 1 - col] ??= color
176  }
177
178  return canvas
179}
180
181/**
182 * Pixels as RasterProps cells, an upper-half block per two pixels. Where nothing is drawn a cell
183 * shows `ground`: the pane's own background, or the terminal's (NONE) on a pane that paints none.
184 */
185export function encoded(canvas: Pixels, ground = NONE): string {
186  const columns = canvas[0]!.length
187  const rows = canvas.length / 2
188  const words = new Uint32Array(columns * rows * 3)
189  for (let y = 0; y < rows; y++) {
190    for (let x = 0; x < columns; x++) {
191      const top = canvas[y * 2]?.[x] ?? null
192      const bottom = canvas[y * 2 + 1]?.[x] ?? null
193      const cell =
194        top !== null ? [0x2580, top, bottom ?? ground]
195        : bottom !== null ? [0x2584, bottom, ground]
196        : [0x20, ground, ground]
197      words.set(cell, (y * columns + x) * 3)
198    }
199  }
200
201  return btoa(String.fromCharCode(...new Uint8Array(words.buffer)))
202}
203
204/** The largest box of cells of a portrait's shape in `columns` by `room`: its art fields are its device pixels. */
205function portraitBox(cell: { w: number; h: number }, columns: number, room: number, shape: number): ReturnType<typeof boxIn> {
206  let wide = columns
207  let rows = Math.round((wide * cell.w) / shape / cell.h)
208  if (rows > room) {
209    rows = room
210    wide = Math.max(1, Math.min(columns, Math.round((rows * cell.h * shape) / cell.w)))
211  }
212
213  return { columns: wide, rows: Math.max(1, rows), width: wide * cell.w, height: Math.max(1, rows) * cell.h }
214}
215
avatar/hooks/look.ts 12 lines
1// What a mascot is made of: pixels, and a form by model. Which family a theme calls for is families.ts's.
2export type Pixels = readonly (readonly (number | null)[])[]
3export type Form = 'opus' | 'sonnet' | 'haiku' | 'fable'
4/** The cell-block stage's mini sets (minis.ts). */
5export type Family = 'kirby' | 'clawd'
6
7export const FORMS: readonly Form[] = ['opus', 'sonnet', 'haiku', 'fable']
8
9/** A model id or alias as a form; one that names no family is `fallback`. */
10export const formOf = (id: string, fallback: Form = 'opus'): Form =>
11  FORMS.find(form => id.toLowerCase().includes(form)) ?? fallback
12