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.

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):
| Part | Folder | What it draws |
|---|---|---|
| Avatar | avatar/ | 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 map | flow/ | The "Flow map" pane: workflow runs, plain subagents, the step plan, the wayfinder map. Registers the tool mcp__loadout__steps. |
| Frames | frames/ | 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). |
| Typed | Does | ||
|---|---|---|---|
/loadout | Opens 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> here | This 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 follow | The line that makes a terminal with its own theme follow the global one again. | ||
/loadout avatar \ | flow | Opens 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 status | Theme 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 help | This 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.
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:
| File | Used by | Rewritten by |
|---|---|---|
~/.claude/themes/loadout.json | Every terminal that follows the global setting, which is "theme": "custom:loadout". | /loadout <theme>, the picker. |
~/.claude/themes/loadout-<tag>.json | The 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.
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.
~/.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.themes/<slug>.json, in this folder. Without that folder the built-in tables answer.assets/<slot>/, one folder per character (below).To add a theme, see THEMES.md.
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.
| Model | Ghostrunner (ghostrunner-…) | Cyberpunk Edgerunners (edgerunners-…) |
|---|---|---|
Opus (…-opus) | Jack | Lucy |
Sonnet (…-sonnet) | Hel | David Martinez |
Haiku (…-haiku) | Mitra | Rebecca |
Fable (…-fable) | Mara the Keymaster | Adam 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:
assets/<slot>/<mood>-NN.png, twelve per mood), else the drawn character: the ninja for Ghostrunner, the thief for Edgerunners.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.
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.
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
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.
hooks/register.tsx 67 lines1// 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}
67avatar/hooks/register.tsx 1676 lines1import { 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 lines1import { 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}
927frames/hooks/register.tsx 273 lines1import { 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}
273avatar/hooks/canvas.ts 126 lines1// 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}
126avatar/hooks/cyber.ts 1316 lines1// 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 lines1import 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}
89avatar/hooks/families.ts 281 lines1// 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}
281avatar/hooks/hud.ts 577 lines1// 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}
577avatar/hooks/hudpane.tsx 149 lines1// 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}
149avatar/hooks/layout.ts 215 lines1// 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}
215avatar/hooks/look.ts 12 lines1// 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