A little pixel monster in place of the spinner: animated by what Claude is doing, with a visual timer, a stack of token coins, usage charts and Omarchy-style…

A fork of fabandrade88/tamagoxtix 2.1.6 (MIT, by Fabricio Oliveira de Andrade), kept here with the other mods; see CHANGELOG.md for what differs. The herdr plugin is in herdr-tamagoxtix.
A pixel monster in place of Claude Code's spinner: it acts out what Claude is doing, hangs out above the prompt between turns, cheers you on while you type a prompt, and colors everything in one palette.
claude plugin install tamagoxtix@schreibse-mods # or clone, see the repo README
Run it in a terminal, then open Claude Code in any folder. Then /tamagoxtix help lists every command, and /tamagoxtix doctor checks what it needs (Node.js, git, a Nerd Font) — nothing is installed for you. Needs Claude Code 2.1.300+ (the plugin uses Claude Code's function hooks, an early-access API) and Node.js 18+.
Screenshots, commands and details: the main README.
Everything TamaGoxtix does happens on your machine, in readable source in this folder. It sends no analytics and no telemetry, reads no credentials (no tokens, keys or passwords), contacts no host, and never takes commands from a server.
Always by argument list ($.process.run), never through a shell, and never a command that comes from the network:
| Program | When | Why |
|---|---|---|
node scripts/usage-node.mjs --days 14 (this folder) | at the start of a session and after each turn | to sum token usage from Claude Code's transcripts |
herdr pane get <pane>, herdr pane report-metadata <pane> --source tamagoxtix --token monster=<icon>, herdr plugin action invoke tamagoxtix.sidebar-refresh, herdr plugin action invoke tamagoxtix.theme-refresh | only inside a herdr pane (HERDR_PANE_ID set) | the monster's identity for the pane, its face in herdr's sidebar, and the herdr plugin's palette |
node --version, git --version | only when you run /tamagoxtix doctor | to say what is installed |
Each command is written out in full at its call in register.tsx; <pane> is herdr's own pane id. The status line (only when you turn it on) runs git itself for the branch.
HOME (or USERPROFILE), XDG_CONFIG_HOME, XDG_STATE_HOME and CLAUDE_CONFIG_DIR, to find the folders below, and HERDR_PANE_ID, to know it runs inside herdr. Nothing else from the environment.
~/.claude/projects: only each response's token counts, ids and timestamp; message text is never kept~/.claude/settings.json, Claude Code's settings file: only its statusLine and theme keys are looked at, to show in /tamagoxtix help what is on and to change them when you ask (below)~/.config/omarchy/current/theme), only with the omarchy palette and only where the file existsEvery path is fixed and named in full at its write; nothing is written into your project. (~/.config and ~/.local/state stand for $XDG_CONFIG_HOME and $XDG_STATE_HOME when those are set, and ~/.claude for $CLAUDE_CONFIG_DIR.)
~/.config/tamagoxtix/config.json: your choices (palette, icons, toggles)~/.local/state/tamagoxtix/rerolls.json and icons.json: your monster's rerolls and icon pick (/tamagoxtix reroll, /tamagoxtix icon)~/.local/state/tamagoxtix/limits.json: the last 5-hour and weekly limit readings, for the herdr popup~/.local/state/tamagoxtix/usage-cache.json: written by scripts/usage-node.mjs, a cache of short ids, timestamps and token counts~/.local/state/tamagoxtix/statusline/statusline.mjs, palettes.mjs, powerline.mjs and forge.mjs: copies of this folder's scripts/ files, in a place that survives plugin updates, only with the status line onconfig.json, rerolls.json, icons.json and limits.json copied from tamaclaude/ to tamagoxtix/ in the same folders, never over a file already thereThis mod edits Claude Code's settings file, ~/.claude/settings.json, but only when you ask it to, with these commands:
/tamagoxtix statusline on|off sets or restores the statusLine key (pointing it at the status line copy above), and /tamagoxtix theme on|off sets or restores the theme key. No other key changes. A file that isn't valid JSON is left alone; the first change keeps a copy as ~/.claude/settings.json.tamagoxtix-backup; the file is read back and restored if anything went wrong./tamagoxtix theme on also writes the chat theme file ~/.claude/themes/tamagoxtix.json, rewritten when you switch palettes while it is on.Before uninstalling, run /tamagoxtix statusline off and /tamagoxtix theme off to give those settings back.
| Hook | What it does |
|---|---|
session.start, session.end | loads your palette and monster; registers /tamagoxtix |
turn.start, turn.complete | starts and stops the spinner's clock; refreshes usage |
session.measure | keeps Claude Code's 5-hour and weekly limit readings for the usage card |
prompt.edit | looks at whether the prompt box holds a prompt, to cheer you on while you type; it passes every edit on unchanged and never keeps or sends the text |
ui.render (Spinner, AbovePrompt, CommandOutput of /tamagoxtix) | draws the monster, the band above the prompt and the cards |
command.run (only /tamagoxtix) | answers its own command; other commands are not touched |
It registers one command, /tamagoxtix, and no tools or agents. It answers no permission prompts and changes no other plugin's calls. The scripts/ files are Node.js programs it runs with node (above), not mod code.
MIT, see LICENSE.
register.tsx 1029 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { DEFAULT_ICON, iconByName, iconFor, ICONS, iconToken } from './lib/icons.mjs'
5import { makeMonster, slotColor, spriteRuns } from './lib/monsters.mjs'
6import {
7 claudeTheme,
8 OMARCHY_COLORS_PATH,
9 paletteChoices,
10 resolvePalette,
11} from './lib/palettes.mjs'
12import type { TamaLimit, TamaMonster, TamaPalette, TamaUsage } from './types'
13
14const palette = atom({ plugin: 'tamagoxtix', key: 'palette' } as const, null)
15const monster = atom({ plugin: 'tamagoxtix', key: 'monster' } as const, null)
16const turn = atom({ plugin: 'tamagoxtix', key: 'turn' } as const, null)
17const usage = atom({ plugin: 'tamagoxtix', key: 'usage' } as const, null)
18const limits = atom({ plugin: 'tamagoxtix', key: 'limits' } as const, [])
19const idleSince = atom({ plugin: 'tamagoxtix', key: 'idleSince' } as const, 0)
20const memoryLine = atom({ plugin: 'usage-percent', key: 'memory' } as const, null)
21const showPet = atom({ plugin: 'tamagoxtix', key: 'showPet' } as const, true)
22const nodeMissing = atom({ plugin: 'tamagoxtix', key: 'nodeMissing' } as const, false)
23const drafting = atom({ plugin: 'tamagoxtix', key: 'drafting' } as const, false)
24// The agent session herdr recorded for this pane, once it has one.
25const herdrSeed = atom({ plugin: 'tamagoxtix', key: 'herdrSeed' } as const, null)
26
27type Config = { palette?: string; sidebar?: boolean; animate?: boolean; [key: string]: unknown }
28
29// ---- the draft in the prompt box ----
30
31// A prompt is a draft with text that is not a slash command or a shell line;
32// the band redraws only when that flips, not on every key.
33function isPrompt(text: string): boolean {
34 return text.trim().length > 0 && !/^\s*[/!]/.test(text)
35}
36
37async function setDrafting($: EngineInterface, text: string): Promise<void> {
38 const now = isPrompt(text)
39 if (now !== (await read($, drafting))) await update($, drafting, () => now)
40}
41
42// ---- files shared with the herdr plugin ----
43
44async function home($: EngineInterface): Promise<string> {
45 const unix = await $.env.get('HOME')
46 if (unix) return unix
47 const windows = await $.env.get('USERPROFILE')
48 return windows ?? '.'
49}
50
51// The folders it reads and writes. Every write names its file in full at the
52// call (`${base}/tamagoxtix/config.json`), so what it writes reads off the source.
53
54// $XDG_CONFIG_HOME, else ~/.config
55async function configBase($: EngineInterface): Promise<string> {
56 const xdg = await $.env.get('XDG_CONFIG_HOME')
57 return xdg ?? `${await home($)}/.config`
58}
59
60// $XDG_STATE_HOME, else ~/.local/state
61async function stateBase($: EngineInterface): Promise<string> {
62 const xdg = await $.env.get('XDG_STATE_HOME')
63 return xdg ?? `${await home($)}/.local/state`
64}
65
66// Claude Code's own folder: $CLAUDE_CONFIG_DIR, else ~/.claude
67async function claudeDir($: EngineInterface): Promise<string> {
68 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
69 return custom ?? `${await home($)}/.claude`
70}
71
72async function readJson($: EngineInterface, path: string, fallback: unknown): Promise<any> {
73 try {
74 const text = await $.fs.read(path)
75 return JSON.parse(text)
76 } catch {
77 return fallback
78 }
79}
80
81async function readConfig($: EngineInterface): Promise<Config> {
82 return { palette: 'beach', sidebar: false, ...(await readJson($, `${await configBase($)}/tamagoxtix/config.json`, {})) }
83}
84
85async function writeConfig($: EngineInterface, patch: Config): Promise<Config> {
86 const merged = { ...(await readConfig($)), ...patch }
87 const base = await configBase($)
88 await $.fs.write(`${base}/tamagoxtix/config.json`, JSON.stringify(merged, null, 2) + '\n')
89 return merged
90}
91
92// Omarchy's colors are read only for the `omarchy` palette (or no choice
93// yet), and only where the file exists: elsewhere, macOS and Windows
94// included, nothing is read.
95async function loadPalette($: EngineInterface, choice?: string): Promise<TamaPalette> {
96 const pick = choice ?? (await readConfig($)).palette
97 let omarchy: string | undefined
98 if (pick === undefined || pick === 'omarchy') {
99 const path = `${await home($)}/${OMARCHY_COLORS_PATH}`
100 try {
101 const there = await $.fs.exists(path)
102 if (there) omarchy = await $.fs.read(path)
103 } catch {}
104 }
105 return resolvePalette(pick, omarchy) as TamaPalette
106}
107
108// ---- running programs ----
109
110// What a program printed, or null when it failed. Each caller writes the
111// whole command at its own `$.process.run` call, in a try: a program that is
112// not installed rejects.
113type RunResult = { exitCode: number; stdout: string; stderr?: string }
114
115// ---- herdr ----
116
117async function herdrPane($: EngineInterface): Promise<string | undefined> {
118 return $.env.get('HERDR_PANE_ID')
119}
120
121// Asks herdr to redraw the tamagoxtix sidebar and look (inside herdr only).
122async function refreshHerdr($: EngineInterface): Promise<void> {
123 const pane = await herdrPane($)
124 if (!pane) return
125 try {
126 await $.process.run(['herdr', 'plugin', 'action', 'invoke', 'tamagoxtix.sidebar-refresh'], { timeoutMs: 5000 })
127 await $.process.run(['herdr', 'plugin', 'action', 'invoke', 'tamagoxtix.theme-refresh'], { timeoutMs: 5000 })
128 } catch {}
129}
130
131// Sends herdr's sidebar the monster's face for this pane.
132async function reportFace($: EngineInterface, pane: string, token: string): Promise<void> {
133 try {
134 await $.process.run(['herdr', 'pane', 'report-metadata', pane, '--source', 'tamagoxtix', '--token', `monster=${token}`], { timeoutMs: 5000 })
135 } catch {}
136}
137
138// The same seed the herdr plugin uses: the agent session herdr recorded for
139// the pane (this Claude session), else the session id.
140// Asked of herdr once per session: kept once herdr has recorded one.
141async function seedKey($: EngineInterface): Promise<string> {
142 const pane = await herdrPane($)
143 if (pane) {
144 const known = await read($, herdrSeed)
145 if (known?.pane === pane) return known.key
146 try {
147 const { stdout } = await $.process.run(['herdr', 'pane', 'get', pane], { timeoutMs: 5000 })
148 const key = JSON.parse(stdout)?.result?.pane?.agent_session?.value
149 if (typeof key === 'string' && key) {
150 await update($, herdrSeed, () => ({ pane, key }))
151 return key
152 }
153 } catch {}
154 return pane
155 }
156 return $.session.id()
157}
158
159// The monster and its sidebar icon, after any reroll or pick; the pixel
160// monster wears its icon's color so both read as the same critter.
161async function loadMonster($: EngineInterface): Promise<TamaMonster> {
162 const key = await seedKey($)
163 const state = await stateBase($)
164 const rolls = await readJson($, `${state}/tamagoxtix/rerolls.json`, {})
165 const picks = await readJson($, `${state}/tamagoxtix/icons.json`, {})
166 const icon = iconFor(key, { picks, rolls })
167 const n = rolls[key] ?? 0
168 const pet = makeMonster(n ? `${key}#${n}` : key) as TamaMonster
169 return { ...pet, color: icon.slot, icon: icon.name }
170}
171
172async function sidebarToken($: EngineInterface, pet: TamaMonster, state: string, frame: number | null = null) {
173 const icon = iconByName(pet.icon) ?? DEFAULT_ICON
174 const set = String((await readConfig($)).icons ?? 'nerd')
175 return iconToken(icon, state, { set, frame })
176}
177
178async function paintSidebar($: EngineInterface, pet: TamaMonster, state: string) {
179 const pane = await herdrPane($)
180 if (!pane || (await readConfig($)).sidebar !== true) return
181 await reportFace($, pane, await sidebarToken($, pet, state))
182}
183
184// ---- usage ----
185
186async function refreshUsage($: EngineInterface) {
187 let result: { exitCode: number; stdout: string } | null = null
188 try {
189 result = await $.process.run(['node', `${$.plugin.root}/scripts/usage-node.mjs`, '--days', '14'], { timeoutMs: 30_000 })
190 } catch {
191 // `node` didn't start: say so where the numbers would be
192 await update($, nodeMissing, () => true)
193 return
194 }
195 await update($, nodeMissing, () => false)
196 try {
197 if (result.exitCode === 0) await update($, usage, () => JSON.parse(result.stdout) as TamaUsage)
198 } catch {}
199}
200
201// ---- /tamagoxtix doctor: what TamaGoxtix needs, and what is missing ----
202
203type Check = { name: string; showVersion: boolean; required: boolean; why: string; install: string; ok?: (out: string) => boolean }
204
205const CHECKS: Check[] = [
206 {
207 name: 'Node.js 18+',
208 showVersion: true,
209 required: true,
210 why: 'usage numbers, the usage card and the status line',
211 install: 'https://nodejs.org · brew install node · sudo apt install nodejs · sudo pacman -S nodejs · winget install OpenJS.NodeJS',
212 ok: out => Number(out.replace(/^v/, '').split('.')[0]) >= 18,
213 },
214 { name: 'git', showVersion: true, required: true, why: 'the status line\'s branch', install: 'https://git-scm.com' },
215]
216
217type Probe = { found: boolean; version: string }
218
219const MISSING: Probe = { found: false, version: '' }
220
221// Whether a program answered, and the first line it printed.
222function probe(result: RunResult, check: Check): Probe {
223 const version = (result.stdout || result.stderr || '').trim().split('\n')[0] ?? ''
224 return { found: result.exitCode === 0 && (check.ok ? check.ok(version) : true), version }
225}
226
227// The two programs the doctor checks, each command written out in full; a
228// program that is not installed rejects and reads as missing.
229async function probeAll($: EngineInterface): Promise<Probe[]> {
230 const [node, git] = CHECKS as [Check, Check]
231 const DOCTOR = { timeoutMs: 5000 }
232 const found: Probe[] = [MISSING, MISSING]
233 try {
234 const answer = await $.process.run(['node', '--version'], DOCTOR)
235 found[0] = probe(answer, node)
236 } catch {}
237 try {
238 const answer = await $.process.run(['git', '--version'], DOCTOR)
239 found[1] = probe(answer, git)
240 } catch {}
241 return found
242}
243
244// scene: 20 pixel columns, a space, the 11-wide effects column and the gap.
245// Under `min` columns the band and the spinner become one line.
246const BAND_WIDTH = { min: 44, scene: 34 }
247
248async function doctorText($: EngineInterface, columns = 80): Promise<string> {
249 const lines = ['TamaGoxtix doctor', '']
250 const probes = await probeAll($)
251 for (const [i, check] of CHECKS.entries()) {
252 const { found, version } = probes[i] ?? { found: false, version: '' }
253 const mark = found ? '✔' : check.required ? '✖' : '○'
254 lines.push(`${mark} ${check.name}${found && version && check.showVersion ? ` (${version})` : ''}`)
255 if (!found) lines.push(` needed for ${check.why}${check.required ? '' : ' (optional)'}`, ` install: ${check.install}`)
256 }
257 const pet = await read($, showPet)
258 const fits = columns >= BAND_WIDTH.min ? 'the scene' : `one line (the scene needs ${BAND_WIDTH.min} columns)`
259 lines.push(
260 '',
261 pet ? 'Monster band: on (/tamagoxtix pet off hides it)' : '✖ Monster band: hidden by /tamagoxtix pet off (pet on brings it back)',
262 ` terminal: ${columns} columns, room for ${fits}`,
263 ' the full band needs 3 free rows above the prompt; with 1 or 2 it shrinks to one line,',
264 ' and it steps aside while Claude works or a survey is shown',
265 )
266 const icons = String((await readConfig($)).icons ?? 'nerd')
267 lines.push(
268 '',
269 `Icons: ${icons === 'nerd' ? 'Nerd Font' : 'plain symbols'} (/tamagoxtix icons nerd|plain)`,
270 'Nerd Font check, these should be icons, not boxes: \u{f167a} \u{f024b} \uf418 \u{f09d1} \ue0b0 \ue0b1',
271 'Boxes? Install a Nerd Font (https://www.nerdfonts.com) and pick it in your terminal, or run /tamagoxtix icons plain.',
272 )
273 return lines.join('\n')
274}
275
276function formatCount(n: number): string {
277 if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
278 if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
279 if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
280 return String(Math.round(n))
281}
282
283function resetIn(now: number, iso?: string): string {
284 const ms = iso ? Date.parse(iso) - now : NaN
285 if (!Number.isFinite(ms) || ms <= 0) return ''
286 const hrs = Math.floor(ms / 3_600_000)
287 const mins = Math.floor((ms % 3_600_000) / 60_000)
288 if (hrs >= 24) return `${Math.floor(hrs / 24)}d ${hrs % 24}h`
289 return hrs ? `${hrs}h ${mins}m` : `${mins}m`
290}
291
292const LEVELS = ' ▁▂▃▄▅▆▇█'
293
294function columnRows(values: number[], height: number): string[][] {
295 const max = Math.max(1, ...values)
296 const rows: string[][] = []
297 for (let r = height - 1; r >= 0; r--) {
298 rows.push(
299 values.map(v => {
300 const level = Math.max(0, Math.min(8, Math.round(((v / max) * height - r) * 8)))
301 return LEVELS[level] ?? ' '
302 }),
303 )
304 }
305 return rows
306}
307
308// the same meter as the spinner's and the band's
309function meter(fraction: number, width: number): [string, string] {
310 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
311 return ['▰'.repeat(filled), '▱'.repeat(width - filled)]
312}
313
314// ---- palette switching ----
315
316async function applyPalette($: EngineInterface, choice: string): Promise<TamaPalette> {
317 await writeConfig($, { palette: choice })
318 const chosen = await loadPalette($, choice)
319 await update($, palette, () => chosen)
320 // the chat theme file is rewritten only where the chat uses it
321 if ((await readClaudeSettings($)).theme === 'custom:tamagoxtix') await writeChatTheme($, chosen)
322 await refreshHerdr($)
323 return chosen
324}
325
326// The chat theme file, ~/.claude/themes/tamagoxtix.json, from the palette.
327async function writeChatTheme($: EngineInterface, pal: TamaPalette): Promise<void> {
328 const dir = await claudeDir($)
329 await $.fs.write(`${dir}/themes/tamagoxtix.json`, JSON.stringify(claudeTheme(pal), null, 2) + '\n')
330}
331
332// Loads what the drawings read. Runs at the session's start and again after
333// a /clear, which leaves the session's values behind without a new start.
334// TamaGoxtix was called tamaclaude before 2.0: carry its files over once
335// (tamaclaude/<file> to tamagoxtix/<file>, never over one already there),
336// and point Claude Code's settings at the renamed status line and theme.
337// The old file's text, when there is one and no new file yet; else null.
338async function legacyText($: EngineInterface, from: string, to: string): Promise<string | null> {
339 try {
340 const hasOld = await $.fs.exists(from)
341 const hasNew = await $.fs.exists(to)
342 if (!hasOld || hasNew) return null
343 return await $.fs.read(from)
344 } catch {
345 return null
346 }
347}
348
349async function migrateLegacy($: EngineInterface) {
350 const base = await configBase($)
351 const state = await stateBase($)
352 const config = await legacyText($, `${base}/tamaclaude/config.json`, `${base}/tamagoxtix/config.json`)
353 if (config !== null) await $.fs.write(`${base}/tamagoxtix/config.json`, config)
354 const rerolls = await legacyText($, `${state}/tamaclaude/rerolls.json`, `${state}/tamagoxtix/rerolls.json`)
355 if (rerolls !== null) await $.fs.write(`${state}/tamagoxtix/rerolls.json`, rerolls)
356 const icons = await legacyText($, `${state}/tamaclaude/icons.json`, `${state}/tamagoxtix/icons.json`)
357 if (icons !== null) await $.fs.write(`${state}/tamagoxtix/icons.json`, icons)
358 const limitsText = await legacyText($, `${state}/tamaclaude/limits.json`, `${state}/tamagoxtix/limits.json`)
359 if (limitsText !== null) await $.fs.write(`${state}/tamagoxtix/limits.json`, limitsText)
360 const settings = await readClaudeSettings($)
361 const oldLine = JSON.stringify(settings.statusLine ?? '').includes('/tamaclaude/statusline/')
362 const oldTheme = settings.theme === 'custom:tamaclaude'
363 if (!oldLine && !oldTheme) return
364 const script = oldLine ? await installStatuslineFiles($) : null
365 if (oldTheme) await writeChatTheme($, await loadPalette($))
366 await editClaudeSettings($, s => {
367 if (script) s.statusLine = { type: 'command', command: `node "${script}"`, padding: 0, refreshInterval: 30 }
368 if (oldTheme) s.theme = 'custom:tamagoxtix'
369 })
370}
371
372async function loadSession($: EngineInterface) {
373 try {
374 await migrateLegacy($)
375 } catch {}
376 const [pal, pet] = [await loadPalette($), await loadMonster($)]
377 await update($, palette, () => pal)
378 await update($, monster, () => pet)
379 const now = await $.clock.now()
380 await update($, idleSince, () => now)
381 const saved = await readJson($, `${await stateBase($)}/tamagoxtix/limits.json`, {})
382 if (saved.windows?.length) await update($, limits, () => saved.windows ?? [])
383 const pref = await $.store.get('showPet')
384 await update($, showPet, () => pref !== false)
385 void refreshUsage($)
386}
387
388// ---- the powerline status line ----
389
390// Copies the status line script (and the three files it imports) from the
391// plugin's scripts/ to ~/.local/state/tamagoxtix/statusline/, a folder that
392// survives plugin updates (the plugin's own folder moves with each version),
393// and returns the script's path.
394async function installStatuslineFiles($: EngineInterface): Promise<string> {
395 const dir = `${await stateBase($)}/tamagoxtix/statusline`
396 const src = `${$.plugin.root}/scripts`
397 await $.fs.write(`${dir}/statusline.mjs`, await $.fs.read(`${src}/statusline.mjs`))
398 await $.fs.write(`${dir}/palettes.mjs`, await $.fs.read(`${src}/palettes.mjs`))
399 await $.fs.write(`${dir}/powerline.mjs`, await $.fs.read(`${src}/powerline.mjs`))
400 await $.fs.write(`${dir}/forge.mjs`, await $.fs.read(`${src}/forge.mjs`))
401 return `${dir}/statusline.mjs`
402}
403
404type ClaudeSettings = { statusLine?: unknown; theme?: unknown; [key: string]: unknown }
405
406// Changes Claude Code's own settings.json, carefully, since it holds
407// everything else the person configured:
408// - a file that is not valid JSON is left alone (never replaced by ours);
409// - the first change keeps a copy beside it (settings.json.tamagoxtix-backup);
410// - only the keys `change` touches differ, and the result is read back: when
411// it does not parse, the file the person had is put back.
412// Returns null on success, else why nothing was changed.
413// Only the commands `/tamagoxtix statusline on|off` and `/tamagoxtix theme
414// on|off` (and the one-time tamaclaude migration) call this; it changes the
415// `statusLine` or `theme` key of ~/.claude/settings.json and nothing else.
416async function editClaudeSettings($: EngineInterface, change: (s: ClaudeSettings) => void): Promise<string | null> {
417 const dir = await claudeDir($)
418 let original = ''
419 let settings: ClaudeSettings = {}
420 const hasSettings = await $.fs.exists(`${dir}/settings.json`)
421 if (hasSettings) {
422 original = await $.fs.read(`${dir}/settings.json`)
423 if (original.trim()) {
424 try {
425 const parsed: unknown = JSON.parse(original)
426 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object')
427 settings = parsed as ClaudeSettings
428 } catch {
429 return `${dir}/settings.json is not valid JSON, so tamagoxtix left it alone. Fix it (or remove the broken part) and run the command again.`
430 }
431 }
432 const hasBackup = await $.fs.exists(`${dir}/settings.json.tamagoxtix-backup`)
433 if (original && !hasBackup) await $.fs.write(`${dir}/settings.json.tamagoxtix-backup`, original)
434 }
435 change(settings)
436 await $.fs.write(`${dir}/settings.json`, JSON.stringify(settings, null, 2) + '\n')
437 try {
438 const written = await $.fs.read(`${dir}/settings.json`)
439 JSON.parse(written)
440 } catch {
441 if (original) await $.fs.write(`${dir}/settings.json`, original)
442 return `Could not write ${dir}/settings.json; it was put back as it was.`
443 }
444 return null
445}
446
447async function readClaudeSettings($: EngineInterface): Promise<ClaudeSettings> {
448 return readJson($, `${await claudeDir($)}/settings.json`, {})
449}
450
451async function setStatusline($: EngineInterface, enable: boolean): Promise<string> {
452 const config = await readConfig($)
453 const before = await readClaudeSettings($)
454 if (enable) {
455 const script = await installStatuslineFiles($)
456 const ours = { type: 'command', command: `node "${script}"`, padding: 0, refreshInterval: 30 }
457 const current = JSON.stringify(before.statusLine ?? null)
458 const keepPrevious = config.statusline !== true && !current.includes('tamagoxtix')
459 const refused = await editClaudeSettings($, s => {
460 s.statusLine = ours
461 })
462 if (refused) return refused
463 await writeConfig($, { statusline: true, ...(keepPrevious ? { previousStatusLine: before.statusLine ?? null } : {}) })
464 return 'Powerline status line on: model, folder, branch, context, limits and time. /tamagoxtix statusline off restores yours.'
465 }
466 const refused = await editClaudeSettings($, s => {
467 if (config.previousStatusLine) s.statusLine = config.previousStatusLine
468 else delete s.statusLine
469 })
470 if (refused) return refused
471 await writeConfig($, { statusline: false, previousStatusLine: null })
472 return 'Status line restored.'
473}
474
475// ---- chat colors: a Claude Code custom theme from the palette ----
476
477// /config takes only the built-in themes, so the theme is set in
478// settings.json the way Omarchy sets its own; Claude Code picks it up live.
479async function setChatTheme($: EngineInterface, enable: boolean): Promise<string> {
480 const pal = (await read($, palette)) ?? (await loadPalette($))
481 if (enable) {
482 const before = await readClaudeSettings($)
483 const current = typeof before.theme === 'string' ? before.theme : undefined
484 await writeChatTheme($, pal)
485 const refused = await editClaudeSettings($, s => {
486 s.theme = 'custom:tamagoxtix'
487 })
488 if (refused) return refused
489 await writeConfig($, { chatTheme: true, ...(current && current !== 'custom:tamagoxtix' ? { previousTheme: current } : {}) })
490 return `Chat colors follow the ${pal.label} palette. /tamagoxtix theme off goes back.`
491 }
492 const previous = (await readConfig($)).previousTheme
493 const refused = await editClaudeSettings($, s => {
494 if (typeof previous === 'string' && previous) s.theme = previous
495 else delete s.theme
496 })
497 if (refused) return refused
498 await writeConfig($, { chatTheme: false })
499 return 'Chat colors back to your previous theme.'
500}
501
502// ---- /tamagoxtix help ----
503
504type HelpRow = { command: string; does: string; state?: boolean }
505type HelpSection = { title: string; rows: HelpRow[] }
506
507async function helpSections($: EngineInterface): Promise<HelpSection[]> {
508 const config = await readConfig($)
509 const settings = await readClaudeSettings($)
510 const pal = (await read($, palette)) ?? (await loadPalette($))
511 const chatTheme = settings.theme === 'custom:tamagoxtix'
512 const statusline = JSON.stringify(settings.statusLine ?? '').includes('tamagoxtix')
513 const pet = await read($, showPet)
514 return [
515 {
516 title: `colors · palette: ${pal.label}`,
517 rows: [
518 { command: '/tamagoxtix palette', does: 'list the palettes' },
519 { command: '/tamagoxtix palette <name>', does: `switch: ${paletteChoices().join(' ')}` },
520 { command: '/tamagoxtix palette omarchy', does: 'follow your Omarchy theme' },
521 { command: '/tamagoxtix theme on|off', does: 'chat colors from the palette', state: chatTheme },
522 { command: '/tamagoxtix statusline on|off', does: 'powerline status line', state: statusline },
523 { command: '/tamagoxtix icons nerd|plain', does: 'Nerd Font icons, or plain symbols any font has', state: config.icons !== 'plain' && config.icons !== 'emoji' },
524 ],
525 },
526 {
527 title: 'your monster',
528 rows: [
529 { command: '/tamagoxtix pet on|off', does: 'the monster above the prompt', state: pet },
530 { command: '/tamagoxtix animate on|off', does: 'animated scenes, or a still frame', state: config.animate !== false },
531 { command: '/tamagoxtix reroll', does: 'a new monster' },
532 ],
533 },
534 {
535 title: 'usage',
536 rows: [
537 { command: '/tamagoxtix', does: 'the usage card: today, session, week, limits, charts' },
538 { command: '/tamagoxtix doctor', does: 'check Node.js, git and the font' },
539 ],
540 },
541 {
542 title: 'herdr (herdr plugin action invoke …)',
543 rows: [
544 { command: 'tamagoxtix.stats', does: 'usage popup · p switches palette' },
545 { command: 'tamagoxtix.palette-next', does: 'next palette' },
546 { command: 'tamagoxtix.theme-install|uninstall', does: 'the tamagoxtix look for herdr', state: config.herdrTheme === true },
547 { command: 'tamagoxtix.theme-follow-herdr', does: "its colors from herdr's theme", state: config.herdrColors !== 'palette' },
548 { command: 'tamagoxtix.theme-follow-palette', does: 'its colors from the palette', state: config.herdrColors === 'palette' },
549 ],
550 },
551 {
552 title: 'before uninstalling',
553 rows: [
554 { command: '/tamagoxtix statusline off', does: 'give the status line back' },
555 { command: '/tamagoxtix theme off', does: 'give the chat theme back' },
556 { command: 'tamagoxtix.theme-uninstall', does: "give herdr's look back (in herdr)" },
557 ],
558 },
559 ]
560}
561
562async function helpText($: EngineInterface): Promise<string> {
563 const lines = ['tamagoxtix commands:']
564 for (const section of await helpSections($)) {
565 lines.push('', section.title)
566 for (const row of section.rows) {
567 const state = row.state === undefined ? '' : row.state ? ' [on]' : ' [off]'
568 lines.push(` ${row.command.padEnd(36)} ${row.does}${state}`)
569 }
570 }
571 return lines.join('\n')
572}
573
574export const register: Register = on => {
575 let mainTurn: string | null = null
576 let sidebarTimer: Timer | null = null
577
578 on('session.start', async ($, e, next) => {
579 const started = await next(e)
580 await loadSession($)
581 if ((await readConfig($)).statusline === true) {
582 try {
583 await installStatuslineFiles($)
584 } catch {}
585 }
586 await $.command.register({
587 name: 'tamagoxtix',
588 description: 'Token usage charts, palettes and your monster',
589 argumentHint: '[help | doctor | palette [name] | theme on|off | statusline on|off | icons nerd|plain | pet on|off | animate on|off | reroll]',
590 })
591 return started
592 })
593
594 on('session.end', async ($, e, next) => {
595 const ended = await next(e)
596 // a /clear drops the session's values after its hooks ran: load them
597 // again once it has
598 if (e.reason === 'clear') {
599 $.clock.after(400, () => void loadSession($))
600 $.clock.after(2000, () => void loadSession($))
601 }
602 return ended
603 })
604
605 // ---- the turn the spinner draws ----
606
607 on('turn.start', async ($, e, next) => {
608 const started = await next(e)
609 if (e.agentId) return started
610 mainTurn = e.turnId
611 // the prompt was sent: no draft in the box anymore
612 await update($, drafting, () => false)
613 const startedAt = await $.clock.now()
614 await update($, turn, () => ({ startedAt }))
615
616 // the icon may have changed since (a pick, a reroll, herdr spreading
617 // icons so no two agents share one): read it again each turn
618 const pet = await loadMonster($)
619 await update($, monster, () => pet)
620 // the palette too: an Omarchy theme change shows up on the next turn
621 const pal = await loadPalette($)
622 await update($, palette, () => pal)
623 const pane = await herdrPane($)
624 if (pane && (await readConfig($)).sidebar === true) {
625 sidebarTimer?.cancel()
626 const icon = iconByName(pet.icon) ?? DEFAULT_ICON
627 const set = String((await readConfig($)).icons ?? 'nerd')
628 let frame = 0
629 // herdr's sidebar is plain text: send it a new face a few times a second
630 sidebarTimer = $.clock.every(300, () => {
631 frame++
632 void reportFace($, pane, iconToken(icon, 'working', { set, frame }))
633 })
634 }
635 return started
636 })
637
638 on('turn.complete', async ($, e, next) => {
639 const done = await next(e)
640 const turnId = (e as { turnId?: string }).turnId
641 if (turnId && turnId !== mainTurn) return done
642 mainTurn = null
643 sidebarTimer?.cancel()
644 sidebarTimer = null
645 await update($, turn, () => null)
646 const now = await $.clock.now()
647 await update($, idleSince, () => now)
648 void refreshUsage($)
649 return done
650 })
651
652 // ---- rate limits: kept for the pane and written for herdr's popup ----
653
654 on('session.measure', async ($, e, next) => {
655 const measured = await next(e)
656 if (e.changed.includes('rateLimits')) {
657 const windows = e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
658 await update($, limits, () => windows as TamaLimit[])
659 try {
660 const state = await stateBase($)
661 const updatedAt = await $.clock.now()
662 await $.fs.write(`${state}/tamagoxtix/limits.json`, JSON.stringify({ updatedAt, windows }, null, 2))
663 } catch {}
664 }
665 return measured
666 })
667
668 // ---- the spinner ----
669
670 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
671 if ((e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
672 const pal = await read($, palette)
673 const pet = await read($, monster)
674 const current = await read($, turn)
675 if (!pal || !pet) return next(e)
676 const now = await $.clock.now()
677 const spinnerProps = {
678 monster: pet,
679 palette: pal,
680 mode: e.props.mode,
681 word: e.props.message ?? e.props.word,
682 startedAt: current?.startedAt ?? now,
683 animate: (await readConfig($)).animate !== false,
684 columns: e.viewport?.columns ?? 100,
685 status: (await read($, memoryLine)) ?? [],
686 }
687 const { Client } = $.ui.resolve(e)
688 return <Client module="./spinner.tsx" key="tamagoxtix-spinner" props={spinnerProps} />
689 })
690
691 // ---- while you type a prompt, the monster cheers you on ----
692
693 on('prompt.edit', async ($, e, next) => {
694 const result = await next(e)
695 await setDrafting($, result?.text ?? e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end))
696 return result
697 })
698
699 // ---- the idle band: the monster lives above the prompt between turns ----
700
701 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
702 if ((e.surface !== 'terminal' && e.surface !== 'desktop') || e.props.isWorking) return next(e)
703 // a survey owns the band; with under 3 rows the monster shrinks to a line
704 if (e.props.hasSurvey || e.props.maxRows < 1) return next(e)
705 const mode = e.props.maxRows >= 3 && e.props.bodyColumns >= BAND_WIDTH.min ? 'full' : 'compact'
706 const pal = await read($, palette)
707 const pet = await read($, monster)
708 const show = await read($, showPet)
709 const since = await read($, idleSince)
710 const data = await read($, usage)
711 if (!pal || !pet || !show) return next(e)
712 const now = await $.clock.now()
713 const missingNode = await read($, nodeMissing)
714 const cheering = await read($, drafting)
715 const face = await sidebarToken($, pet, 'idle')
716 const config = await readConfig($)
717 const status = await read($, memoryLine)
718 const idleProps = {
719 monster: pet,
720 palette: pal,
721 idleSince: since || now,
722 todayCount: data?.today.tokens ?? null,
723 columns: e.props.bodyColumns,
724 nodeMissing: missingNode,
725 cheering,
726 compact: mode === 'compact',
727 face,
728 animate: config.animate !== false,
729 status: status ?? [],
730 }
731 // the other mods' bands (running tasks, handover, …) go under the caption column, indented past
732 // the monster and told the width they have; in compact mode they stack under the line
733 const { Box, Client } = $.ui.resolve(e)
734 if (mode === 'compact') {
735 const others = await next(e)
736 return (
737 <Box flexDirection="column">
738 <Client module="./idle.tsx" key="tamagoxtix-idle" props={idleProps} />
739 {others}
740 </Box>
741 )
742 }
743 const columns = e.props.bodyColumns
744 const bodyColumns = columns - BAND_WIDTH.scene
745 const others = await next({ ...e, props: { ...e.props, bodyColumns } })
746 return (
747 <Box flexDirection="column">
748 <Client module="./idle.tsx" key="tamagoxtix-idle" props={idleProps} />
749 <Box flexDirection="column" paddingLeft={BAND_WIDTH.scene}>{others}</Box>
750 </Box>
751 )
752 })
753
754 // ---- /tamagoxtix help: the commands as a card, with what is on ----
755
756 on('ui.render', { component: 'CommandOutput', props: { command: 'tamagoxtix' } }, async ($, e, next) => {
757 const sub = e.props.args.trim().split(/\s+/)[0] ?? ''
758 if (sub !== 'help' || (e.surface !== 'terminal' && e.surface !== 'desktop') || e.props.isErrored) return next(e)
759 const { Box, Text } = $.ui.resolve(e)
760 const p = (await read($, palette)) ?? (resolvePalette('beach') as TamaPalette)
761 const sections = await helpSections($)
762 const width = Math.max(60, Math.min(96, (e.viewport?.columns ?? 100) - 4))
763 return (
764 <Box flexDirection="column" borderStyle="round" borderColor={p.muted} paddingX={2} width={width}>
765 <Text mono>
766 <Text mono color={p.orange} bold>
767 tamagoxtix
768 </Text>
769 <Text mono color={p.muted}> · help</Text>
770 </Text>
771 {sections.map(section => (
772 <Box flexDirection="column" marginTop={1}>
773 <Text mono>
774 <Text mono color={p.orange}>▍</Text>
775 <Text mono color={p.fg} bold>{` ${section.title}`}</Text>
776 </Text>
777 {section.rows.map(row => (
778 <Text mono>
779 <Text mono color={p.accent}>{` ${row.command.padEnd(36)}`}</Text>
780 <Text mono color={p.muted}>{row.does}</Text>
781 {row.state !== undefined && (
782 <Text mono color={row.state ? p.green : p.muted} bold={row.state}>
783 {row.state ? ' ● on' : ' ○ off'}
784 </Text>
785 )}
786 </Text>
787 ))}
788 </Box>
789 ))}
790 </Box>
791 )
792 })
793
794 // ---- /tamagoxtix: a usage card drawn in the transcript ----
795
796 on('ui.render', { component: 'CommandOutput', props: { command: 'tamagoxtix' } }, async ($, e, next) => {
797 const sub = e.props.args.trim().split(/\s+/)[0] || 'stats'
798 if (sub !== 'stats' || (e.surface !== 'terminal' && e.surface !== 'desktop') || e.props.isErrored) return next(e)
799 const { Box, Text } = $.ui.resolve(e)
800 const [pal, pet, data, windows] = [
801 await read($, palette),
802 await read($, monster),
803 await read($, usage),
804 await read($, limits),
805 ]
806 const p = pal ?? (resolvePalette('beach') as TamaPalette)
807 if (!data) return next(e)
808 const now = await $.clock.now()
809 const width = Math.max(60, Math.min(78, (e.viewport?.columns ?? 100) - 4))
810
811 const title = (text: string) => (
812 <Text mono>
813 <Text mono color={p.accent}>▍</Text>
814 <Text mono color={p.fg} bold>
815 {` ${text}`}
816 </Text>
817 </Text>
818 )
819
820 const sprite = pet
821 ? spriteRuns(pet, 'idle', 0).map(runs => (
822 <Text mono>
823 {runs.map(run => (
824 <Text
825 color={run.fg ? slotColor(pet, run.fg, p) : undefined}
826 backgroundColor={run.bg ? slotColor(pet, run.bg, p) : undefined}
827 >
828 {run.text}
829 </Text>
830 ))}
831 </Text>
832 ))
833 : []
834
835 const limitRow = (label: string, kind: string) => {
836 const l = windows.find(w => w.kind === kind)
837 const pct = l?.percentUsed ?? 0
838 const color = !l ? p.muted : pct >= 90 ? p.red : pct >= 70 ? p.orange : p.cyan
839 const [full, empty] = meter(pct / 100, 24)
840 const reset = resetIn(now, l?.resetsAt)
841 return (
842 <Text mono>
843 <Text mono color={p.fg}>{label.padEnd(8)}</Text>
844 <Text mono color={color}>{full}</Text>
845 <Text mono color={p.muted}>{empty}</Text>
846 <Text mono color={color} bold>
847 {l ? ` ${String(Math.round(pct)).padStart(3)}%` : ' —'}
848 </Text>
849 <Text mono color={p.muted}>{reset ? ` ⟳ ${reset}` : l ? '' : ' no reading yet'}</Text>
850 </Text>
851 )
852 }
853
854 // last 14 days: bars three cells wide, today in the accent color
855 const days = data.days
856 const dayRows = columnRows(days.map(d => d.tokens), 5)
857 const dayChart = dayRows.map(row => (
858 <Text mono>
859 {row.map((ch, i) => (
860 <Text mono color={i === row.length - 1 ? p.accent : p.blue}>{ch.repeat(3) + ' '}</Text>
861 ))}
862 </Text>
863 ))
864 const dayLabels = days.map(d => d.date.slice(8).padEnd(4)).join('')
865
866 // today by hour: one bar per hour, the current one in the accent color
867 const hourNow = new Date(now).getHours()
868 const hourRows = columnRows(data.today.hours.map(slot => slot.tokens), 3)
869 const hourChart = hourRows.map(row => (
870 <Text mono>
871 {row.map((ch, i) => (
872 <Text mono color={i === hourNow ? p.accent : p.green}>{ch.repeat(2) + ' '}</Text>
873 ))}
874 </Text>
875 ))
876 const hourLabels = data.today.hours.map(slot => (slot.hour % 6 === 0 ? String(slot.hour) : '').padEnd(3)).join('')
877
878 return (
879 <Box flexDirection="column" borderStyle="round" borderColor={p.muted} paddingX={2} paddingY={0} width={width}>
880 <Box flexDirection="row" gap={3}>
881 <Box flexDirection="column">{sprite}</Box>
882 <Box flexDirection="column">
883 <Text mono>
884 <Text mono color={p.accent} bold>
885 {pet?.name ?? 'tamagoxtix'}
886 </Text>
887 <Text mono color={p.muted}>{` · token usage · ${p.label}`}</Text>
888 </Text>
889 <Text mono>
890 <Text mono color={p.muted}>today </Text>
891 <Text mono color={p.yellow} bold>
892 {formatCount(data.today.tokens)}
893 </Text>
894 <Text mono color={p.muted}>{` session `}</Text>
895 <Text mono color={p.cyan} bold>
896 {formatCount(data.session?.tokens ?? 0)}
897 </Text>
898 <Text mono color={p.muted}>{` week `}</Text>
899 <Text mono color={p.magenta} bold>
900 {formatCount(data.week.tokens)}
901 </Text>
902 </Text>
903 <Text mono color={p.muted}>{`${formatCount(data.today.output)} written by Claude today`}</Text>
904 </Box>
905 </Box>
906 <Text mono> </Text>
907 {title('limits')}
908 {limitRow('5h', 'five_hour')}
909 {limitRow('week', 'seven_day')}
910 <Text mono> </Text>
911 <Text mono>
912 {title('last 14 days')}
913 <Text mono color={p.muted}>{` max ${formatCount(Math.max(...days.map(d => d.tokens)))}`}</Text>
914 </Text>
915 {dayChart}
916 <Text mono color={p.muted}>{dayLabels}</Text>
917 <Text mono> </Text>
918 {title('today by hour')}
919 {hourChart}
920 <Text mono color={p.muted}>{hourLabels}</Text>
921 <Text mono> </Text>
922 <Text mono color={p.muted}>/tamagoxtix help for all the commands</Text>
923 </Box>
924 )
925 })
926
927 // ---- /tamagoxtix ----
928
929 on('command.run', { command: 'tamagoxtix' }, async ($, e) => {
930 const [sub = 'stats', arg] = e.args.trim().split(/\s+/).filter(Boolean)
931
932 if (sub === 'stats') {
933 await refreshUsage($)
934 const data = await read($, usage)
935 const windows = await read($, limits)
936 const pct = (kind: string) => windows.find(w => w.kind === kind)?.percentUsed
937 // the row the model reads; the card above is drawn from the same numbers
938 return {
939 text: data
940 ? `Token usage: today ${formatCount(data.today.tokens)}, this 5h session ${formatCount(data.session?.tokens ?? 0)}, this week ${formatCount(data.week.tokens)}. Limits: 5h ${pct('five_hour') ?? '?'}%, week ${pct('seven_day') ?? '?'}%.`
941 : 'Token usage is not available yet.',
942 }
943 }
944
945 if (sub === 'palette') {
946 const choices = paletteChoices()
947 if (!arg) {
948 const current = (await readConfig($)).palette ?? 'omarchy'
949 const list = choices
950 .map(id => `${id === current ? '●' : '○'} ${id}${id === 'omarchy' ? ' (follows the Omarchy theme)' : ''}`)
951 .join('\n')
952 return { text: `Palettes:\n${list}\n\n/tamagoxtix palette <name> to switch.` }
953 }
954 if (!choices.includes(arg)) {
955 return { text: `Unknown palette "${arg}". Try one of: ${choices.join(', ')}` }
956 }
957 const chosen = await applyPalette($, arg)
958 return { text: `Palette: ${chosen.label}.` }
959 }
960
961 if (sub === 'theme') {
962 if (arg !== 'on' && arg !== 'off') return { text: 'Usage: /tamagoxtix theme on|off' }
963 return { text: await setChatTheme($, arg === 'on') }
964 }
965
966 if (sub === 'help') return { text: await helpText($) }
967
968 if (sub === 'reroll' || sub === 'icon') {
969 const key = await seedKey($)
970 const state = await stateBase($)
971 const picks = await readJson($, `${state}/tamagoxtix/icons.json`, {})
972 if (sub === 'icon') {
973 if (!arg) {
974 const current = (await read($, monster))?.icon
975 const list = ICONS.map(i => `${i.name === current ? '●' : '○'} ${i.nerd} ${i.emoji} ${i.name}`).join('\n')
976 return { text: `Icons:\n${list}\n\n/tamagoxtix icon <name> to pick one.` }
977 }
978 if (!iconByName(arg)) return { text: `Unknown icon "${arg}". Try one of: ${ICONS.map(i => i.name).join(', ')}` }
979 picks[key] = arg
980 } else {
981 const rolls = await readJson($, `${state}/tamagoxtix/rerolls.json`, {})
982 rolls[key] = (rolls[key] ?? 0) + 1
983 await $.fs.write(`${state}/tamagoxtix/rerolls.json`, JSON.stringify(rolls, null, 2))
984 delete picks[key]
985 }
986 await $.fs.write(`${state}/tamagoxtix/icons.json`, JSON.stringify(picks, null, 2))
987 const pet = await loadMonster($)
988 await update($, monster, () => pet)
989 await paintSidebar($, pet, 'idle')
990 return { text: `Say hi to ${pet.name} the ${pet.icon}!` }
991 }
992
993 if (sub === 'statusline') {
994 if (arg !== 'on' && arg !== 'off') return { text: 'Usage: /tamagoxtix statusline on|off' }
995 return { text: await setStatusline($, arg === 'on') }
996 }
997
998 if (sub === 'pet') {
999 const visible = arg !== 'off'
1000 await $.store.set('showPet', visible)
1001 await update($, showPet, () => visible)
1002 return { text: visible ? 'Your monster hangs out above the prompt between turns.' : 'Idle monster hidden. /tamagoxtix pet on brings it back.' }
1003 }
1004
1005 if (sub === 'animate') {
1006 if (arg !== 'on' && arg !== 'off') return { text: 'Usage: /tamagoxtix animate on|off' }
1007 await writeConfig($, { animate: arg === 'on' })
1008 return { text: arg === 'on' ? 'Animated scenes.' : 'Still scenes: one frame per scene, no animation. /tamagoxtix animate on brings the motion back.' }
1009 }
1010
1011 if (sub === 'icons') {
1012 if (arg !== 'nerd' && arg !== 'plain' && arg !== 'emoji') return { text: 'Usage: /tamagoxtix icons nerd|plain' }
1013 await writeConfig($, { icons: arg })
1014 await refreshHerdr($)
1015 return {
1016 text:
1017 arg === 'nerd'
1018 ? 'Icons: Nerd Font (icons and powerline arrows). /tamagoxtix icons plain if you see boxes.'
1019 : 'Icons: plain symbols that any font draws. /tamagoxtix icons nerd once a Nerd Font is set.',
1020 }
1021 }
1022
1023 if (sub === 'doctor') return { text: await doctorText($, e.presentation?.columns) }
1024
1025 return { text: `Unknown: ${sub}.\n\n${await helpText($)}` }
1026 })
1027}
1028
1029lib/icons.mjs 86 lines1// GENERATED from shared/icons.mjs by scripts/sync-shared.mjs. Edit the original.
2// Sidebar icons: one critter per agent, picked from a set by seed and
3// changeable by the person (reroll, or pick one by name).
4//
5// The `nerd` set needs a Nerd Font (Omarchy ships JetBrainsMono Nerd Font);
6// `emoji` works in any terminal with color emoji. Both sets list the same
7// names in the same order, so a pick survives switching sets.
8//
9// Pure module shared by the herdr plugin and the Claude Code mod.
10
11import { hashString } from "./monsters.mjs";
12
13/** @typedef {{ name: string, nerd: string, emoji: string, slot: string }} Icon */
14
15// Each icon has a palette slot for its color. Red is left out: it means blocked.
16/** @type {Icon[]} */
17export const ICONS = [
18 { name: "ghost", nerd: "\u{f02a0}", emoji: "👻", slot: "magenta" },
19 { name: "invader", nerd: "\u{f0bc9}", emoji: "👾", slot: "green" },
20 { name: "alien", nerd: "\u{f089a}", emoji: "👽", slot: "cyan" },
21 { name: "robot", nerd: "\u{f1719}", emoji: "🤖", slot: "blue" },
22 { name: "pacman", nerd: "\u{f0baf}", emoji: "🟡", slot: "yellow" },
23 { name: "jellyfish", nerd: "\u{f0f01}", emoji: "🐙", slot: "accent" },
24 { name: "cat", nerd: "\u{f011b}", emoji: "🐱", slot: "orange" },
25 { name: "owl", nerd: "\u{f03d2}", emoji: "🦉", slot: "yellow" },
26 { name: "penguin", nerd: "\u{f0ec0}", emoji: "🐧", slot: "blue" },
27 { name: "ladybug", nerd: "\u{f082d}", emoji: "🐞", slot: "orange" },
28 { name: "bat", nerd: "\u{f0b5f}", emoji: "🦇", slot: "magenta" },
29 { name: "unicorn", nerd: "\u{f15c2}", emoji: "🦄", slot: "accent" },
30 { name: "snail", nerd: "\u{f1677}", emoji: "🐌", slot: "green" },
31];
32
33/** @type {Icon} */
34export const DEFAULT_ICON = /** @type {Icon} */ (ICONS[0]);
35
36export const ICON_SETS = ["nerd", "emoji"];
37
38// State markers follow the icon; the sidebar color rules match on them.
39export const STATE_MARKERS = {
40 nerd: { blocked: "\u{f0026}", done: "\u{f012c}", working: "\u{f01d8}" },
41 emoji: { blocked: "❗", done: "✅", working: "…" },
42};
43
44// Braille spinner frames for an agent whose face is animated while it works.
45export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
46
47/** @param {string | undefined} name @returns {Icon | null} */
48export function iconByName(name) {
49 return ICONS.find((icon) => icon.name === name) ?? null;
50}
51
52// `picks` maps a seed key to an icon name the person chose; `rolls` to how
53// many times they rerolled it.
54/**
55 * @param {string} seedKey
56 * @param {{ picks?: Record<string, string>, rolls?: Record<string, number> }} [options]
57 * @returns {Icon}
58 */
59export function iconFor(seedKey, { picks = {}, rolls = {} } = {}) {
60 const picked = iconByName(picks[seedKey]);
61 if (picked) return picked;
62 const n = rolls[seedKey] ?? 0;
63 return /** @type {Icon} */ (ICONS[hashString(n ? `${seedKey}#${n}` : String(seedKey)) % ICONS.length]);
64}
65
66/**
67 * The sidebar token: the icon, then what the agent is doing.
68 * @param {Icon} icon
69 * @param {string} [state]
70 * @param {{ set?: string, frame?: number | null }} [options]
71 */
72export function iconToken(icon, state = "idle", { set = "nerd", frame = null } = {}) {
73 const glyph = set === "emoji" ? icon.emoji : icon.nerd;
74 const marks = set === "emoji" ? STATE_MARKERS.emoji : STATE_MARKERS.nerd;
75 switch (state) {
76 case "working":
77 return `${glyph} ${frame === null ? marks.working : SPINNER[frame % SPINNER.length]}`;
78 case "blocked":
79 return `${glyph} ${marks.blocked}`;
80 case "done":
81 return `${glyph} ${marks.done}`;
82 default:
83 return glyph;
84 }
85}
86lib/monsters.mjs 300 lines1// GENERATED from shared/monsters.mjs by scripts/sync-shared.mjs. Edit the original.
2// TamaGoxtix monsters: small pixel critters in the spirit of Claude Code's
3// mascot, generated from a seed so every agent gets its own.
4//
5// Pure module (no Node or engine APIs) shared by the herdr plugin and the
6// Claude Code mod. Colors are palette slot names, resolved by palettes.mjs.
7
8// ---- seeded randomness ----
9
10export function hashString(text) {
11 let h = 2166136261;
12 for (let i = 0; i < text.length; i++) {
13 h ^= text.charCodeAt(i);
14 h = Math.imul(h, 16777619);
15 }
16 return h >>> 0;
17}
18
19function rng(seed) {
20 let a = seed >>> 0;
21 return () => {
22 a = (a + 0x6d2b79f5) >>> 0;
23 let t = a;
24 t = Math.imul(t ^ (t >>> 15), t | 1);
25 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27 };
28}
29
30const pick = (rand, list) => list[Math.floor(rand() * list.length)];
31
32// ---- parts ----
33
34// Bodies are 10 px wide and 5 px tall: '#' body, 'o' eye (a hole), 'a'
35// accent. Eyes sit on rows 2-3 so they fill one whole text cell.
36export const BODIES = [
37 { id: "clawd", rows: [".########.", ".########.", ".#o####o#.", "##o####o##", ".########."] },
38 { id: "blob", rows: ["...####...", ".########.", ".#o####o#.", ".#o####o#.", "..######.."] },
39 { id: "horned", rows: ["a........a", ".a######a.", ".#o####o#.", ".#o####o#.", ".########."] },
40 { id: "antenna", rows: ["....aa....", "...####...", ".#o####o#.", "##o####o##", ".########."] },
41 { id: "kitty", rows: [".##....##.", ".########.", ".#o####o#.", ".#o####o#.", ".########."] },
42 { id: "goggles", rows: ["..######..", ".########.", "##oo##oo##", ".#oo##oo#.", ".########."] },
43 { id: "cyclops", rows: ["..######..", ".########.", ".###oo###.", "####oo####", ".########."] },
44 { id: "bunny", rows: ["..a....a..", "..##..##..", ".#o####o#.", ".#o####o#.", ".########."] },
45 // more critters: hats, horns, masks, shells, spikes
46 { id: "slime", rows: ["...####...", ".########.", ".#o####o#.", "##o####o##", "##########"] },
47 { id: "bear", rows: ["##......##", "##########", ".#o####o#.", ".#o#aa#o#.", ".########."] },
48 { id: "frog", rows: [".oo....oo.", "#oo####oo#", "##########", "#aaaaaaaa#", ".########."] },
49 { id: "owl", rows: ["#........#", "##########", "#oo####oo#", "#oo#aa#oo#", ".########."] },
50 { id: "ghosty", rows: ["..######..", ".########.", ".#o####o#.", ".#o####o#.", "#.##.##.#."] },
51 { id: "tv", rows: ["...a..a...", "##########", "#oo####oo#", "#oo####oo#", "##########"] },
52 { id: "mushroom", rows: [".aaaaaaaa.", "aa#aaaa#aa", ".#o####o#.", ".#o####o#.", "..######.."] },
53 { id: "claws", rows: ["a........a", "aa.####.aa", ".#o####o#.", "##o####o##", ".########."] },
54 { id: "ninja", rows: ["..######..", "aaaaaaaaaa", "a#o####o#a", ".#o####o#.", ".########."] },
55 { id: "viking", rows: ["a.######.a", "aa######aa", ".#o####o#.", ".#o####o#.", ".########."] },
56 { id: "king", rows: ["a.a.aa.a.a", "aaaaaaaaaa", ".#o####o#.", ".#o####o#.", ".########."] },
57 { id: "wizard", rows: ["....aa....", "...aaaa...", ".aaaaaaaa.", ".#o####o#.", ".#o####o#."] },
58 { id: "bandit", rows: [".########.", "##########", "aaoaaaaoaa", "##o####o##", ".########."] },
59 { id: "unicorn", rows: ["....aa....", "..######..", ".###oo###.", ".###oo###.", "..######.."] },
60 { id: "tall", rows: ["...####...", "..######..", "..#o##o#..", "..#o##o#..", "..######.."] },
61 { id: "chonk", rows: ["##########", "##########", "#o######o#", "#o######o#", "##########"] },
62 { id: "batty", rows: ["a........a", "aa######aa", "a#o####o#a", ".#o####o#.", "..######.."] },
63 { id: "bee", rows: [".########.", ".#o####o#.", ".#o####o#.", "aaaaaaaaaa", ".########."] },
64 { id: "pumpkin", rows: ["....aa....", ".########.", "##o####o##", "#.o####o.#", ".########."] },
65 { id: "lynx", rows: ["#........#", "##.####.##", "##########", "##o####o##", ".#o####o#."] },
66 { id: "penguin", rows: ["..######..", ".##aaaa##.", ".#oaaaao#.", ".#oaaaao#.", "..######.."] },
67 { id: "jelly", rows: ["..######..", ".########.", ".#o####o#.", ".#o####o#.", "#.#.#.#.#."] },
68 { id: "cloud", rows: ["..##..##..", ".########.", "##o####o##", "##o####o##", ".##.##.##."] },
69 { id: "rocket", rows: ["....##....", "...####...", "..#o##o#..", ".##o##o##.", "a########a"] },
70 { id: "sushi", rows: ["aaaaaaaaaa", "a########a", "a#o####o#a", "a#o####o#a", "aaaaaaaaaa"] },
71 { id: "dino", rows: ["..a.a.a...", ".########.", ".#o####o##", ".#o#######", ".######..."] },
72 { id: "astronaut", rows: ["..aaaaaa..", ".a######a.", "a#o####o#a", "a#o####o#a", ".aaaaaaaa."] },
73 { id: "donut", rows: [".aaaaaaaa.", "a########a", "##o####o##", "##o####o##", ".########."] },
74 { id: "triclops", rows: ["..######..", ".########.", ".#o#oo#o#.", ".#o#oo#o#.", ".########."] },
75 { id: "pug", rows: ["a########a", "a########a", ".#o#aa#o#.", ".#o####o#.", ".########."] },
76];
77
78// Legs are one px row, two frames each for walking.
79export const LEGS = [
80 { id: "crab", frames: [".#.#..#.#.", "#.#....#.#"] },
81 { id: "stubby", frames: ["..#....#..", ".#......#."] },
82 { id: "tentacle", frames: [".#.#.#.#..", "..#.#.#.#."] },
83 { id: "boots", frames: [".##....##.", "##......##"] },
84 { id: "wheels", frames: ["#.#....#.#", ".#......#."] },
85 { id: "skirt", frames: ["#.#.#.#.#.", ".#.#.#.#.#"] },
86 { id: "spring", frames: ["....##....", "...#..#..."] },
87];
88
89// Body colors, each tied to the bracket pair its one-line face uses so the
90// herdr sidebar can tell the color from the text alone.
91export const COLORS = [
92 { slot: "orange", left: "▐", right: "▌" },
93 { slot: "magenta", left: "ʕ", right: "ʔ" },
94 { slot: "cyan", left: "(", right: ")" },
95 { slot: "green", left: "{", right: "}" },
96 { slot: "yellow", left: "[", right: "]" },
97 { slot: "blue", left: "«", right: "»" },
98 { slot: "accent", left: "▕", right: "▏" },
99];
100
101const FACE_EYES = ["°", "o", "•", "O", "0", "@", "e", "ö"];
102const FACE_MOUTHS = ["w", "v", "_", "o", "3", "u", "x", "-"];
103const ACCENTS = ["yellow", "red", "cyan", "magenta", "green", "fg"];
104
105const SYLLABLES = ["blo", "rp", "zu", "mi", "ko", "ra", "pi", "nu", "gle", "bo", "ta", "fu", "zi", "mo", "ka", "li", "do", "wi"];
106
107// ---- the monster ----
108
109export function makeMonster(seedKey) {
110 const seed = hashString(String(seedKey));
111 const rand = rng(seed);
112 const bodyIndex = Math.floor(rand() * BODIES.length);
113 const legs = pick(rand, LEGS);
114 const color = pick(rand, COLORS);
115 const accent = pick(rand, ACCENTS.filter((a) => a !== color.slot));
116 const syllables = 2 + Math.floor(rand() * 2);
117 let name = "";
118 for (let i = 0; i < syllables; i++) name += pick(rand, SYLLABLES);
119 return {
120 seed,
121 name: name.charAt(0).toUpperCase() + name.slice(1),
122 body: BODIES[bodyIndex].id,
123 legs: legs.id,
124 color: color.slot,
125 accent,
126 face: {
127 left: color.left,
128 right: color.right,
129 eye: FACE_EYES[bodyIndex % FACE_EYES.length],
130 mouth: FACE_MOUTHS[Math.floor(rand() * FACE_MOUTHS.length)],
131 },
132 };
133}
134
135// ---- one-line face (herdr sidebar) ----
136
137// Suffix glyphs double as markers the sidebar color rules match on.
138export const FACE_MARKERS = { working: "~", blocked: "!", done: "✓", idle: "", unknown: "" };
139
140export function faceFor(monster, state = "idle", frame = 0) {
141 const { left, right, eye, mouth } = monster.face;
142 switch (state) {
143 case "working": {
144 const mouths = [mouth, "o", mouth, "-"];
145 const eyes = frame % 8 === 7 ? "-" : eye;
146 return `${left}${eyes}${mouths[frame % mouths.length]}${eyes}${right}${FACE_MARKERS.working}`;
147 }
148 case "blocked":
149 return `${left}>${mouth}<${right}${FACE_MARKERS.blocked}`;
150 case "done":
151 return `${left}^${mouth}^${right}${FACE_MARKERS.done}`;
152 default:
153 return `${left}-${mouth}-${right}`;
154 }
155}
156
157// ---- pixel sprite (Claude Code spinner, stats) ----
158
159// Animation modes follow Claude Code's turn modes plus herdr's agent states.
160export const MODES = ["idle", "requesting", "thinking", "tool-input", "tool-use", "responding", "blocked", "done"];
161
162// Returns a 6x12 grid of color slots (null = empty). The body sits in a
163// 10 px box with one px of room each side for shakes and arms.
164export function spriteGrid(monster, mode = "idle", frame = 0) {
165 const W = 12;
166 const H = 6;
167 const grid = Array.from({ length: H }, () => Array(W).fill(null));
168 const body = BODIES.find((b) => b.id === monster.body) ?? BODIES[0];
169 const legs = LEGS.find((l) => l.id === monster.legs) ?? LEGS[0];
170
171 let dx = 1; // body offset inside the 12 px box (shakes, steps)
172 let squat = false; // body one px lower with legs tucked: the bounce frame
173 let legFrame = 0;
174 let eyes = "open"; // open | closed | happy | worried
175 let look = 0;
176
177 switch (mode) {
178 case "idle":
179 eyes = frame % 16 === 15 ? "closed" : "open";
180 break;
181 case "requesting":
182 squat = frame % 4 >= 2;
183 eyes = frame % 12 === 11 ? "closed" : "open";
184 break;
185 case "thinking":
186 look = frame % 8 < 4 ? 1 : -1;
187 eyes = frame % 10 === 9 ? "closed" : "open";
188 break;
189 case "tool-input":
190 case "tool-use":
191 legFrame = frame % 2;
192 dx = 1 + (frame % 4 === 1 ? 1 : frame % 4 === 3 ? -1 : 0);
193 break;
194 case "responding":
195 squat = frame % 2 === 1;
196 legFrame = frame % 2;
197 break;
198 case "blocked":
199 dx = 1 + (frame % 2 === 0 ? -1 : 1);
200 eyes = "worried";
201 break;
202 case "done":
203 squat = frame % 6 >= 4;
204 eyes = "happy";
205 break;
206 // poses for the idle scenes
207 case "sleep":
208 squat = frame % 8 >= 4; // slow breathing
209 eyes = "closed";
210 break;
211 case "chew":
212 squat = frame % 2 === 1;
213 eyes = "happy";
214 break;
215 case "walk":
216 legFrame = frame % 2;
217 break;
218 case "read":
219 look = 1;
220 eyes = frame % 14 === 13 ? "closed" : "worried"; // eyes down on the page
221 break;
222 case "hop":
223 squat = frame % 4 >= 2;
224 eyes = "happy";
225 legFrame = frame % 2;
226 break;
227 }
228
229 const put = (y, x, value) => {
230 if (y >= 0 && y < H && x >= 0 && x < W) grid[y][x] = value;
231 };
232 const top = squat ? 1 : 0;
233 const holes = [];
234
235 body.rows.forEach((row, y) => {
236 [...row].forEach((ch, x) => {
237 if (ch === "#" || ch === "o") put(top + y, dx + x, "body");
238 else if (ch === "a") put(top + y, dx + x, "accent");
239 if (ch === "o") holes.push({ y, x });
240 });
241 });
242 // Eyes are cut after the body so a glance can move them over body pixels.
243 const firstEyeRow = Math.min(...holes.map((h) => h.y));
244 for (const { y, x } of holes) {
245 const upper = y === firstEyeRow;
246 const open =
247 eyes === "open" || (eyes === "happy" && upper) || (eyes === "worried" && !upper);
248 if (open) put(top + y, dx + x + look, null);
249 }
250 if (!squat) {
251 [...legs.frames[legFrame]].forEach((ch, x) => {
252 if (ch === "#") put(H - 1, dx + x, "body");
253 });
254 }
255 return grid;
256}
257
258// Converts a grid of slots into rows of half-block cells: each text row packs
259// two pixel rows (fg = top pixel, bg = bottom pixel).
260export function gridToCells(grid) {
261 const rows = [];
262 for (let y = 0; y < grid.length; y += 2) {
263 const top = grid[y];
264 const bottom = grid[y + 1] ?? [];
265 const cells = top.map((t, x) => {
266 const b = bottom[x] ?? null;
267 if (!t && !b) return { ch: " " };
268 if (t && !b) return { ch: "▀", fg: t };
269 if (!t && b) return { ch: "▄", fg: b };
270 if (t === b) return { ch: "█", fg: t };
271 return { ch: "▀", fg: t, bg: b };
272 });
273 rows.push(cells);
274 }
275 return rows;
276}
277
278// Merges neighbouring cells of one style into runs, the shape a renderer
279// draws as one styled text span each.
280export function cellsToRuns(row) {
281 const runs = [];
282 for (const cell of row) {
283 const last = runs[runs.length - 1];
284 if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.ch;
285 else runs.push({ text: cell.ch, fg: cell.fg, bg: cell.bg });
286 }
287 return runs;
288}
289
290export function spriteRuns(monster, mode, frame) {
291 return gridToCells(spriteGrid(monster, mode, frame)).map(cellsToRuns);
292}
293
294// Slot names a sprite uses, resolved against a palette.
295export function slotColor(monster, slot, palette) {
296 if (slot === "body") return palette[monster.color] ?? palette.accent;
297 if (slot === "accent") return palette[monster.accent] ?? palette.fg;
298 return palette[slot] ?? slot;
299}
300lib/palettes.mjs 216 lines1// GENERATED from shared/palettes.mjs by scripts/sync-shared.mjs. Edit the original.
2// TamaGoxtix palettes: the color slots every drawing uses, a set of built-in
3// palettes after Omarchy's themes, and a reader for Omarchy's colors.toml so
4// "omarchy" follows whatever theme is active.
5//
6// Pure module (no Node or engine APIs) shared by the herdr plugin and the
7// Claude Code mod.
8
9export const SLOTS = ["bg", "fg", "muted", "accent", "red", "orange", "yellow", "green", "cyan", "blue", "magenta"];
10
11export const PALETTES = {
12 // tamagoxtix's own: Omarchy's Beach theme, ocean dusk with terracotta,
13 // palm green, seafoam and bougainvillea
14 beach: {
15 label: "Beach",
16 mode: "dark",
17 bg: "#0d1f2b", fg: "#dfd8c8", muted: "#3d5b6e", accent: "#3f96d4",
18 red: "#e35d5b", orange: "#e8813f", yellow: "#f2c14e", green: "#7fbf6a",
19 cyan: "#5fd3c4", blue: "#4fa6e0", magenta: "#e84f9a",
20 },
21 "tokyo-night": {
22 label: "Tokyo Night",
23 mode: "dark",
24 bg: "#1a1b26", fg: "#a9b1d6", muted: "#414868", accent: "#7aa2f7",
25 red: "#f7768e", orange: "#eb927b", yellow: "#e0af68", green: "#9ece6a",
26 cyan: "#449dab", blue: "#7aa2f7", magenta: "#ad8ee6",
27 },
28 catppuccin: {
29 label: "Catppuccin",
30 mode: "dark",
31 bg: "#1e1e2e", fg: "#cdd6f4", muted: "#585b70", accent: "#89b4fa",
32 red: "#f38ba8", orange: "#fab387", yellow: "#f9e2af", green: "#a6e3a1",
33 cyan: "#94e2d5", blue: "#89b4fa", magenta: "#f5c2e7",
34 },
35 gruvbox: {
36 label: "Gruvbox",
37 mode: "dark",
38 bg: "#282828", fg: "#d4be98", muted: "#665c54", accent: "#7daea3",
39 red: "#ea6962", orange: "#e1875c", yellow: "#d8a657", green: "#a9b665",
40 cyan: "#89b482", blue: "#7daea3", magenta: "#d3869b",
41 },
42 nord: {
43 label: "Nord",
44 mode: "dark",
45 bg: "#2e3440", fg: "#d8dee9", muted: "#4c566a", accent: "#81a1c1",
46 red: "#bf616a", orange: "#d08770", yellow: "#ebcb8b", green: "#a3be8c",
47 cyan: "#88c0d0", blue: "#81a1c1", magenta: "#b48ead",
48 },
49 everforest: {
50 label: "Everforest",
51 mode: "dark",
52 bg: "#2d353b", fg: "#d3c6aa", muted: "#475258", accent: "#7fbbb3",
53 red: "#e67e80", orange: "#e69875", yellow: "#dbbc7f", green: "#a7c080",
54 cyan: "#83c092", blue: "#7fbbb3", magenta: "#d699b6",
55 },
56 kanagawa: {
57 label: "Kanagawa",
58 mode: "dark",
59 bg: "#1f1f28", fg: "#dcd7ba", muted: "#54546d", accent: "#7e9cd8",
60 red: "#e46876", orange: "#ffa066", yellow: "#e6c384", green: "#98bb6c",
61 cyan: "#7fb4ca", blue: "#7e9cd8", magenta: "#957fb8",
62 },
63 "rose-pine": {
64 label: "Rosé Pine",
65 mode: "dark",
66 bg: "#191724", fg: "#e0def4", muted: "#6e6a86", accent: "#c4a7e7",
67 red: "#eb6f92", orange: "#f6c177", yellow: "#f6c177", green: "#31748f",
68 cyan: "#9ccfd8", blue: "#31748f", magenta: "#c4a7e7",
69 },
70 "osaka-jade": {
71 label: "Osaka Jade",
72 mode: "dark",
73 bg: "#111c18", fg: "#c1c497", muted: "#53685b", accent: "#509475",
74 red: "#ff5345", orange: "#e5c736", yellow: "#e5c736", green: "#549e6a",
75 cyan: "#2dd5b7", blue: "#509475", magenta: "#d2689c",
76 },
77 ristretto: {
78 label: "Ristretto",
79 mode: "dark",
80 bg: "#2c2525", fg: "#e6d9db", muted: "#72696a", accent: "#f38d70",
81 red: "#fd6883", orange: "#fb9a77", yellow: "#f9cc6c", green: "#adda78",
82 cyan: "#85dacc", blue: "#a8a9eb", magenta: "#a8a9eb",
83 },
84 "matte-black": {
85 label: "Matte Black",
86 mode: "dark",
87 bg: "#121212", fg: "#bebebe", muted: "#333333", accent: "#e68e0d",
88 red: "#d35f5f", orange: "#e68e0d", yellow: "#ffc107", green: "#8a9a5b",
89 cyan: "#8fb8c8", blue: "#6f8fbf", magenta: "#b07ab0",
90 },
91};
92
93export const DEFAULT_PALETTE = "beach";
94
95// The orange used where a theme has none.
96export const HIGHLIGHT_ORANGE = "#e8875b";
97
98// ---- Omarchy ----
99
100// Omarchy themes ship colors.toml in one of two shapes: named slots
101// (`accent`, `red`, ...) or terminal slots (`color0`..`color15`).
102export function parseColorsToml(text) {
103 const raw = {};
104 for (const line of String(text).split(/\r?\n/)) {
105 const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"(#[0-9A-Fa-f]{6})"/);
106 if (m) raw[m[1]] = m[2].toLowerCase();
107 }
108 const mode = /^\s*mode\s*=\s*"light"/m.test(text) ? "light" : "dark";
109 const c = (n) => raw[`color${n}`];
110 const palette = {
111 label: "Omarchy",
112 mode,
113 bg: raw.background ?? c(0),
114 fg: raw.foreground ?? c(7) ?? c(15),
115 muted: raw.muted ?? raw.dark_foreground ?? c(8),
116 accent: raw.accent ?? c(4),
117 red: raw.red ?? c(1),
118 // a theme without its own orange still gets one, for highlights
119 orange: raw.orange ?? HIGHLIGHT_ORANGE,
120 yellow: raw.yellow ?? c(3) ?? c(11),
121 green: raw.green ?? c(2),
122 cyan: raw.cyan ?? c(6),
123 blue: raw.blue ?? c(4),
124 magenta: raw.magenta ?? c(5),
125 };
126 // Any slot the file left out falls back to the default palette's.
127 for (const slot of SLOTS) palette[slot] ??= PALETTES[DEFAULT_PALETTE][slot];
128 return palette;
129}
130
131// Where the active Omarchy theme lives, relative to the home directory.
132export const OMARCHY_COLORS_PATH = ".local/state/omarchy/current/theme/colors.toml";
133export const OMARCHY_NAME_PATH = ".local/state/omarchy/current/theme.name";
134
135// Picks the palette named by `choice` ("omarchy", a built-in id). The caller
136// hands in the Omarchy colors.toml text when it could read one.
137export function resolvePalette(choice, omarchyColorsText) {
138 if (choice === "omarchy" || !choice) {
139 if (omarchyColorsText) return { id: "omarchy", ...parseColorsToml(omarchyColorsText) };
140 return { id: DEFAULT_PALETTE, ...PALETTES[DEFAULT_PALETTE] };
141 }
142 const palette = PALETTES[choice] ?? PALETTES[DEFAULT_PALETTE];
143 return { id: PALETTES[choice] ? choice : DEFAULT_PALETTE, ...palette };
144}
145
146export function paletteChoices() {
147 return ["omarchy", ...Object.keys(PALETTES)];
148}
149
150// ---- helpers ----
151
152export function hexToRgb(hex) {
153 const n = parseInt(String(hex).replace("#", ""), 16);
154 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
155}
156
157export function mix(a, b, t) {
158 const [r1, g1, b1] = hexToRgb(a);
159 const [r2, g2, b2] = hexToRgb(b);
160 const to = (x, y) => Math.round(x + (y - x) * t).toString(16).padStart(2, "0");
161 return `#${to(r1, r2)}${to(g1, g2)}${to(b1, b2)}`;
162}
163
164// Claude Code custom theme (~/.claude/themes/<name>.json) colored from a
165// palette, so the chat text itself follows it. The mapping is Omarchy's own
166// (default/themed/claude.json.tpl), so every palette reads like an Omarchy
167// theme does in Claude Code.
168export function claudeTheme(palette, name = "TamaGoxtix") {
169 const p = palette;
170 const brightYellow = mix(p.yellow, p.fg, 0.3);
171 const brightCyan = mix(p.cyan, p.fg, 0.3);
172 return {
173 name,
174 base: p.mode === "light" ? "light" : "dark",
175 overrides: {
176 claude: p.accent,
177 claudeShimmer: mix(p.accent, p.fg, 0.35),
178 text: p.fg,
179 inverseText: p.bg,
180 inactive: mix(p.fg, p.bg, 0.4),
181 inactiveShimmer: mix(p.fg, p.bg, 0.25),
182 subtle: p.muted,
183 suggestion: p.cyan,
184 permission: p.blue,
185 permissionShimmer: mix(p.blue, p.fg, 0.35),
186 remember: p.yellow,
187 success: p.green,
188 error: p.red,
189 warning: p.yellow,
190 warningShimmer: mix(p.yellow, p.fg, 0.35),
191 merged: p.magenta,
192 promptBorder: p.accent,
193 promptBorderShimmer: mix(p.accent, p.fg, 0.35),
194 planMode: p.cyan,
195 autoAccept: p.yellow,
196 bashBorder: brightYellow,
197 ide: brightCyan,
198 diffAdded: mix(p.bg, p.green, 0.15),
199 diffRemoved: mix(p.bg, p.red, 0.15),
200 diffAddedDimmed: mix(p.bg, p.green, 0.08),
201 diffRemovedDimmed: mix(p.bg, p.red, 0.08),
202 diffAddedWord: mix(p.bg, p.green, 0.32),
203 diffRemovedWord: mix(p.bg, p.red, 0.32),
204 userMessageBackground: mix(p.bg, p.fg, 0.06),
205 userMessageBackgroundHover: mix(p.bg, p.fg, 0.1),
206 bashMessageBackgroundColor: mix(p.bg, p.fg, 0.06),
207 memoryBackgroundColor: mix(p.bg, p.fg, 0.06),
208 selectionBg: mix(p.bg, p.accent, 0.45),
209 rate_limit_fill: p.accent,
210 rate_limit_empty: mix(p.bg, p.fg, 0.2),
211 briefLabelYou: p.yellow,
212 briefLabelClaude: p.accent,
213 },
214 };
215}
216spinner.tsx 156 lines1// Surface module drawn in place of Claude Code's spinner line: the agent's
2// monster acting out the turn's mode and a clock that fills as time passes.
3//
4// Runs on the drawing thread with its own frame clock, so the animation does
5// not cost the hooks module a redraw per frame.
6import type { ClientElements, ClientModule, ClientSurface } from 'claude-code'
7
8import { scene, workSceneAt } from './lib/scenes.mjs'
9import { slotColor } from './lib/monsters.mjs'
10import type { TamaMonster, TamaPalette, TamaStatusPiece } from './types'
11
12type Run = { text: string; fg?: string; bg?: string }
13type SceneData = { rows: Run[][]; fx: string[]; fxSlot: string; caption: string }
14
15// Draws a scene (scenes.mjs) as Text rows (monospaced, so the desktop app lines
16// them up as a terminal does): the pixels as half blocks in the monster's
17// palette colors, then the effects column. A row never wraps: on a narrow
18// screen the effects are cut at the edge rather than the pixels split apart.
19function sceneRows(Text: ClientElements['Text'], monster: TamaMonster, palette: TamaPalette, data: SceneData) {
20 const color = (slot?: string) => (slot ? slotColor(monster, slot, palette) : undefined)
21 return data.rows.map((runs, i) => (
22 <Text mono wrap="truncate">
23 {runs.map(run => (
24 <Text mono color={color(run.fg)} backgroundColor={color(run.bg)}>
25 {run.text}
26 </Text>
27 ))}
28 <Text mono color={color(data.fxSlot)}>{' ' + (data.fx[i] ?? '')}</Text>
29 </Text>
30 ))
31}
32
33export type SpinnerProps = {
34 monster: TamaMonster
35 palette: TamaPalette
36 mode: string
37 word: string
38 startedAt: number
39 /** false: one still frame per scene; the clock still ticks once a second. */
40 animate: boolean
41 /** The terminal's width: under MIN_WIDTH the spinner is one line. */
42 columns: number
43 /** The third line, beside the monster's feet: usage-percent's memory bar. */
44 status: TamaStatusPiece[]
45}
46
47type State = { frame: number }
48
49const FRAME_MS = 160
50const STILL_MS = 1000
51const STILL_FRAME = 7 // the frame where most scenes show their effect
52const BAR_CELLS = 8
53const MAX_MARKS = 10
54const GAP = 2 // columns between the monster and the text
55const MIN_WIDTH = 44 // narrower than this: one line, no monster
56
57const MODE_LABEL: Record<string, string> = {
58 requesting: 'is calling',
59 thinking: 'is thinking',
60 'tool-input': 'is writing a tool call',
61 'tool-use': 'is using tools',
62 responding: 'is writing',
63}
64
65function formatElapsed(ms: number): string {
66 const s = Math.max(0, Math.floor(ms / 1000))
67 const m = Math.floor(s / 60)
68 return m ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${s}s`
69}
70
71// A bar of BAR_CELLS that fills over one cycle, and how many cycles are done.
72function cycleBar(value: number, perCycle: number): { bar: string; laps: number } {
73 const laps = Math.floor(value / perCycle)
74 const filled = Math.floor(((value % perCycle) / perCycle) * BAR_CELLS)
75 return { bar: '▰'.repeat(filled) + '▱'.repeat(BAR_CELLS - filled), laps }
76}
77
78function marks(glyph: string, laps: number): string {
79 if (laps <= 0) return ''
80 return laps > MAX_MARKS ? `${glyph}×${laps}` : glyph.repeat(laps)
81}
82
83const CLOCK = ['◴', '◷', '◶', '◵']
84
85const Spinner: ClientModule<SpinnerProps, State> = (props, surface: ClientSurface<State>) => {
86 const { Box, Text } = surface.elements
87 if (surface.state === undefined) {
88 surface.every(props.animate ? FRAME_MS : STILL_MS, () => surface.setState({ frame: (surface.state?.frame ?? 0) + 1 }))
89 }
90 const frame = surface.state?.frame ?? 0
91 const { monster, palette, mode } = props
92 const elapsed = Date.now() - props.startedAt
93
94 // the monster acts out the mode with its props: laptop, thought bubbles,
95 // hammer, keyboard
96 const acting = scene(monster, workSceneAt(monster, mode, Date.now()), props.animate ? Math.floor(frame / 2) : STILL_FRAME)
97 const sprite = sceneRows(Text, monster, palette, acting)
98
99 // the clock fills in eight steps over one minute, then earns a ● per minute
100 const seconds = Math.floor(elapsed / 1000)
101 const time = cycleBar(seconds, 60)
102 const timeLabel = formatElapsed(elapsed)
103
104 const dots = '.'.repeat((Math.floor(frame / 3) % 3) + 1).padEnd(3)
105 const label = acting.caption || (MODE_LABEL[mode] ?? mode)
106
107 // too narrow for the monster: what it is up to and the time
108 if (props.columns < MIN_WIDTH) {
109 return (
110 <Text mono wrap="truncate-end">
111 <Text mono color={palette.accent} bold>
112 {props.word}
113 </Text>
114 <Text mono color={palette.accent}>{dots}</Text>
115 <Text mono color={palette.cyan}>{` ${CLOCK[seconds % 4]} ${timeLabel}`}</Text>
116 <Text mono color={palette.muted}>{` · ${monster.name} ${label}`}</Text>
117 </Text>
118 )
119 }
120
121 // the monster keeps its width; the text beside it is cut at the edge
122 return (
123 <Box flexDirection="row" gap={GAP} marginTop={1}>
124 <Box flexDirection="column" flexShrink={0}>{sprite}</Box>
125 <Box flexDirection="column" flexShrink={1}>
126 <Text mono wrap="truncate-end">
127 <Text mono color={palette.accent} bold>
128 {props.word}
129 </Text>
130 <Text mono color={palette.accent}>{dots}</Text>
131 <Text mono color={palette.muted}> {monster.name} {label}</Text>
132 </Text>
133 <Text mono wrap="truncate-end">
134 <Text mono color={palette.cyan}>{CLOCK[seconds % 4]} </Text>
135 <Text mono color={palette.fg} bold>
136 {timeLabel}
137 </Text>
138 <Text mono> </Text>
139 <Text mono color={palette.cyan}>{time.bar}</Text>
140 <Text mono color={palette.cyan}>{time.laps > 0 ? ` ${marks('●', time.laps)}` : ''}</Text>
141 <Text mono color={palette.muted}> · esc to interrupt</Text>
142 </Text>
143 {props.status.length > 0 && (
144 <Text mono wrap="truncate-end">
145 {props.status.map(piece => (
146 <Text mono color={piece.tone === 'dim' ? palette.fg : palette[piece.tone]}>{piece.text}</Text>
147 ))}
148 </Text>
149 )}
150 </Box>
151 </Box>
152 )
153}
154
155export default Spinner
156idle.tsx 151 lines1// Surface module for the band above the prompt while Claude waits for you:
2// the monster naps, snacks, sips coffee, plays, reads or goes to the
3// bathroom, a new scene every 40 seconds; while you type a prompt it cheers
4// you on instead, a new cheer every 10 seconds. The hook puts the bands of
5// the mods beneath (running tasks, a handover…) under the caption column.
6import type { ClientElements, ClientModule, ClientSurface } from 'claude-code'
7
8import { cheerSceneAt, gitlabFox, idleSceneAt, octocat, scene } from './lib/scenes.mjs'
9import { slotColor } from './lib/monsters.mjs'
10import type { TamaMonster, TamaPalette, TamaStatusPiece } from './types'
11
12type Run = { text: string; fg?: string; bg?: string }
13type SceneData = { rows: Run[][]; fx: string[]; fxSlot: string; caption: string }
14
15// Draws a scene (scenes.mjs) as Text rows (monospaced, so the desktop app lines
16// them up as a terminal does): the pixels as half blocks in the monster's
17// palette colors, then the effects column. A row never wraps: on a narrow
18// screen the effects are cut at the edge rather than the pixels split apart.
19function sceneRows(Text: ClientElements['Text'], monster: TamaMonster, palette: TamaPalette, data: SceneData) {
20 const color = (slot?: string) => (slot ? slotColor(monster, slot, palette) : undefined)
21 return data.rows.map((runs, i) => (
22 <Text mono wrap="truncate">
23 {runs.map(run => (
24 <Text mono color={color(run.fg)} backgroundColor={color(run.bg)}>
25 {run.text}
26 </Text>
27 ))}
28 <Text mono color={color(data.fxSlot)}>{' ' + (data.fx[i] ?? '')}</Text>
29 </Text>
30 ))
31}
32
33export type IdleProps = {
34 monster: TamaMonster
35 palette: TamaPalette
36 idleSince: number
37 todayCount: number | null
38 /** The band's width: under MIN_BAND it is one line. */
39 columns: number
40 /** `node` could not be started, so there are no usage numbers. */
41 nodeMissing: boolean
42 /** You are typing a prompt: cheer scenes instead of idle ones. */
43 cheering?: boolean
44 /** Under 3 rows free: one line with the monster's icon instead of the band. */
45 compact?: boolean
46 /** The monster's icon, a glyph or a symbol (icons.mjs). */
47 face?: string
48 /** false: one still frame per scene; the scene still changes every 40 seconds. */
49 animate?: boolean
50 /** The caption column's third line, beside the monster's feet: usage-percent's memory bar. */
51 status: TamaStatusPiece[]
52}
53
54// Under this many columns the scene and a readable caption do not fit side by
55// side, so the band becomes one line.
56const MIN_BAND = 44
57
58type State = { frame: number }
59
60const FRAME_MS = 320
61const STILL_MS = 10_000 // enough for the idle minutes, the cheer and the scene change
62const STILL_FRAME = 7 // the frame where most scenes show their effect
63
64// The line under the caption while you type, a new one every 10 seconds.
65const CHEERS = [
66 'you got this',
67 'great prompt incoming',
68 'keep going, it is looking good',
69 'Claude is going to love this one',
70 'one more line and ship it',
71 'this is the prompt',
72]
73
74function formatCount(n: number): string {
75 if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
76 if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
77 if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
78 return String(Math.round(n))
79}
80
81function formatIdle(ms: number): string {
82 const m = Math.floor(ms / 60_000)
83 if (m < 1) return 'just now'
84 if (m < 60) return `${m}m`
85 return `${Math.floor(m / 60)}h ${m % 60}m`
86}
87
88const Idle: ClientModule<IdleProps, State> = (props, surface: ClientSurface<State>) => {
89 const { Box, Text } = surface.elements
90 const animate = props.animate !== false
91 if (surface.state === undefined) {
92 surface.every(animate ? FRAME_MS : STILL_MS, () => surface.setState({ frame: (surface.state?.frame ?? 0) + 1 }))
93 }
94 const frame = animate ? surface.state?.frame ?? 0 : STILL_FRAME
95 const { monster, palette } = props
96 const now = Date.now()
97 const data = scene(monster, props.cheering ? cheerSceneAt(monster, now) : idleSceneAt(monster, now), frame)
98
99 // no room for the band: the monster's icon, what it is up to, and today
100 if (props.compact || props.columns < MIN_BAND) {
101 return (
102 <Text mono wrap="truncate-end">
103 <Text mono color={palette.accent} bold>{`${props.face ? `${props.face} ` : ''}${monster.name}`}</Text>
104 <Text mono color={palette.fg}> {data.caption}</Text>
105 {props.cheering ? (
106 <Text mono color={palette.orange}>{` · ${CHEERS[Math.floor(now / 10_000) % CHEERS.length]}`}</Text>
107 ) : null}
108 {props.todayCount !== null && !props.nodeMissing ? (
109 <Text mono color={palette.yellow}>{` · $ ${formatCount(props.todayCount)} today`}</Text>
110 ) : null}
111 </Text>
112 )
113 }
114
115 // the scene keeps its width; the text beside it is cut at the edge
116 return (
117 <Box flexDirection="row" gap={2} marginTop={1}>
118 <Box flexDirection="column" flexShrink={0}>{sceneRows(Text, monster, palette, data)}</Box>
119 <Box flexDirection="column" flexShrink={1}>
120 <Text mono wrap="truncate-end">
121 <Text mono color={palette.accent} bold>
122 {monster.name}
123 </Text>
124 <Text mono color={palette.fg}> {data.caption}</Text>
125 </Text>
126 <Text mono wrap="truncate-end">
127 {props.cheering ? (
128 <Text mono color={palette.orange}>{CHEERS[Math.floor(now / 10_000) % CHEERS.length]} · enter to send</Text>
129 ) : (
130 <Text mono color={palette.fg}>waiting for you · idle {formatIdle(now - props.idleSince)}</Text>
131 )}
132 {props.nodeMissing ? (
133 <Text mono color={palette.orange}> · install Node.js 18+ to see your usage · /tamagoxtix doctor</Text>
134 ) : props.todayCount !== null ? (
135 <Text mono color={palette.fg}>{` · ${formatCount(props.todayCount)} today`}</Text>
136 ) : null}
137 </Text>
138 {props.status.length > 0 && (
139 <Text mono wrap="truncate-end">
140 {props.status.map(piece => (
141 <Text mono color={piece.tone === 'dim' ? palette.fg : palette[piece.tone]}>{piece.text}</Text>
142 ))}
143 </Text>
144 )}
145 </Box>
146 </Box>
147 )
148}
149
150export default Idle
151lib/scenes.mjs 503 lines1// GENERATED from shared/scenes.mjs by scripts/sync-shared.mjs. Edit the original.
2// Scenes: the monster with props (a burger, a guitar, a laser sword…) and a
3// small column of effects beside it (zZ, notes, sparks, "GOL!").
4//
5// Idle scenes play while the agent waits for you, cheer scenes while you
6// type a prompt; work scenes follow Claude
7// Code's turn modes, a different one every few seconds. Each scene is a
8// function of the frame number that says the monster's pose and position,
9// which props to draw where, and the effects text. Pure module shared by the
10// herdr plugin and the mod.
11
12import { cellsToRuns, gridToCells, hashString, spriteGrid } from "./monsters.mjs";
13import { ART_CHEER, CHEER } from "./scenes-cheer.mjs";
14import { ART_MORE, IDLE_MORE, IDLE_PARTY, POOLS_MORE, WORK_INDUSTRIAL, WORK_MEMES, WORK_MORE } from "./scenes-more.mjs";
15
16export const SCENE_WIDTH = 20; // pixels, one text column each
17export const SCENE_HEIGHT = 6; // pixels, two per text row
18// The effects column is padded to the widest effect ("hole in one"), so the text beside the scene never shifts.
19export const FX_WIDTH = 11;
20
21// ---- props: rows of characters, each mapped to a palette slot ----
22
23const SLOTS = { o: "orange", g: "green", r: "red", y: "yellow", f: "fg", b: "blue", c: "cyan", m: "magenta", a: "accent", u: "muted" };
24
25const ART = {
26 // food and drink
27 burger: [".oooo.", "gggggg", "rrrrrr", ".oooo."],
28 mug: ["oooo.", "ffffu", "ffffu", "ffff."],
29 pizza: ["oooooo", ".yryy.", "..yr..", "...y.."],
30 icecream: [".mm.", "mmmm", ".oo.", ".oo.", "..o."],
31 boba: ["...u", "cccc", "cccc", "cucu", ".cc."],
32 popcorn: ["f.f.", "ffff", "rfrf", "rfrf", "rfrf"],
33 // home and cleaning
34 door: ["uuuuu", "u...u", "u..yu", "u...u", "uuuuu", "uuuuu"],
35 book: ["bbbbbb", "bffffb", "bffffb", "bbbbbb"],
36 broomA: ["...o", "..o.", ".o..", "yyy.", "yyy."],
37 broomB: ["o...", ".o..", "..o.", ".yyy", ".yyy"],
38 vacuum: ["u....", "u....", "u....", "urrr.", "rrrrr", ".u.u."],
39 plate: [".ffff.", "ffffff", ".ffff."],
40 can: ["c..ccc", "cccccc", ".cccc."],
41 sprout: [".g.", "ooo", ".o."],
42 plant: ["ggg", ".g.", "ooo", ".o."],
43 tree: ["g.g.g", "ggggg", ".ggg.", "..o..", ".ooo."],
44 washerA: ["uuuuuu", "uuuuyu", "u.cc.u", "u.cc.u", "uuuuuu"],
45 washerB: ["uuuuuu", "uuuuyu", "u.bb.u", "u.bb.u", "uuuuuu"],
46 duck: [".yy..", "yyyo.", ".yyyy", ".yyy."],
47 // music and screens
48 guitar: ["....u", "...u.", "ooou.", "oooo.", ".oo.."],
49 decksA: ["uuuuuu", "ummmmu", "uuuuuu"],
50 decksB: ["uuuuuu", "umffmu", "uuuuuu"],
51 mic: [".u", ".u", "u.", "u.", "u."],
52 controller: ["uuuuuu", "ufuurr", "uuuuuu"],
53 phone: ["uuu", "ucu", "ucu", "uuu"],
54 telescope: ["....uu", "...uu.", "..uu..", ".u..u.", "u....u"],
55 easel: ["uuuuu", "u...u", "u...u", "uuuuu", ".u.u."],
56 // pop culture
57 saberBlue: ["c", "c", "c", "c", "u", "u"],
58 saberRed: ["r", "r", "r", "r", "u", "u"],
59 pokeball: ["rr", "ff"],
60 pipe: ["gggg", ".gg.", ".gg.", ".gg."],
61 coin: ["yy"],
62 // sports
63 soccer: ["ff", "ff"],
64 goal: ["uuuu", "u..u", "u..u", "u..u"],
65 hoop: ["rrrr", "u..u", ".uu.", "..u.", "..u.", "..u."],
66 orange: ["oo", "oo"],
67 racket: [".yyy", ".y.y", ".yyy", "..o.", "..o."],
68 dot: ["y"],
69 barbell: ["u........u", "uuuuuuuuuu", "u........u"],
70 board: ["oooooooooo"],
71 waves: ["c.cc.cc.cc.cc.cc.cc."],
72 wavesB: [".cc.cc.cc.cc.cc.cc.c"],
73 rod: ["ooooo.", "....c.", "....c.", "....c.", "...ccc"],
74 fish: ["oo.", "ooo"],
75 // work
76 laptop: [".uuuuu.", ".ucccu.", ".uuuuu.", "uuuuuuu"],
77 hammerUp: [".uu", ".uu", "..y", "..y", "..y"],
78 hammerDown: ["....", "....", "....", "yyyuu", "...uu"],
79 keyboard: ["uuuuuuu"],
80 dish: ["...u.", "u.u..", ".uu..", "..u..", ".uuu."],
81 bird: ["u.u", ".u."],
82 letter: ["ff", "ff"],
83 handset: ["uu..", "u...", "u...", "uu.."],
84 mailbox: ["bbbb", "b..b", "bbbb", ".u..", ".u.."],
85 rocket: [".f.", "fff", "fbf", "fff", "r.r"],
86 tower: [".u.", "uuu", ".u.", "u.u", "u.u"],
87 chess: ["fufu", "ufuf", "fufu", "ufuf"],
88 bulbOn: [".yy.", "yyyy", ".yy.", ".uu."],
89 bulbOff: [".uu.", "u..u", ".uu.", ".uu."],
90 crystalA: [".mm.", "mmmm", "mmmm", ".mm.", "uuuu"],
91 crystalB: [".aa.", "aaaa", "aaaa", ".aa.", "uuuu"],
92 magnifier: [".uu.", "u..u", "u..u", ".uu.", "...u"],
93 gearA: ["u.u", "uuu", "u.u"],
94 gearB: ["uuu", "u.u", "uuu"],
95 wrench: ["y..", ".y.", "..y"],
96 log: ["oooooo", "oooooo"],
97 saw: ["uuuu", "uu.."],
98 drill: ["uuuy", "uu..", ".u.."],
99 flask: [".u.", ".u.", "ggg", "ggg"],
100 torch: ["u.", "uu"],
101 box: ["oooo", "oyyo", "oooo"],
102 pickUp: ["uuu", ".o.", ".o."],
103 pickDown: ["...", "uo.", "uoo"],
104 grass: ["gggg", "oooo", "oooo"],
105 net: ["..ff", ".f.f", "o.ff", "o..."],
106 roller: ["uuu", ".o.", ".o."],
107 typewriter: ["uuuuuu", "uuuuuu"],
108 quill: ["...f", "..f.", ".f..", "o..."],
109 piano: ["fufuff", "fufuff", "ffffff"],
110 printer: ["uuuuu", "uuuuu", ".fff."],
111 chalkboard: ["gggggg", "g....g", "g....g", "gggggg"],
112 telegraph: ["uuuu", ".uu.", "uuuu"],
113 needles: ["u...u", ".u.u."],
114 megaphone: ["...y", "..yy", "yyyy", "..yy", "...y"],
115};
116
117function blankGrid() {
118 return Array.from({ length: SCENE_HEIGHT }, () => Array(SCENE_WIDTH).fill(null));
119}
120
121// Draws an art (a name in ART, or rows given inline) at x, y. `cut` skips its
122// first columns (a bitten burger), `light` lights one column (a pressed key).
123function drawArt(grid, art, x, y, { cut = 0, light = -1, slot = null } = {}) {
124 const rows = typeof art === "string" ? ART[art] : art;
125 if (!rows) return;
126 rows.forEach((row, dy) => {
127 [...row].forEach((ch, dx) => {
128 if (ch === "." || dx < cut) return;
129 const gy = y + dy;
130 const gx = x + dx;
131 if (gy < 0 || gy >= SCENE_HEIGHT || gx < 0 || gx >= SCENE_WIDTH) return;
132 grid[gy][gx] = dx === light ? "accent" : slot ?? SLOTS[ch] ?? ch;
133 });
134 });
135}
136
137// The monster's 12 px sprite placed at column `ox`, clipped to the scene.
138function drawMonster(grid, monster, pose, frame, ox = 1) {
139 const sprite = spriteGrid(monster, pose, frame);
140 sprite.forEach((row, y) => {
141 row.forEach((cell, x) => {
142 const gx = x + ox - 1;
143 if (cell && gx >= 0 && gx < SCENE_WIDTH) grid[y][gx] = cell;
144 });
145 });
146}
147
148// ---- helpers for scene definitions ----
149
150const BLANK = ["", "", ""];
151const cyc = (t, list, period = 1) => list[Math.floor(t / period) % list.length];
152const mid = (text) => ["", text, ""];
153
154function rising(glyphs, frame, period = 3) {
155 const step = Math.floor(frame / period) % (glyphs.length + 1);
156 const rows = ["", "", ""];
157 for (let i = 0; i < Math.min(step, glyphs.length); i++) rows[2 - i] = " ".repeat(i) + glyphs[i];
158 return rows;
159}
160
161// A scene is a function of the frame: { pose, ox, props: [[art, x, y, opts]],
162// fx: [3 strings], fxSlot, caption, hide }.
163const IDLE = {
164 // the first six
165 sleep: (t) => ({ pose: "sleep", fx: rising(["z", "z", "Z"], t), fxSlot: "blue", caption: "is napping" }),
166 eat: (t) => {
167 const bites = Math.floor((t % 36) / 6);
168 return {
169 pose: bites < 6 ? "chew" : "idle",
170 props: bites < 6 ? [["burger", 12, 1, { cut: bites }]] : [],
171 fx: t % 6 < 3 && bites > 0 ? mid("nom") : BLANK,
172 fxSlot: "orange",
173 caption: "is having a snack",
174 };
175 },
176 drink: (t) => {
177 const sip = t % 16 >= 10;
178 return {
179 pose: sip ? "chew" : "idle",
180 props: [["mug", 12, sip ? 0 : 2]],
181 fx: sip ? mid("ahh") : cyc(t, [[" ~", "~", ""], ["~", " ~", ""]], 2),
182 fxSlot: "muted",
183 caption: "is sipping coffee",
184 };
185 },
186 bathroom: (t) => {
187 const p = t % 60;
188 const hide = p >= 14 && p < 44;
189 const ox = p < 14 ? 1 + Math.floor(p / 2) : hide ? 8 : 8 - Math.floor((p - 44) / 2);
190 return {
191 pose: hide ? "idle" : "walk",
192 ox,
193 hide,
194 props: [["door", 15, 0]],
195 fx: hide && t % 6 < 3 ? ["WC", "", "brb"] : ["WC", "", ""],
196 fxSlot: "muted",
197 caption: hide ? "is in the bathroom" : "is going to the bathroom",
198 };
199 },
200 play: (t) => {
201 const by = cyc(t, [4, 2, 1, 0, 1, 2, 4]);
202 return { pose: by >= 4 ? "hop" : "idle", props: [["orange", 14, by, { slot: "red" }]], fx: by >= 4 ? ["", "", "!"] : BLANK, fxSlot: "yellow", caption: "is playing ball" };
203 },
204 read: (t) => ({ pose: "read", props: [["book", 12, 2]], fx: t % 20 >= 17 ? mid("flip") : BLANK, fxSlot: "muted", caption: "is reading the docs" }),
205
206 // cleaning and chores
207 sweep: (t) => ({ pose: "walk", ox: 1 + (Math.floor(t / 2) % 3), props: [[cyc(t, ["broomA", "broomB"], 2), 14, 1]], fx: cyc(t, [["", "", "∴"], ["", "", " ·:"], BLANK], 2), fxSlot: "muted", caption: "is sweeping the floor" }),
208 vacuum: (t) => ({ pose: "walk", ox: 1 + (t % 4 < 2 ? 0 : 1), props: [["vacuum", 13 + (t % 4 < 2 ? 0 : 1), 0]], fx: mid(cyc(t, ["vrr", "vrrr", "vr"])), fxSlot: "red", caption: "is vacuuming" }),
209 dishes: (t) => ({ pose: "chew", props: [["plate", 12, 3]], fx: cyc(t, [["°", " o", ""], [" o", "°", ""], ["", " °", "o"]], 2), fxSlot: "cyan", caption: "is washing the dishes" }),
210 garden: (t) => {
211 const grow = Math.floor((t % 30) / 10);
212 const plant = ["sprout", "plant", "tree"][grow];
213 return { pose: "idle", props: [["can", 11, 0], [plant, 16, 6 - ART[plant].length]], fx: cyc(t, [["", " '", ""], ["", "'", " '"]], 2), fxSlot: "cyan", caption: "is watering the plants" };
214 },
215 laundry: (t) => ({ pose: t % 10 < 5 ? "idle" : "hop", props: [[cyc(t, ["washerA", "washerB"]), 13, 1]], fx: mid(cyc(t, ["~", "~~", "~~~"])), fxSlot: "cyan", caption: "is doing the laundry" }),
216
217 // music and screens
218 guitar: (t) => ({ pose: "hop", props: [["guitar", 10, 1]], fx: cyc(t, [["♪", "", ""], [" ♫", "♪", ""], ["", " ♫", "♪"]], 2), fxSlot: "magenta", caption: "is shredding a guitar solo" }),
219 dj: (t) => ({ pose: "hop", props: [[cyc(t, ["decksA", "decksB"]), 12, 3]], fx: cyc(t, [["♪", "", " ♬"], ["", "♬", ""], [" ♪", "", "♪"]], 2), fxSlot: "magenta", caption: "is dropping a beat" }),
220 karaoke: (t) => ({ pose: t % 4 < 2 ? "chew" : "hop", props: [["mic", 11, 1]], fx: cyc(t, [mid("♪ la"), ["la ♪", "", ""], mid("")], 2), fxSlot: "magenta", caption: "is singing karaoke" }),
221 gaming: (t) => ({ pose: "read", props: [["controller", 12, 3]], fx: mid(cyc(t, ["↑↑", "↓↓", "←→", "←→", "B A"], 2)), fxSlot: "accent", caption: "is entering the cheat code" }),
222 selfie: (t) => ({ pose: t % 12 === 11 ? "done" : "idle", props: [["phone", 13, 1]], fx: t % 12 === 11 ? mid("✧ click") : BLANK, fxSlot: "yellow", caption: "is taking a selfie" }),
223 movie: (t) => ({ pose: "chew", props: [["popcorn", 13, 1]], fx: cyc(t, [["pop", "", ""], ["", " pop", ""], BLANK], 2), fxSlot: "yellow", caption: "is watching a movie" }),
224 stargazing: (t) => ({ pose: "read", props: [["telescope", 12, 0]], fx: cyc(t, [["✦", " ☆", ""], [" ☆", "", "✦"]], 3), fxSlot: "yellow", caption: "is stargazing" }),
225 painting: (t) => {
226 const fill = Math.floor((t % 32) / 4);
227 const dots = [[1, 1, "r"], [2, 1, "y"], [3, 1, "b"], [1, 2, "g"], [2, 2, "m"], [3, 2, "c"], [2, 1, "o"], [1, 1, "a"]].slice(0, fill);
228 const canvas = ["uuuuu", "u...u", "u...u", "uuuuu", ".u.u."].map((r) => [...r]);
229 for (const [x, y, c] of dots) canvas[y][x] = c;
230 return { pose: "idle", props: [[canvas.map((r) => r.join("")), 13, 1]], fx: mid(t % 8 < 4 ? "~" : ""), fxSlot: "magenta", caption: "is painting a masterpiece" };
231 },
232
233 // pop culture
234 saber: (t) => ({ pose: t % 6 < 3 ? "tool-use" : "idle", props: [[cyc(t, ["saberBlue", "saberRed"], 6), 12, 0]], fx: mid(cyc(t, ["vmm", "VMM", "vmm"], 2)), fxSlot: t % 12 < 6 ? "cyan" : "red", caption: "is training with a laser sword" }),
235 catchem: (t) => {
236 const p = t % 12;
237 const x = 12 + Math.min(p, 6);
238 const y = [3, 1, 0, 1, 2, 4, 4][Math.min(p, 6)];
239 return { pose: p < 2 ? "tool-use" : "idle", props: [["pokeball", x, y]], fx: p >= 8 ? mid(p % 2 ? "gotcha!" : "...") : BLANK, fxSlot: "red", caption: "is catching them all" };
240 },
241 plumber: (t) => {
242 const up = t % 8 < 4;
243 return { pose: up ? "hop" : "idle", props: [["pipe", 14, 2], ...(up ? [["coin", 15, 0]] : [])], fx: up ? mid("◎ +1") : BLANK, fxSlot: "yellow", caption: "is jumping for coins" };
244 },
245 duck: (t) => ({ pose: t % 8 < 4 ? "chew" : "idle", props: [["duck", 13, 2]], fx: t % 8 >= 4 ? mid("quack") : BLANK, fxSlot: "yellow", caption: "is rubber-duck debugging" }),
246 matrix: (t) => ({ pose: "read", fx: [cyc(t, ["1 0", "0 1"]), cyc(t, ["0 1", "1 0"]), cyc(t, ["1 1", "0 0"])], fxSlot: "green", caption: "sees the matrix" }),
247
248 // sports
249 soccer: (t) => {
250 const p = t % 14;
251 const x = p < 2 ? 11 : Math.min(17, 11 + p);
252 return { pose: p < 2 ? "tool-use" : "idle", props: [["goal", 16, 2], ["soccer", x, 4]], fx: p >= 8 && p < 13 ? mid(p % 2 ? "GOL!" : "GOOL!") : BLANK, fxSlot: "green", caption: "is playing football" };
253 },
254 basketball: (t) => {
255 const arc = [[11, 4], [12, 2], [13, 1], [14, 0], [15, 1], [16, 1], [16, 3], [16, 4]];
256 const [x, y] = cyc(t, arc);
257 return { pose: t % arc.length < 2 ? "hop" : "idle", props: [["hoop", 15, 0], ["orange", x, y]], fx: t % arc.length >= 6 ? mid("swish") : BLANK, fxSlot: "orange", caption: "is shooting hoops" };
258 },
259 tennis: (t) => {
260 const p = t % 10;
261 const x = p < 5 ? 15 + p : 24 - p;
262 return { pose: p === 0 ? "tool-use" : "idle", props: [["racket", 11, 0], ["dot", Math.min(x, 19), 1 + (p % 3)]], fx: p === 0 ? mid("pok") : BLANK, fxSlot: "yellow", caption: "is playing tennis" };
263 },
264 lifting: (t) => {
265 const up = t % 8 >= 4;
266 return { pose: up ? "idle" : "chew", props: [["barbell", 1, up ? 0 : 1]], fx: up ? mid("hnng") : BLANK, fxSlot: "red", caption: "is lifting weights" };
267 },
268 yoga: (t) => ({ pose: t % 8 < 4 ? "sleep" : "idle", fx: mid(t % 8 < 4 ? "om" : " om"), fxSlot: "magenta", caption: "is doing yoga" }),
269 skate: (t) => {
270 const ox = 1 + cyc(t, [0, 1, 2, 3, 4, 3, 2, 1]);
271 const air = t % 8 === 4;
272 return { pose: "hop", ox, props: [["board", ox, air ? 4 : 5]], fx: air ? mid("ollie!") : BLANK, fxSlot: "orange", caption: "is skateboarding" };
273 },
274 swim: (t) => ({ pose: "hop", props: [[cyc(t, ["waves", "wavesB"]), 0, 5]], fx: cyc(t, [mid("splash"), BLANK, BLANK]), fxSlot: "cyan", caption: "is going for a swim" }),
275 fishing: (t) => {
276 const bite = t % 16 >= 12;
277 return { pose: bite ? "done" : "idle", props: [["rod", 11, 0], ...(bite ? [["fish", 16, 2]] : [])], fx: bite ? mid("!") : BLANK, fxSlot: "orange", caption: bite ? "caught a fish!" : "is fishing" };
278 },
279 juggling: (t) => {
280 const spots = [[12, 4], [13, 1], [15, 0], [17, 1], [18, 4]];
281 const balls = ["r", "g", "b"].map((c, i) => [[c], ...cyc(t + i * 2, spots)]);
282 return { pose: "hop", props: balls.map(([art, x, y]) => [art, x, y]), fx: BLANK, fxSlot: "fg", caption: "is juggling" };
283 },
284 meditate: (t) => ({ pose: t % 6 < 3 ? "hop" : "sleep", fx: cyc(t, [["✧", "", ""], ["", " ✧", ""], ["", "", "✧"]], 2), fxSlot: "accent", caption: "is levitating" }),
285 snack: (t) => {
286 const bites = Math.floor((t % 30) / 5);
287 return { pose: "chew", props: bites < 6 ? [["pizza", 12, 1, { cut: bites }]] : [], fx: bites > 0 && t % 5 < 2 ? mid("nom") : BLANK, fxSlot: "orange", caption: "is eating pizza" };
288 },
289 icecream: (t) => ({ pose: t % 4 < 2 ? "chew" : "idle", props: [["icecream", 13, 1]], fx: t % 8 < 2 ? mid("♥") : BLANK, fxSlot: "magenta", caption: "is having ice cream" }),
290 boba: (t) => ({ pose: t % 10 >= 7 ? "chew" : "idle", props: [["boba", 13, 1]], fx: t % 10 >= 7 ? mid("slurp") : BLANK, fxSlot: "cyan", caption: "is drinking bubble tea" }),
291};
292
293Object.assign(ART, ART_MORE, ART_CHEER);
294Object.assign(IDLE, IDLE_MORE, IDLE_PARTY);
295
296export const IDLE_SCENES = Object.keys(IDLE);
297
298// Work scenes, grouped by the turn mode they suit.
299const WORK = {
300 // requesting: talking to the model
301 laptop: (t) => ({ pose: "requesting", props: [["laptop", 12, 2]], fx: mid(cyc(t, [")", "))", ")))", ""])), fxSlot: "cyan", caption: "is calling Claude" }),
302 satellite: (t) => ({ pose: "read", props: [["dish", 12, 1]], fx: cyc(t, [["", "", "·"], ["", " ·)", ""], ["·))", "", ""]]), fxSlot: "cyan", caption: "is pinging the satellite" }),
303 pigeon: (t) => {
304 const x = 12 + (t % 8);
305 return { pose: "idle", props: [["bird", x, cyc(t, [0, 1])], ["letter", x, 2]], fx: BLANK, fxSlot: "fg", caption: "is sending a carrier pigeon" };
306 },
307 phonecall: (t) => ({ pose: "chew", props: [["handset", 11, 1]], fx: mid(cyc(t, ["ring", "ring!", ""], 2)), fxSlot: "yellow", caption: "is on the phone" }),
308 mail: (t) => ({ pose: "walk", props: [["mailbox", 15, 1], ["letter", 12 + (t % 4), 2]], fx: t % 4 === 3 ? mid("sent") : BLANK, fxSlot: "blue", caption: "is mailing a request" }),
309 launch: (t) => {
310 const p = t % 12;
311 return { pose: p > 5 ? "done" : "idle", props: [["rocket", 15, p > 5 ? 6 - p : 1]], fx: p <= 5 ? mid(String(Math.max(1, 3 - Math.floor(p / 2)))) : mid("liftoff"), fxSlot: "red", caption: "is launching a rocket" };
312 },
313 radio: (t) => ({ pose: "idle", props: [["tower", 14, 1]], fx: cyc(t, [mid("(·)"), ["", "((·))", ""], ["(((", "", ")))"]]), fxSlot: "accent", caption: "is broadcasting" }),
314
315 // thinking
316 bubbles: (t) => {
317 const step = Math.floor(t / 3) % 5;
318 return { pose: "thinking", fx: step === 4 ? ["!", " o", "°"] : rising(["°", "o", "O?"], t), fxSlot: step === 4 ? "yellow" : "fg", caption: "is thinking" };
319 },
320 chess: (t) => ({ pose: "thinking", props: [["chess", 13, 2], [["r"], 13 + (Math.floor(t / 4) % 4), 2 + (Math.floor(t / 8) % 4)]], fx: t % 16 === 15 ? mid("check") : BLANK, fxSlot: "red", caption: "is playing chess" }),
321 cube: (t) => {
322 const colors = "rgbyom";
323 const face = [0, 1, 2].map((r) => [0, 1, 2].map((c) => colors[(r * 3 + c + Math.floor(t / 2)) % 6]).join(""));
324 const solved = t % 24 >= 20;
325 return { pose: solved ? "done" : "thinking", props: [[solved ? ["rrr", "rrr", "rrr"] : face, 14, 2]], fx: solved ? mid("solved") : BLANK, fxSlot: "green", caption: "is solving the cube" };
326 },
327 idea: (t) => ({ pose: t % 10 >= 8 ? "done" : "thinking", props: [[t % 10 >= 8 ? "bulbOn" : cyc(t, ["bulbOff", "bulbOff", "bulbOn", "bulbOff"]), 14, 0]], fx: t % 10 >= 8 ? mid("aha!") : BLANK, fxSlot: "yellow", caption: "is having an idea" }),
328 whiteboard: (t) => {
329 const n = Math.floor((t % 24) / 3);
330 const board = ["ffffff", "f....f", "f....f", "ffffff", ".u..u."].map((r) => [...r]);
331 for (let i = 0; i < n; i++) board[1 + (i % 2)][1 + Math.floor(i / 2)] = "b";
332 return { pose: "read", props: [[board.map((r) => r.join("")), 13, 0]], fx: BLANK, fxSlot: "fg", caption: "is sketching on the whiteboard" };
333 },
334 crystal: (t) => ({ pose: "thinking", props: [[cyc(t, ["crystalA", "crystalB"], 2), 14, 1]], fx: cyc(t, [mid("✧"), ["✦", "", ""], BLANK]), fxSlot: "magenta", caption: "is consulting the crystal ball" }),
335 detective: (t) => ({ pose: "walk", ox: 1 + (Math.floor(t / 2) % 3), props: [["magnifier", 14, 1]], fx: mid(cyc(t, ["?", "hmm", "?"], 3)), fxSlot: "fg", caption: "is investigating" }),
336 blocks: (t) => {
337 const rows = Math.floor((t % 24) / 4);
338 const stack = ["rrbb", "gyyo", "mmcc", "bbgg", "ooyy", "ccrr"].slice(0, rows).reverse();
339 const fall = (t % 4) + 0;
340 return { pose: "thinking", props: [[stack, 14, 6 - stack.length], [["aa"], 15, Math.min(fall, 5 - stack.length)]], fx: rows === 5 ? mid("line!") : BLANK, fxSlot: "accent", caption: "is stacking blocks" };
341 },
342
343 // tool use
344 hammer: (t) => {
345 const down = t % 2 === 1;
346 return { pose: "tool-use", props: [[down ? "hammerDown" : "hammerUp", 12, 0]], fx: down ? ["", "✦", "· ✦"] : BLANK, fxSlot: "yellow", caption: "is hammering away" };
347 },
348 gears: (t) => ({ pose: "tool-use", props: [[cyc(t, ["gearA", "gearB"]), 15, 1], ["wrench", 12, cyc(t, [1, 2])]], fx: t % 4 === 0 ? mid("clank") : BLANK, fxSlot: "muted", caption: "is tightening bolts" }),
349 saw: (t) => ({ pose: "tool-use", props: [["log", 12, 4], ["saw", 12 + (t % 3), 2]], fx: cyc(t, [["", "", "∴"], ["", "", " ∵"]]), fxSlot: "orange", caption: "is sawing wood" }),
350 drill: (t) => ({ pose: "tool-use", props: [["drill", 12, 2]], fx: mid(cyc(t, ["brr", "brrr", "rrr"])), fxSlot: "yellow", caption: "is drilling" }),
351 lab: (t) => ({ pose: "thinking", props: [["flask", 14, 2]], fx: cyc(t, [["", "", "o"], ["", "°", ""], ["°", "", ""]]), fxSlot: "green", caption: "is mixing potions" }),
352 weld: (t) => ({ pose: "tool-use", props: [["torch", 12, 3], ["log", 14, 4]], fx: cyc(t, [["*", "✦", ""], ["", "*", "✦"], ["✦", "", "*"]]), fxSlot: "orange", caption: "is welding" }),
353 boxes: (t) => {
354 const lifted = t % 8 < 4;
355 return { pose: lifted ? "walk" : "idle", ox: 1 + (t % 8 < 4 ? Math.floor((t % 4) / 2) : 0), props: [["box", lifted ? 4 : 14, lifted ? 0 : 3]], fx: BLANK, fxSlot: "fg", caption: "is moving boxes" };
356 },
357 mining: (t) => {
358 const down = t % 2 === 1;
359 return { pose: "tool-use", props: [["grass", 15, 3], [down ? "pickDown" : "pickUp", 12, down ? 2 : 0]], fx: down ? mid("tok") : BLANK, fxSlot: "muted", caption: "is mining blocks" };
360 },
361 bugnet: (t) => {
362 const caught = t % 12 >= 9;
363 return { pose: caught ? "done" : "walk", props: [["net", 12, 1], ...(caught ? [] : [[["r"], 16 + (t % 3), cyc(t, [0, 2, 1])]])], fx: caught ? mid("got it") : BLANK, fxSlot: "red", caption: "is catching a bug" };
364 },
365 roller: (t) => {
366 const n = Math.floor((t % 24) / 3);
367 const wall = Array.from({ length: 5 }, () => "b".repeat(n).padEnd(8, "."));
368 return { pose: "tool-use", props: [[wall, 12, 0], ["roller", 12 + Math.min(n, 7), 2]], fx: BLANK, fxSlot: "blue", caption: "is painting the wall" };
369 },
370
371 // responding: writing the answer
372 keyboard: (t) => {
373 const letters = ["a", "{", "</", "ok", ";", "fn", "=>"];
374 const fx = ["", "", ""];
375 fx[2 - (t % 3)] = " ".repeat(t % 3) + letters[Math.floor(t / 3) % letters.length];
376 return { pose: "responding", props: [["keyboard", 12, 5, { light: t % 7 }]], fx, fxSlot: "accent", caption: "is typing" };
377 },
378 typewriter: (t) => ({ pose: "responding", props: [[Array.from({ length: 1 + (Math.floor(t / 3) % 3) }, () => "ffff"), 13, 3 - (Math.floor(t / 3) % 3)], ["typewriter", 12, 4]], fx: t % 6 === 5 ? mid("ding") : BLANK, fxSlot: "yellow", caption: "is on the typewriter" }),
379 quill: (t) => {
380 const n = Math.floor((t % 20) / 4);
381 const scroll = ["ffffff", ...Array.from({ length: 3 }, (_, i) => (i < n ? "fuuuuf" : "f....f")), "ffffff"];
382 return { pose: "read", props: [[scroll, 13, 1], ["quill", 11, 0]], fx: BLANK, fxSlot: "fg", caption: "is writing with a quill" };
383 },
384 piano: (t) => ({ pose: "hop", props: [["piano", 12, 3, { light: t % 6 }]], fx: cyc(t, [["♪", "", ""], ["", " ♫", ""], ["", "", "♪"]]), fxSlot: "magenta", caption: "is playing the piano" }),
385 printer: (t) => {
386 const n = Math.floor((t % 15) / 3);
387 return { pose: "idle", props: [["printer", 12, 1], [Array.from({ length: n }, () => "fff"), 13, 4 - Math.min(n, 2) + 2]], fx: mid(cyc(t, ["brr", "zzt", ""])), fxSlot: "muted", caption: "is printing the answer" };
388 },
389 chalk: (t) => {
390 const n = Math.floor((t % 16) / 2);
391 const board = ["gggggg", "g....g", "g....g", "gggggg"].map((r) => [...r]);
392 for (let i = 0; i < n; i++) board[1 + (i % 2)][1 + Math.floor(i / 2)] = "f";
393 return { pose: "responding", props: [[board.map((r) => r.join("")), 13, 1]], fx: BLANK, fxSlot: "fg", caption: "is writing on the chalkboard" };
394 },
395 telegraph: (t) => ({ pose: t % 2 ? "chew" : "idle", props: [["telegraph", 13, 3]], fx: mid(cyc(t, ["·", "·−", "·−·", "−·−"])), fxSlot: "yellow", caption: "is tapping out morse" }),
396 knitting: (t) => {
397 const n = 1 + (Math.floor(t / 4) % 4);
398 return { pose: "chew", props: [["needles", 12, 1], [Array.from({ length: n }, (_, i) => (i % 2 ? "rrr" : "fff")), 13, 2]], fx: BLANK, fxSlot: "red", caption: "is knitting a reply" };
399 },
400 megaphone: (t) => ({ pose: "responding", props: [["megaphone", 12, 1]], fx: cyc(t, [mid(")"), mid("))"), mid(")))hey")]), fxSlot: "yellow", caption: "is announcing the answer" }),
401
402 // states
403 blocked: (t) => ({ pose: "blocked", fx: t % 4 < 2 ? mid("!") : mid("!!"), fxSlot: "red", caption: "needs you" }),
404 done: (t) => ({ pose: "done", fx: t % 4 < 2 ? ["✦", " ✧", "✦"] : [" ✧", "✦", " ✧"], fxSlot: "yellow", caption: "is done" }),
405};
406
407Object.assign(WORK, WORK_MORE, WORK_MEMES, WORK_INDUSTRIAL);
408
409const TOOLS = ["hammer", "gears", "saw", "drill", "lab", "weld", "boxes", "mining", "bugnet", "roller", ...POOLS_MORE.tool];
410
411export const WORK_POOLS = {
412 requesting: ["laptop", "satellite", "pigeon", "phonecall", "mail", "launch", "radio", ...POOLS_MORE.requesting],
413 thinking: ["bubbles", "chess", "cube", "idea", "whiteboard", "crystal", "detective", "blocks", ...POOLS_MORE.thinking],
414 "tool-input": TOOLS,
415 "tool-use": TOOLS,
416 responding: ["keyboard", "typewriter", "quill", "piano", "printer", "chalk", "telegraph", "knitting", "megaphone", ...POOLS_MORE.responding],
417 blocked: ["blocked"],
418 done: ["done"],
419};
420
421export const WORK_SCENES = Object.keys(WORK);
422
423// Which idle scene plays now: a new one every 40 seconds, in an order that
424// differs per monster.
425export function idleSceneAt(monster, now, period = 40_000) {
426 const block = Math.floor(now / period);
427 return IDLE_SCENES[hashString(`${monster.seed}:${block}`) % IDLE_SCENES.length];
428}
429
430// Which work scene suits a mode now: a new one every 15 seconds.
431export function workSceneAt(monster, mode, now, period = 15_000) {
432 const pool = WORK_POOLS[mode] ?? WORK_POOLS.thinking;
433 const block = Math.floor(now / period);
434 return pool[hashString(`${monster.seed}:${mode}:${block}`) % pool.length];
435}
436
437// Cheer scenes play while you type a prompt: a new one every 10 seconds.
438export const CHEER_SCENES = Object.keys(CHEER);
439
440export function cheerSceneAt(monster, now, period = 10_000) {
441 const block = Math.floor(now / period);
442 return CHEER_SCENES[hashString(`${monster.seed}:cheer:${block}`) % CHEER_SCENES.length];
443}
444
445export function scene(monster, name, frame) {
446 const make = IDLE[name] ?? WORK[name] ?? CHEER[name] ?? WORK[WORK_POOLS[name]?.[0]] ?? IDLE.sleep;
447 const s = make(frame);
448 const grid = blankGrid();
449 if (!s.hide) drawMonster(grid, monster, s.pose ?? "idle", frame, s.ox ?? 1);
450 for (const [art, x, y, opts] of s.props ?? []) drawArt(grid, art, x, y, opts);
451 return {
452 rows: gridToCells(grid).map(cellsToRuns),
453 fx: (s.fx ?? BLANK).map((f) => f.padEnd(FX_WIDTH)),
454 fxSlot: s.fxSlot ?? "fg",
455 caption: s.caption ?? "",
456 };
457}
458
459// ---- the GitHub critter: a small octocat for the repository column ----
460
461const OCTOCAT = [
462 [".#....#.", ".#....#."],
463 ["########", "########"],
464 ["#o####o#", "########"], // blinks now and then
465 ["########", "########"],
466 [".######.", ".######."],
467 ["#.#..#.#", ".#.##.#."], // tentacles wiggle
468];
469
470// 8x6 px in `slot`, as rows of runs like scene() gives.
471export function octocat(frame, slot = "fg") {
472 const blink = frame % 18 === 17 ? 1 : 0;
473 const wiggle = Math.floor(frame / 2) % 2;
474 const grid = OCTOCAT.map((variants, y) => {
475 const row = y === 2 ? variants[blink] : y === 5 ? variants[wiggle] : variants[0];
476 return [...row].map((ch) => (ch === "#" ? slot : null));
477 });
478 return gridToCells(grid).map(cellsToRuns);
479}
480
481// ---- the GitLab critter: a small fox ----
482
483const FOX = [
484 ["r......r", "r......r"],
485 ["rr....rr", "rr....rr"],
486 ["roooooor", "roooooor"],
487 ["o.oooo.o", "oooooooo"], // blinks now and then
488 [".oooooo.", ".oooooo."],
489 ["..yyyy..", "...yy..."], // the chin moves as it talks
490];
491const FOX_SLOTS = { r: "red", o: "orange", y: "yellow" };
492
493// 8x6 px in the fox's own colors, as rows of runs like scene() gives.
494export function gitlabFox(frame) {
495 const blink = frame % 18 === 17 ? 1 : 0;
496 const chin = Math.floor(frame / 3) % 2;
497 const grid = FOX.map((variants, y) => {
498 const row = y === 3 ? variants[blink] : y === 5 ? variants[chin] : variants[0];
499 return [...row].map((ch) => FOX_SLOTS[ch] ?? null);
500 });
501 return gridToCells(grid).map(cellsToRuns);
502}
503lib/scenes-cheer.mjs 117 lines1// GENERATED from shared/scenes-cheer.mjs by scripts/sync-shared.mjs. Edit the original.
2// Cheer scenes for scenes.mjs: 36 scenes that play while you type a prompt,
3// the monster rooting for you (pom-poms, fireworks, a trophy, a high five…).
4// Same shape as the scenes there: each is a function of the frame returning
5// { pose, ox, props, fx, fxSlot, caption }.
6//
7// Pure module shared by the herdr plugin and the mod.
8
9const cyc = (t, list, period = 1) => list[Math.floor(t / period) % list.length];
10const mid = (text) => ["", text, ""];
11const BLANK = ["", "", ""];
12
13function rising(glyphs, frame, period = 3) {
14 const step = Math.floor(frame / period) % (glyphs.length + 1);
15 const rows = ["", "", ""];
16 for (let i = 0; i < Math.min(step, glyphs.length); i++) rows[2 - i] = " ".repeat(i) + glyphs[i];
17 return rows;
18}
19
20export const ART_CHEER = {
21 pompomA: ["m.m.", ".mm.", "m.m.", "..u."],
22 pompomB: [".m.m", ".mm.", ".m.m", ".u.."],
23 foamFinger: [".y..", ".y..", "yyyy", "yyyy", ".yy."],
24 goSign: ["gggggg", "gfgfgg", "gggggg", "..u...", "..u..."],
25 trophy: ["yyyyyy", "yyyyyy", ".yyyy.", "..yy..", ".oooo."],
26 medal: ["r..r", ".rr.", ".yy.", "yyyy", ".yy."],
27 heartBig: [".rr.rr", "rrrrrr", ".rrrr.", "..rr.."],
28 heartSmall: ["r.r", "rrr", ".r."],
29 thumb: ["..y.", "..y.", "yyyy", "yyyy", "yyy."],
30 starA: ["..y..", "yyyyy", ".yyy.", "y...y"],
31 starB: ["..a..", "aaaaa", ".aaa.", "a...a"],
32 crown: ["y.y.y", "yyyyy", "yryby"],
33 flagA: ["rrrr", "rrrr", "u...", "u...", "u...", "u..."],
34 flagB: ["rrr.", "rrrr", "u.r.", "u...", "u...", "u..."],
35 banner: ["uuuuuuu", "mmmmmmm", ".mmmmm."],
36 scoreboard: ["uuuuuu", "ugugu.", "uuuuuu", "..u..."],
37 finishFlag: ["fufu", "ufuf", "u..u", "u..u", "u..u", "u..u"],
38 cheerCake: ["..y...", "..f...", "mmmmmm", "ffffff", "mmmmmm"],
39 giftShut: ["..r..", "ooroo", "ooroo", "ooroo"],
40 giftOpen: ["r...r", ".....", "ooroo", "ooroo"],
41 bouquet: ["r.m.y", ".rmy.", "..g..", ".ggg.", "..g.."],
42 sunA: ["y.y.y", ".yyy.", "yyyyy", ".yyy.", "y.y.y"],
43 sunB: ["..y..", "yyyyy", ".yyy.", "yyyyy", "..y.."],
44 rainbow: [".rrrr.", "ryyyyr", "yg..gy", "g....g"],
45 glowA: ["g.", "g.", ".u"],
46 glowB: [".g", ".g", "u."],
47 bell: ["..u..", ".yyy.", ".yyy.", "yyyyy", "..y.."],
48 gong: [".uuu.", "uyyyu", "uyyyu", ".uuu.", "u...u"],
49 hand: ["f.f.f", "fffff", "fffff", ".fff."],
50 sparkler: ["y", "u", "u", "u", "u"],
51 ovation: ["f...f..f", "f.f.f..f", "uuuuuuuu"],
52};
53
54const confetti = (t, n = 6) =>
55 Array.from({ length: n }, (_, i) => [[["r", "y", "g", "b", "m", "c"][i]], 11 + ((i * 3 + t) % 9), (i + t) % 6]);
56
57export const CHEER = {
58 pompoms: (t) => ({ pose: "hop", props: [[cyc(t, ["pompomA", "pompomB"]), 13, 0]], fx: mid(cyc(t, ["go!", "go go!", "GO!"], 2)), fxSlot: "magenta", caption: "is cheering you on" }),
59 foamFinger: (t) => ({ pose: t % 4 < 2 ? "hop" : "idle", props: [["foamFinger", 13, cyc(t, [0, 1])]], fx: mid("#1"), fxSlot: "yellow", caption: "says you're number one" }),
60 goSign: (t) => ({ pose: "hop", props: [["goSign", 13, cyc(t, [0, 1], 2)]], fx: mid(cyc(t, ["GO", "GO GO", ""], 2)), fxSlot: "green", caption: "holds up a GO! sign" }),
61 theWave: (t) => ({ pose: cyc(t, ["idle", "hop", "hop", "idle"]), fx: mid(cyc(t, ["\\o/", " o ", "\\o/", "/o\\"])), fxSlot: "accent", caption: "is doing the wave" }),
62 clapping: (t) => ({ pose: t % 2 ? "chew" : "hop", fx: cyc(t, [mid("clap"), ["clap", "", " clap"], BLANK]), fxSlot: "yellow", caption: "is clapping for you" }),
63 trophy: (t) => ({ pose: "done", props: [["trophy", 13, 1]], fx: t % 6 < 3 ? ["✦", "", " ✧"] : [" ✧", "✦", ""], fxSlot: "yellow", caption: "has a trophy ready for you" }),
64 medal: (t) => ({ pose: t % 8 < 4 ? "chew" : "done", props: [["medal", 13, 1]], fx: t % 8 < 4 ? mid("*shine*") : BLANK, fxSlot: "yellow", caption: "is polishing your medal" }),
65 confetti: (t) => ({ pose: "hop", props: confetti(t), fx: mid(cyc(t, ["yay!", "woo!"], 3)), fxSlot: "magenta", caption: "throws confetti for you" }),
66 fireworks: (t) => {
67 const p = t % 12;
68 const burst = p < 4 ? [[["y"], 15, 5 - p]] : p < 8 ? [["starA", 13, 0]] : [["starB", 13, 0]];
69 return { pose: p < 4 ? "idle" : "done", props: burst, fx: p >= 4 && p < 6 ? mid("BOOM") : BLANK, fxSlot: "red", caption: "lights fireworks for your idea" };
70 },
71 drumroll: (t) => ({ pose: "tool-use", props: [["drums", 13, 2]], fx: mid(cyc(t, ["brrr", "brrrr", "brrrrr"])), fxSlot: "yellow", caption: "plays a drumroll for your prompt" }),
72 hype: (t) => ({ pose: "responding", props: [["megaphone", 12, 1]], fx: cyc(t, [mid("YOU"), mid("GOT"), mid("THIS!")], 2), fxSlot: "orange", caption: "hypes you up" }),
73 love: (t) => ({ pose: "chew", props: [[t % 4 < 2 ? "heartBig" : "heartSmall", t % 4 < 2 ? 13 : 14, 1]], fx: rising(["♥", "♥", "♥"], t, 2), fxSlot: "red", caption: "sends you love" }),
74 thumbsUp: (t) => ({ pose: "done", props: [["thumb", 13, cyc(t, [1, 0, 1, 1])]], fx: mid(cyc(t, ["nice", "nice!"], 3)), fxSlot: "green", caption: "gives you a thumbs up" }),
75 superstar: (t) => ({ pose: "hop", props: [[cyc(t, ["starA", "starB"], 2), 13, 1]], fx: cyc(t, [["✦", "", ""], ["", " ✧", ""], ["", "", "✦"]]), fxSlot: "yellow", caption: "thinks you're a star" }),
76 moonshot: (t) => {
77 const y = 5 - (t % 9);
78 return { pose: "done", props: y > -5 ? [["rocket", 14, y]] : [], fx: y < 0 ? mid("to the moon") : mid("3.. 2.."), fxSlot: "cyan", caption: "launches your idea" };
79 },
80 crown: (t) => ({ pose: "done", props: [["crown", 4, cyc(t, [0, 0, 0, 1])]], fx: t % 6 < 3 ? mid("✧") : BLANK, fxSlot: "yellow", caption: "crowns you prompt royalty" }),
81 flag: (t) => ({ pose: "hop", props: [[cyc(t, ["flagA", "flagB"]), 14, 0]], fx: BLANK, fxSlot: "red", caption: "waves your flag" }),
82 banner: (t) => ({ pose: "idle", props: [["banner", 12, 0]], fx: mid(cyc(t, ["YAY", "YAAAY"], 3)), fxSlot: "magenta", caption: "hangs up a banner for you" }),
83 scoreboard: (t) => ({ pose: t % 10 >= 7 ? "done" : "read", props: [["scoreboard", 13, 1]], fx: mid(`${Math.min(10, 1 + (t % 10))}/10`), fxSlot: "green", caption: "scores your prompt 10/10" }),
84 finishLine: (t) => {
85 const ox = Math.min(5, Math.floor((t % 16) / 2));
86 return { pose: ox < 5 ? "walk" : "done", ox: 1 + ox, props: [["finishFlag", 16, 0]], fx: ox >= 5 ? mid("WIN") : BLANK, fxSlot: "green", caption: "races you to the finish line" };
87 },
88 torch: (t) => ({ pose: "walk", ox: 1 + (Math.floor(t / 2) % 3), props: [["torch", 14 + (Math.floor(t / 2) % 3), 2], [[cyc(t, ["o", "r", "y"])], 14 + (Math.floor(t / 2) % 3), 1]], fx: BLANK, fxSlot: "orange", caption: "carries the torch for you" }),
89 cake: (t) => ({ pose: t % 8 < 4 ? "chew" : "done", props: [["cheerCake", 13, 1]], fx: cyc(t, [mid("ta-da"), BLANK], 4), fxSlot: "magenta", caption: "baked you a cake" }),
90 gift: (t) => {
91 const open = t % 12 >= 6;
92 return { pose: open ? "done" : "idle", props: [[open ? "giftOpen" : "giftShut", 13, 2]], fx: open ? mid("for u!") : BLANK, fxSlot: "red", caption: "has a gift for your idea" };
93 },
94 bouquet: (t) => ({ pose: t % 6 < 3 ? "chew" : "idle", props: [["bouquet", 13, 1]], fx: t % 6 < 3 ? mid("❀") : BLANK, fxSlot: "magenta", caption: "brings you flowers" }),
95 sunshine: (t) => ({ pose: "done", props: [[cyc(t, ["sunA", "sunB"], 2), 14, 0]], fx: mid("☀"), fxSlot: "yellow", caption: "brings the sunshine" }),
96 rainbow: (t) => ({ pose: "hop", props: [["rainbow", 13, 1]], fx: t % 6 < 3 ? mid("✧") : BLANK, fxSlot: "accent", caption: "paints you a rainbow" }),
97 glowStick: (t) => ({ pose: "hop", props: [[cyc(t, ["glowA", "glowB"]), 14, 1], [cyc(t + 1, ["glowA", "glowB"]), 17, 2]], fx: mid(cyc(t, ["♪", "♫"], 2)), fxSlot: "green", caption: "waves a glow stick" }),
98 bell: (t) => ({ pose: "tool-use", props: [["bell", 13 + cyc(t, [0, 1, 0, -1]), 0]], fx: mid(cyc(t, ["ding", "ding ding"], 2)), fxSlot: "yellow", caption: "rings the bell for you" }),
99 gong: (t) => {
100 const hit = t % 8 === 4;
101 return { pose: hit ? "tool-use" : "idle", props: [["gong", 13, 0]], fx: hit || t % 8 === 5 ? mid("GONNG") : BLANK, fxSlot: "orange", caption: "hits the gong" };
102 },
103 highFive: (t) => {
104 const p = t % 10;
105 return { pose: p >= 6 ? "done" : "idle", props: [["hand", 19 - Math.min(p, 6), 1]], fx: p >= 6 ? mid("high five!") : BLANK, fxSlot: "yellow", caption: "goes for a high five" };
106 },
107 happyDance: (t) => ({ pose: "hop", ox: 1 + cyc(t, [0, 1, 2, 1, 0, 1, 2, 3]), fx: cyc(t, [["♪", "", " ♫"], ["", "♫", ""], [" ♪", "", "♪"]], 2), fxSlot: "magenta", caption: "does a happy dance" }),
108 sparkler: (t) => ({ pose: "done", props: [["sparkler", 13, 1], [[cyc(t, ["y", "o", "f"])], 13, 0], [[cyc(t + 1, ["y", "o", "f"])], 12 + (t % 3), 0]], fx: cyc(t, [["*", " ·", ""], [" ·", "*", ""], ["", " *", "·"]]), fxSlot: "yellow", caption: "lights a sparkler" }),
109 levelUp: (t) => {
110 const xp = Math.min(6, t % 10);
111 return { pose: xp >= 6 ? "done" : "idle", fx: ["", `${"▰".repeat(xp)}${"▱".repeat(6 - xp)}`, xp >= 6 ? "LVL UP!" : "+xp"], fxSlot: "green", caption: "says your prompt levels you up" };
112 },
113 coinShower: (t) => ({ pose: "hop", props: [0, 1, 2, 3].map((i) => ["coin", 12 + i * 2, (t + i * 2) % 6]), fx: mid("+$$$"), fxSlot: "yellow", caption: "showers you in coins" }),
114 kudos: (t) => ({ pose: "done", fx: rising(["+1", "+1", "+1"], t, 2), fxSlot: "accent", caption: "gives you kudos" }),
115 ovation: (t) => ({ pose: t % 4 < 2 ? "hop" : "done", props: [["ovation", 12, t % 4 < 2 ? 3 : 2]], fx: mid(cyc(t, ["bravo!", "encore!"], 4)), fxSlot: "orange", caption: "gives you a standing ovation" }),
116};
117lib/scenes-more.mjs 378 lines1// GENERATED from shared/scenes-more.mjs by scripts/sync-shared.mjs. Edit the original.
2// More scenes for scenes.mjs: 30 idle (chores, pop culture, sports) and 30
3// work scenes (a few per turn mode). Same shape as the scenes there: each is
4// a function of the frame returning { pose, ox, props, fx, fxSlot, caption }.
5//
6// Pure module shared by the herdr plugin and the mod.
7
8const cyc = (t, list, period = 1) => list[Math.floor(t / period) % list.length];
9const mid = (text) => ["", text, ""];
10const BLANK = ["", "", ""];
11
12function rising(glyphs, frame, period = 3) {
13 const step = Math.floor(frame / period) % (glyphs.length + 1);
14 const rows = ["", "", ""];
15 for (let i = 0; i < Math.min(step, glyphs.length); i++) rows[2 - i] = " ".repeat(i) + glyphs[i];
16 return rows;
17}
18const fill = (rows, n, ch) => rows.map((r, i) => (i < n ? r.replace(/\./g, ch) : r));
19
20export const ART_MORE = {
21 // chores
22 pan: ["u.....", "uuuuuo"],
23 cake: ["..y..", "..f..", "fffff", "mmmmm", "fffff"],
24 fireA: ["..y..", ".yry.", "ryoyr", "ooooo"],
25 fireB: [".y...", "..yr.", ".ryry", "ooooo"],
26 ironBoard: ["uuuuuuu", ".u...u."],
27 iron: [".uu.", "uuuu"],
28 duster: ["mmm", ".m.", ".o.", ".o."],
29 bin: ["uuuuu", ".uuu.", ".uuu.", ".uuu."],
30 mop: ["o..", "o..", "o..", "ccc"],
31 window: ["bbbbb", "bcbcb", "bbbbb", "bcbcb", "bbbbb"],
32 // pop culture
33 discoA: ["..u..", ".fcf.", "cfcfc", ".fcf."],
34 discoB: ["..u..", ".cfc.", "fcfcf", ".cfc."],
35 arcadeA: ["uuuuu", "ucccu", "ucacu", "uuuuu", "uyruu", "uuuuu"],
36 arcadeB: ["uuuuu", "uacau", "ucccu", "uuuuu", "uyruu", "uuuuu"],
37 egg: [".gg.", "gyyg", "gggg", ".gg."],
38 eggCracked: [".gg.", "guyg", "gugg", ".gg."],
39 dragon: ["r..r", "rrrr", ".rr.", "r..r"],
40 bluebox: ["..b..", "bbbbb", "bfbfb", "bbbbb", "bbbbb", "bbbbb"],
41 capeA: ["rr..", "rrr.", ".rrr"],
42 capeB: ["r...", "rrr.", "rrrr"],
43 wand: ["...y", "..o.", ".o..", "o..."],
44 city: [".u..u.", ".u.uu.", "uuuuuu"],
45 ufo: [".uu.", "uuuu"],
46 beam: ["y.y", ".y.", "y.y"],
47 ship: ["..c..", ".ccc.", "c.c.c"],
48 // sports
49 bike: ["uu..uu", "u.uu.u", "uu..uu"],
50 bag: ["u.", "rr", "rr", "rr", "rr"],
51 surfboard: ["yyyyyyyyyy"],
52 wave: ["....cc", "...ccc", "..cccc", "cccccc"],
53 flag: ["r..", "rr.", "u..", "u..", "u.."],
54 pins: ["f.f.f", ".f.f."],
55 pinsDown: [".....", "ff.ff"],
56 netPole: ["u", "u", "u", "u"],
57 finish: ["fu", "uf", "fu", "uf", "fu"],
58 rope: ["oooooooooo"],
59 bat: ["..o", ".o.", "o.."],
60 target: [".rrr.", "rfffr", "rfrfr", "rfffr", ".rrr."],
61 holds: ["u.u..", "..u.u", "u..u.", ".u..u", "u.u.."],
62 kite: [".m.", "mmm", ".m.", "..u", "...u"],
63 // work: requesting
64 smallFire: ["..y..", ".ryr.", "ooooo"],
65 plane: ["f...", "ff..", "fff.", "ff.."],
66 walkie: ["u.", "uu", "uu", "uu"],
67 lighthouse: [".y.", "uuu", "rrr", "fff", "rrr", "fff"],
68 beacon: ["..y..", ".yry.", "ryoyr", "..u..", "..u.."],
69 // work: thinking
70 abacusA: ["uuuuuu", "uryybu", "uyrbyu", "uuuuuu"],
71 abacusB: ["uuuuuu", "uyrybu", "uryybu", "uuuuuu"],
72 puzzleApart: ["r..g", "....", "b..y"],
73 puzzleTogether: [".rg.", ".by."],
74 map: ["yyyyyy", "y....y", "y....y", "yyyyyy"],
75 compassA: [".uu.", "u.ru", "u..u", ".uu."],
76 compassB: [".uu.", "u..u", "u.ru", ".uu."],
77 compassC: [".uu.", "u..u", "ur.u", ".uu."],
78 hourTop: ["uuuu", ".yy.", "..u.", ".u..", "....", "uuuu"],
79 hourBottom: ["uuuu", "....", "..u.", ".u..", ".yy.", "uuuu"],
80 maze: ["uuuuuu", "u..u.u", "u.uu.u", "u....u", "uuuuuu"],
81 sudoku: ["fufuf", "ufufu", "fufuf"],
82 // work: tools
83 screwdriver: ["y", "y", "u", "u"],
84 circuit: ["gggggg", "gyygyg", "gggggg"],
85 solderIron: ["u..", ".u.", "..y"],
86 sewing: ["uuuu.", "u..uu", "uuuuu"],
87 crane: ["uuuuuu", ".u...u", ".u....", ".u....", ".u....", "uuu..."],
88 frame3d: ["uuuuu", "u...u", "u...u", "uuuuu"],
89 anvil: ["uuuu.", ".uu..", "uuuu."],
90 belt: ["uuuuuuuu"],
91 digger: ["..oo", ".o..", "o...", "oooo"],
92 // work: responding
93 stamp: ["oo", ".o", "rr"],
94 paper: ["ffff", "ffff"],
95 clapOpen: ["f.u.", ".u.f", "ffff", "ffff"],
96 clapShut: ["fufu", "ffff", "ffff"],
97 neonOn: ["mmmmm", "m.a.m", "mmmmm"],
98 neonOff: ["uuuuu", "u...u", "uuuuu"],
99 fax: ["uuuu", "uuuu", ".ff."],
100 craneFolded: ["f..f", ".ff.", "ffff"],
101 craneFlat: ["ffff", "ffff", "ffff"],
102 // work: memes and IT life
103 flames: ["y..y..y", "ry.ry.r", "rorroro"],
104 calendar: ["rrrr", "ffff", "fuff", "ffff"],
105 terminal: ["uuuuuu", "ug...u", "u....u", "uuuuuu"],
106 powerOn: [".gg.", "g.gg", "g..g", ".gg."],
107 powerOff: [".uu.", "u.uu", "u..u", ".uu."],
108 clipboard: [".uu.", "ffff", "fuuf", "ffff", "fuff"],
109 helm: [".b.b.", "bbbbb", ".bfb.", "bbbbb", ".b.b."],
110 helmTurn: ["b...b", ".bbb.", "bbfbb", ".bbb.", "b...b"],
111 whale: ["o.y.g.", "cccccc", "cccccf", ".cccc."],
112 bsod: ["uuuuuu", "ubbbbu", "ubfbbu", "uuuuuu", ".uu..."],
113 sticker: ["uuuuuuu", "ugcccgu", "uuuuuuu", "uuuuuuu"],
114 // idle: party, tired, instruments
115 balloons: ["rr.bb", "rr.bb", ".u.u.", "..u.."],
116 pinata: ["..u..", "mmm..", "mmmm.", ".m.m."],
117 drums: ["y...y", "uuu.u", "uuu.u"],
118 trumpet: ["yy...", "yyyyy", "yy..."],
119 violin: ["..o.", ".oo.", "oooo", ".oo."],
120 sax: ["y...", "y...", "yy.y", "yyyy"],
121 xylophone: ["rrrrrr", "yyyyy.", "gggg..", "bbb..."],
122 bubbleWand: ["..u", ".u.", "u.."],
123 // work: industrial instrumentation and automation
124 transmitter: [".bbb.", "bfffb", ".bbb.", "..u..", ".uuu."],
125 communicator: ["uu", "uc", "uu"],
126 aplSwitchA: ["uuuuuu", "ugugug", "uuuuuu", "..y..."],
127 aplSwitchB: ["uuuuuu", "uauaua", "uuuuuu", "..y..."],
128 pipe: ["uuuuuuuu", "........", "uuuuuuuu"],
129 coriolis: ["u....u", "u....u", "uuuuuu"],
130 plcRack: ["uuuuuu", "ugurub", "uuuuuu", "uuuuuu"],
131 valveOpen: [".uuu.", "uuuuu", "..u..", "..u..", "yy.yy"],
132 valveShut: [".uuu.", "uuuuu", "..u..", "..u..", "yyuyy"],
133 calibrator: ["yyyy", "ycfy", "yyyy", "yuuy"],
134 scada: ["uuuuuu", "ug.r.u", "u.g.gu", "uuuuuu", "..uu.."],
135};
136
137export const IDLE_MORE = {
138 // chores
139 cooking: (t) => ({ pose: "tool-use", props: [["pan", 12, 4], [["yy"], 13, cyc(t, [3, 1, 0, 1, 3])]], fx: mid(cyc(t, ["sizzle", "", "flip!"], 2)), fxSlot: "orange", caption: "is flipping pancakes" }),
140 baking: (t) => ({ pose: "done", props: [["cake", 13, 1], [[cyc(t, ["y", "o"])], 15, 0]], fx: t % 10 < 4 ? mid("♪ hbd") : BLANK, fxSlot: "magenta", caption: "baked a cake" }),
141 campfire: (t) => ({ pose: "sleep", props: [[cyc(t, ["fireA", "fireB"]), 13, 2]], fx: cyc(t, [["~", "", ""], [" ~", "~", ""], ["", " ~", ""]], 2), fxSlot: "muted", caption: "is camping by the fire" }),
142 ironing: (t) => ({ pose: "tool-use", props: [["ironBoard", 12, 4], ["iron", 12 + cyc(t, [0, 1, 2, 3, 2, 1]), 2]], fx: cyc(t, [mid("≈"), BLANK]), fxSlot: "muted", caption: "is ironing shirts" }),
143 dusting: (t) => ({ pose: "walk", props: [["duster", 12 + (t % 3), cyc(t, [0, 1])]], fx: cyc(t, [["*", "", ""], ["", " *", ""], ["", "", "*"]]), fxSlot: "muted", caption: "is dusting the shelves" }),
144 trash: (t) => {
145 const p = t % 16;
146 return { pose: p < 8 ? "walk" : "idle", ox: 1 + Math.min(Math.floor(p / 2), 3), props: [["bin", 15, 2], ...(p < 8 ? [[["uu", "uu"], 13 + Math.min(Math.floor(p / 2), 3), 2]] : [])], fx: p >= 10 ? mid("phew") : BLANK, fxSlot: "muted", caption: "is taking out the trash" };
147 },
148 mopping: (t) => ({ pose: "walk", props: [["mop", 12 + (t % 3), 1], [cyc(t, [["cc.cc.cc"], [".cc.cc.c"]]), 12, 5]], fx: t % 6 === 5 ? mid("✧") : BLANK, fxSlot: "cyan", caption: "is mopping the floor" }),
149 windows: (t) => ({ pose: "tool-use", props: [["window", 13, 0], [["yyyyy"], 13, t % 5]], fx: t % 5 === 4 ? mid("squeak") : BLANK, fxSlot: "cyan", caption: "is cleaning the windows" }),
150 // pop culture
151 disco: (t) => ({ pose: "hop", props: [[cyc(t, ["discoA", "discoB"]), 13, 0]], fx: cyc(t, [["♪", "", "✦"], ["", "✦ ♪", ""], ["✦", "", "♪"]], 2), fxSlot: "magenta", caption: "is dancing at the disco" }),
152 arcade: (t) => ({ pose: "read", props: [[cyc(t, ["arcadeA", "arcadeB"], 2), 13, 0]], fx: t % 12 >= 10 ? mid("1UP") : BLANK, fxSlot: "green", caption: "is beating the high score" }),
153 dragonegg: (t) => {
154 const p = t % 30;
155 const art = p < 12 ? "egg" : p < 20 ? "eggCracked" : "dragon";
156 return { pose: p >= 20 ? "done" : "read", props: [[art, 14 + (p < 12 && p % 2 ? 1 : 0), 2]], fx: p >= 12 && p < 16 ? mid("crack!") : p >= 20 ? mid("rawr") : BLANK, fxSlot: "red", caption: p >= 20 ? "hatched a dragon" : "is keeping a dragon egg warm" };
157 },
158 bluebox: (t) => {
159 const p = t % 20;
160 const shown = p >= 6 && (p >= 10 || p % 2 === 0);
161 return { pose: p >= 10 ? "walk" : "idle", ox: p >= 14 ? Math.min(1 + (p - 14), 6) : 1, hide: p >= 19, props: shown ? [["bluebox", 15, 0]] : [], fx: p >= 6 && p < 10 ? mid("vworp") : BLANK, fxSlot: "blue", caption: "found a blue police box" };
162 },
163 superhero: (t) => ({ pose: "hop", props: [[cyc(t, ["capeA", "capeB"]), 0, 1]], fx: cyc(t, [mid("up!"), ["↑", "", ""], BLANK]), fxSlot: "red", caption: "is flying like a superhero" }),
164 wizard: (t) => ({ pose: "tool-use", props: [["wand", 12, 1]], fx: cyc(t, [["✦", "", ""], ["", "✧ ⋆", ""], ["⋆", "", "✦"]]), fxSlot: "accent", caption: "is casting spells" }),
165 zombie: (t) => ({ pose: "walk", ox: 1 + (Math.floor(t / 3) % 4), props: [[["gg"], 11 + (Math.floor(t / 3) % 4), 2]], fx: t % 8 < 4 ? mid("brains") : BLANK, fxSlot: "green", caption: "is doing the zombie walk" }),
166 kaiju: (t) => ({ pose: t % 4 < 2 ? "hop" : "blocked", props: [["city", 13 + (t % 2), 3]], fx: t % 8 < 4 ? mid("ROAR") : BLANK, fxSlot: "red", caption: "is stomping the city" }),
167 ufo: (t) => {
168 const beam = t % 10 >= 5;
169 return { pose: beam ? "hop" : "idle", props: [["ufo", 13, 0], ...(beam ? [["beam", 13, 2]] : [])], fx: beam ? mid("beep") : BLANK, fxSlot: "green", caption: "is waving at a UFO" };
170 },
171 shooter: (t) => ({ pose: "read", props: [["ship", 13, 3], [["y"], 15, 2 - (t % 3)], [["g.g"], 13 + (t % 3), 0]], fx: t % 3 === 0 ? mid("pew") : BLANK, fxSlot: "yellow", caption: "is defending earth from invaders" }),
172 // sports
173 cycling: (t) => ({ pose: "walk", props: [["bike", 12, 3]], fx: t % 10 === 9 ? mid("ring") : cyc(t, [mid("~"), mid(" ~")]), fxSlot: "muted", caption: "is out cycling" }),
174 boxing: (t) => {
175 const hit = t % 4 === 1;
176 return { pose: "tool-use", props: [["bag", hit ? 14 : 13, 0]], fx: hit ? mid("pow") : BLANK, fxSlot: "red", caption: "is hitting the punching bag" };
177 },
178 surfing: (t) => ({ pose: "hop", props: [["wave", 14, 2], ["surfboard", 0, 5]], fx: t % 12 < 3 ? mid("radical") : BLANK, fxSlot: "cyan", caption: "is surfing" }),
179 golf: (t) => {
180 const p = t % 12;
181 return { pose: p < 2 ? "tool-use" : "idle", props: [["flag", 17, 1], [["f"], Math.min(12 + p, 17), 5]], fx: p >= 6 && p < 10 ? mid("hole in one") : BLANK, fxSlot: "green", caption: "is playing golf" };
182 },
183 bowling: (t) => {
184 const p = t % 12;
185 return { pose: p < 2 ? "tool-use" : p >= 8 ? "done" : "idle", props: [[p >= 7 ? "pinsDown" : "pins", 15, 4], ...(p < 7 ? [[["b"], 12 + p, 5]] : [])], fx: p >= 8 ? mid("strike!") : BLANK, fxSlot: "yellow", caption: "is bowling" };
186 },
187 volleyball: (t) => {
188 const arc = [[12, 3], [13, 1], [14, 0], [15, 0], [16, 1], [17, 3], [16, 1], [15, 0], [14, 0], [13, 1]];
189 const [x, y] = cyc(t, arc);
190 return { pose: x <= 12 ? "hop" : "idle", props: [["netPole", 15, 2], [["y"], x, y]], fx: x <= 12 ? mid("spike") : BLANK, fxSlot: "yellow", caption: "is playing volleyball" };
191 },
192 running: (t) => {
193 const p = t % 16;
194 return { pose: p >= 12 ? "done" : "walk", ox: 1 + Math.min(Math.floor(p / 2), 5), props: [["finish", 18, 1]], fx: p >= 12 ? mid("win!") : BLANK, fxSlot: "yellow", caption: "is running a race" };
195 },
196 // the rope swings over the head, then under the feet as it hops
197 jumprope: (t) => ({
198 pose: t % 2 ? "hop" : "idle",
199 props: [[t % 2 ? ["o..........o", ".oooooooooo."] : [".oooooooooo.", "o..........o"], 0, t % 2 ? 4 : 0, { slot: "orange" }]],
200 fx: mid(String(1 + (Math.floor(t / 2) % 99))),
201 fxSlot: "orange",
202 caption: "is jumping rope",
203 }),
204 baseball: (t) => {
205 const p = t % 10;
206 return { pose: p === 5 ? "tool-use" : "idle", props: [["bat", 11, 1], [["f"], p < 5 ? 18 - p : 12 + p, p < 5 ? 2 : 2 - Math.min(p - 5, 2)]], fx: p >= 5 && p < 9 ? mid(p === 5 ? "crack" : "home run") : BLANK, fxSlot: "yellow", caption: "is batting" };
207 },
208 archery: (t) => {
209 const p = t % 10;
210 return { pose: p === 0 ? "tool-use" : "idle", props: [["target", 15, 0], [["uu"], Math.min(12 + p, 15), 2]], fx: p >= 4 ? mid("bullseye") : BLANK, fxSlot: "red", caption: "is practicing archery" };
211 },
212 climbing: (t) => ({ pose: "hop", props: [["holds", 13, cyc(t, [1, 0])]], fx: t % 8 === 7 ? mid("↑ top") : BLANK, fxSlot: "orange", caption: "is rock climbing" }),
213 kite: (t) => ({ pose: "idle", props: [["kite", 14 + cyc(t, [0, 1, 1, 0]), cyc(t, [0, 0, 1, 1])]], fx: cyc(t, [mid("~"), mid(" ~")]), fxSlot: "magenta", caption: "is flying a kite" }),
214};
215
216export const WORK_MORE = {
217 // requesting
218 smoke: (t) => ({ pose: "idle", props: [["smallFire", 13, 3]], fx: cyc(t, [["", "", "o"], ["", " O", ""], ["o", "", ""]]), fxSlot: "muted", caption: "is sending smoke signals" }),
219 paperplane: (t) => ({ pose: t % 8 === 0 ? "tool-use" : "idle", props: [["plane", 11 + (t % 8), 2 - Math.floor((t % 8) / 3)]], fx: t % 8 >= 4 ? mid("whoosh") : BLANK, fxSlot: "fg", caption: "is throwing a paper airplane" }),
220 walkie: (t) => ({ pose: "chew", props: [["walkie", 12, 1]], fx: mid(cyc(t, ["kshh", "over", ""], 2)), fxSlot: "muted", caption: "is on the walkie-talkie" }),
221 lighthouse: (t) => ({ pose: "idle", props: [["lighthouse", 14, 0]], fx: cyc(t, [["—=", "", ""], ["", "", ""], ["=—", "", ""]]), fxSlot: "yellow", caption: "is lighting the lighthouse" }),
222 beacon: (t) => ({ pose: "read", props: [["beacon", 14, 1]], fx: t % 6 < 3 ? mid("!") : BLANK, fxSlot: "orange", caption: "is lighting the beacons" }),
223 // thinking
224 abacus: (t) => ({ pose: "thinking", props: [[cyc(t, ["abacusA", "abacusB"], 2), 12, 2]], fx: t % 8 === 7 ? mid("= 42") : BLANK, fxSlot: "yellow", caption: "is counting on the abacus" }),
225 puzzle: (t) => {
226 const done = t % 12 >= 8;
227 return { pose: done ? "done" : "thinking", props: [[done ? "puzzleTogether" : "puzzleApart", 14, 2]], fx: done ? mid("fits!") : BLANK, fxSlot: "green", caption: "is solving a puzzle" };
228 },
229 map: (t) => {
230 const route = [[1, 1], [2, 1], [2, 2], [3, 2], [4, 2]];
231 const steps = route.slice(0, 1 + (t % route.length));
232 return { pose: "read", props: [["map", 13, 1], ...steps.map(([x, y]) => [["r"], 13 + x, 1 + y])], fx: BLANK, fxSlot: "red", caption: "is reading the map" };
233 },
234 compass: (t) => ({ pose: "thinking", props: [[cyc(t, ["compassA", "compassB", "compassC"]), 14, 1]], fx: mid(cyc(t, ["N", "E", "S", "W"], 2)), fxSlot: "red", caption: "is finding the way" }),
235 hourglass: (t) => ({ pose: "thinking", props: [[cyc(t, ["hourTop", "hourBottom"], 4), 14, 0]], fx: BLANK, fxSlot: "yellow", caption: "is waiting for the sand" }),
236 maze: (t) => {
237 const path = [[1, 1], [2, 1], [1, 2], [1, 3], [2, 3], [3, 3], [4, 3], [4, 2], [4, 1]];
238 const [x, y] = cyc(t, path);
239 return { pose: "thinking", props: [["maze", 13, 0], [["r"], 13 + x, y]], fx: BLANK, fxSlot: "red", caption: "is solving the maze" };
240 },
241 sudoku: (t) => ({ pose: "thinking", props: [[fill(["fufuf", "ufufu", "fufuf"], 0, "g").map((r, i) => (i <= (t % 6) / 2 ? r.replace(/u/g, "g") : r)), 14, 2]], fx: t % 6 === 5 ? mid("✓") : BLANK, fxSlot: "green", caption: "is doing sudoku" }),
242 math: (t) => ({ pose: "thinking", fx: [cyc(t, ["∑", "π", "√x", "∫"], 2), cyc(t, [" ∫", " ∑", " π", " √x"], 2), cyc(t, ["π", "√x", "∫", "∑"], 2)], fxSlot: "accent", caption: "is doing the math" }),
243 // tools
244 bricks: (t) => {
245 const n = 1 + (Math.floor(t / 3) % 4);
246 const wall = Array.from({ length: n }, (_, i) => (i % 2 ? ".rr.rr" : "rr.rr."));
247 return { pose: "tool-use", props: [[wall, 13, 6 - n]], fx: BLANK, fxSlot: "red", caption: "is laying bricks" };
248 },
249 screwdriver: (t) => ({ pose: "tool-use", props: [["screwdriver", 13, cyc(t, [0, 1])], [["u"], 13, 5]], fx: t % 4 === 3 ? mid("click") : BLANK, fxSlot: "yellow", caption: "is tightening screws" }),
250 solder: (t) => ({ pose: "tool-use", props: [["circuit", 12, 3], ["solderIron", 13 + (t % 3), 0]], fx: cyc(t, [["~", "", ""], ["", "~", ""]]), fxSlot: "muted", caption: "is soldering a circuit" }),
251 sewing: (t) => ({ pose: "read", props: [["sewing", 13, 2], [["m".repeat(1 + (t % 5))], 12, 5]], fx: mid(cyc(t, ["tk", "tk tk"])), fxSlot: "magenta", caption: "is sewing" }),
252 crane: (t) => ({ pose: "idle", props: [["crane", 13, 0], [["oo"], 17, 1 + cyc(t, [0, 1, 2, 3, 2, 1])]], fx: BLANK, fxSlot: "orange", caption: "is operating the crane" }),
253 printer3d: (t) => {
254 const h = Math.floor((t % 12) / 4);
255 return { pose: "read", props: [["frame3d", 13, 1], ...(h ? [[Array.from({ length: h }, () => "ccc"), 14, 4 - h]] : [])], fx: mid(cyc(t, ["bzz", "bzzz"])), fxSlot: "cyan", caption: "is 3D printing" };
256 },
257 anvil: (t) => {
258 const down = t % 2 === 1;
259 return { pose: "tool-use", props: [["anvil", 13, 3], [down ? "hammerDown" : "hammerUp", 12, 0]], fx: down ? mid("clang") : BLANK, fxSlot: "orange", caption: "is forging a sword" };
260 },
261 conveyor: (t) => ({ pose: "idle", props: [["belt", 12, 5], [["oo"], 12 + (t % 6), 4], [["oo"], 12 + ((t + 3) % 6), 4]], fx: mid("→"), fxSlot: "orange", caption: "is running the conveyor belt" }),
262 excavator: (t) => ({ pose: "read", props: [["digger", 13, cyc(t, [0, 1, 2, 1])], [["oooo"], 13, 5]], fx: BLANK, fxSlot: "orange", caption: "is digging" }),
263 // responding
264 stamp: (t) => {
265 const down = t % 4 >= 2;
266 return { pose: "tool-use", props: [["paper", 13, 4], ["stamp", 14, down ? 1 : 0]], fx: down ? mid("approved") : BLANK, fxSlot: "red", caption: "is stamping documents" };
267 },
268 graffiti: (t) => {
269 const n = Math.floor((t % 21) / 3);
270 const colors = "rmybgca";
271 const wall = ["uuuuuuu", "u.....u", "u.....u", "uuuuuuu"].map((r, y) =>
272 y === 0 || y === 3 ? r : r.split("").map((c, x) => (x > 0 && x <= n && x < 6 ? colors[(x + y) % colors.length] : c)).join(""),
273 );
274 return { pose: "tool-use", props: [[wall, 12, 1]], fx: mid(t % 3 === 0 ? "pssh" : ""), fxSlot: "magenta", caption: "is painting graffiti" };
275 },
276 clapper: (t) => ({ pose: "tool-use", props: [[t % 6 < 3 ? "clapOpen" : "clapShut", 14, 1]], fx: t % 6 === 3 ? mid("action!") : BLANK, fxSlot: "yellow", caption: "is shooting a movie" }),
277 neon: (t) => ({ pose: "idle", props: [[t % 7 === 3 ? "neonOff" : "neonOn", 13, 1]], fx: t % 7 === 3 ? BLANK : mid("OPEN"), fxSlot: "magenta", caption: "is lighting a neon sign" }),
278 fax: (t) => ({ pose: "idle", props: [["fax", 13, 2], ...(t % 6 >= 3 ? [[["ff", "ff"], 14, 4]] : [])], fx: t % 6 === 2 ? mid("beep") : BLANK, fxSlot: "muted", caption: "is faxing the answer" }),
279 notebook: (t) => {
280 const n = 1 + (Math.floor(t / 3) % 3);
281 const page = ["ffffff", ...Array.from({ length: 3 }, (_, i) => (i < n ? "fbbbbf" : "ffffff"))];
282 return { pose: "read", props: [[page, 13, 1]], fx: BLANK, fxSlot: "blue", caption: "is taking notes" };
283 },
284 postits: (t) => {
285 const spots = [[12, 0], [15, 0], [18, 0], [12, 3], [15, 3], [18, 3]];
286 const n = 1 + (Math.floor(t / 2) % spots.length);
287 return { pose: "tool-use", props: spots.slice(0, n).map(([x, y], i) => [[i % 2 ? "mm" : "yy", i % 2 ? "mm" : "yy"], x, y]), fx: BLANK, fxSlot: "yellow", caption: "is sticking post-it notes" };
288 },
289 origami: (t) => ({ pose: "read", props: [[t % 8 < 4 ? "craneFlat" : "craneFolded", 14, 2]], fx: t % 8 >= 4 ? mid("✧") : BLANK, fxSlot: "fg", caption: "is folding origami" }),
290};
291
292// Memes and IT life, mixed into every mode's pool.
293export const WORK_MEMES = {
294 thisisfine: (t) => ({ pose: "chew", props: [["flames", 11, 3], ["mug", 13, cyc(t, [0, 0, 1])]], fx: mid(cyc(t, ["this", "is", "fine."], 3)), fxSlot: "orange", caption: "says this is fine" }),
295 womm: (t) => ({ pose: t % 10 >= 8 ? "done" : "read", props: [["sticker", 12, 2]], fx: t % 10 >= 8 ? mid("WOMM ✓") : BLANK, fxSlot: "green", caption: "says it works on my machine" }),
296 friday: (t) => ({ pose: t % 8 < 4 ? "blocked" : "idle", props: [["calendar", 12, 1], ["rocket", 17, cyc(t, [2, 1, 0, 1])]], fx: mid(cyc(t, ["FRI", "5pm", "yolo"], 3)), fxSlot: "red", caption: "is deploying on a Friday" }),
297 rmrf: (t) => {
298 const oops = t % 12 >= 7;
299 return { pose: oops ? "blocked" : "responding", props: [["terminal", 12, 1]], fx: mid(oops ? "oops" : "rm -rf"), fxSlot: oops ? "red" : "green", caption: oops ? "ran rm -rf in the wrong folder" : "is cleaning up files" };
300 },
301 offon: (t) => {
302 const on = t % 8 >= 4;
303 return { pose: on ? "done" : "sleep", props: [[on ? "powerOn" : "powerOff", 14, 1]], fx: mid(on ? "on" : "off"), fxSlot: on ? "green" : "muted", caption: "turned it off and on again" };
304 },
305 forcepush: (t) => ({ pose: "tool-use", ox: 1 + cyc(t, [0, 1, 2, 1]), props: [["box", 14 + cyc(t, [0, 1, 2, 1]), 2]], fx: mid(cyc(t, ["git push", "--force", "!!"], 3)), fxSlot: "red", caption: "is force pushing to main" }),
306 copypaste: (t) => ({ pose: "read", props: [["clipboard", 13, 1]], fx: mid(cyc(t, ["ctrl+c", "ctrl+v"], 3)), fxSlot: "orange", caption: "is copying from stack overflow" }),
307 merge: (t) => {
308 const p = t % 8;
309 return { pose: p >= 6 ? "blocked" : "thinking", props: [[["rr", "rr"], 11 + Math.min(p, 3), 2], [["gg", "gg"], 18 - Math.min(p, 3), 2]], fx: p >= 4 ? ["<<<<", "====", ">>>>"] : BLANK, fxSlot: "yellow", caption: "is resolving a merge conflict" };
310 },
311 notfound: (t) => ({ pose: "thinking", fx: [cyc(t, ["4", "40", "404"], 2), "", t % 6 >= 3 ? " ?" : ""], fxSlot: "red", caption: "can't find the page" }),
312 kubernetes: (t) => ({ pose: "tool-use", props: [[cyc(t, ["helm", "helmTurn"], 2), 14, 0]], fx: mid("k8s"), fxSlot: "blue", caption: "is steering the cluster" }),
313 docker: (t) => ({ pose: "hop", props: [["whale", 13, cyc(t, [2, 2, 1])]], fx: cyc(t, [["~", "", ""], ["", "~", ""]], 2), fxSlot: "cyan", caption: "is shipping containers" }),
314 vim: (t) => ({ pose: t % 10 >= 8 ? "blocked" : "thinking", fx: mid(cyc(t, [":q", ":wq", ":q!", "esc", "help"], 2)), fxSlot: "green", caption: "is trying to exit vim" }),
315 tabs: (t) => ({ pose: t % 4 < 2 ? "blocked" : "idle", fx: ["tabs", " vs", "spaces"], fxSlot: cyc(t, ["orange", "cyan"], 2), caption: "is arguing tabs vs spaces" }),
316 bsod: (t) => ({ pose: "blocked", props: [["bsod", 13, 0]], fx: mid(t % 6 < 3 ? ":(" : ""), fxSlot: "blue", caption: "got a blue screen" }),
317 npm: (t) => {
318 const n = 1 + (Math.floor(t / 2) % 6);
319 const tower = Array.from({ length: n }, (_, i) => (i % 2 ? "oooo" : "yyyy"));
320 return { pose: n >= 5 ? "blocked" : "tool-use", props: [[tower, 14 + (n >= 5 ? t % 2 : 0), 6 - n]], fx: mid(n >= 5 ? "1.2 GB" : "npm i"), fxSlot: "red", caption: "is installing node_modules" };
321 },
322};
323
324// A party, getting tired, and more instruments.
325export const IDLE_PARTY = {
326 party: (t) => ({ pose: "hop", props: [["balloons", 13, cyc(t, [0, 1])]], fx: cyc(t, [["✦", " *", "✧"], ["*", "✧", " ✦"], [" ✧", "✦", "*"]]), fxSlot: cyc(t, ["magenta", "yellow", "cyan"]), caption: "is throwing a party" }),
327 pinata: (t) => {
328 const hit = t % 6 === 3;
329 return { pose: hit ? "tool-use" : "idle", props: [["pinata", 14 + cyc(t, [0, 1, 0, -1]), 0]], fx: hit ? mid("whack") : t % 12 > 8 ? ["★", " ★", "★"] : BLANK, fxSlot: "yellow", caption: "is hitting a piñata" };
330 },
331 yawn: (t) => {
332 const p = t % 16;
333 return { pose: p < 6 ? "idle" : p < 10 ? "chew" : "sleep", fx: p >= 6 && p < 10 ? mid("yaawn") : p >= 10 ? rising(["z", "z"], t) : BLANK, fxSlot: "muted", caption: "is getting tired" };
334 },
335 stretch: (t) => ({ pose: t % 4 < 2 ? "hop" : "chew", fx: t % 8 < 4 ? mid("*stretch*") : BLANK, fxSlot: "fg", caption: "is stretching" }),
336 drums: (t) => ({ pose: "tool-use", props: [["drums", 13, 2]], fx: mid(cyc(t, ["ba", "dum", "tss"], 2)), fxSlot: "yellow", caption: "is playing the drums" }),
337 trumpet: (t) => ({ pose: t % 4 < 2 ? "chew" : "hop", props: [["trumpet", 11, 2]], fx: cyc(t, [mid("toot"), ["♪", "", ""], ["", " ♫", ""]]), fxSlot: "yellow", caption: "is playing the trumpet" }),
338 violin: (t) => ({ pose: "read", props: [["violin", 12, 1], [["uuu"], 12 + (t % 3), 0]], fx: cyc(t, [["♫", "", ""], ["", "♪", ""]], 2), fxSlot: "orange", caption: "is playing the violin" }),
339 sax: (t) => ({ pose: t % 4 < 2 ? "chew" : "idle", props: [["sax", 12, 2]], fx: cyc(t, [["♪", "", ""], [" ♪♪", "", ""], ["", "♫", ""]]), fxSlot: "yellow", caption: "is playing a sax solo" }),
340 xylophone: (t) => ({ pose: "tool-use", props: [["xylophone", 12, 2, { light: t % 6 }]], fx: mid(cyc(t, ["ding", "dong", "ding"])), fxSlot: "cyan", caption: "is playing the xylophone" }),
341 bubbles: (t) => ({ pose: t % 6 < 3 ? "chew" : "idle", props: [["bubbleWand", 11, 1]], fx: rising(["o", "O", "°"], t, 2), fxSlot: "cyan", caption: "is blowing bubbles" }),
342};
343
344// Industrial instrumentation and automation, mixed into the work pools.
345export const WORK_INDUSTRIAL = {
346 hart: (t) => ({ pose: "read", props: [["transmitter", 15, 0], ["communicator", 12, 2]], fx: mid(cyc(t, ["HART", "∿∿∿", "4-20"], 2)), fxSlot: "blue", caption: "is talking HART to a transmitter" }),
347 apl: (t) => ({ pose: "tool-use", props: [[cyc(t, ["aplSwitchA", "aplSwitchB"]), 13, 1]], fx: mid(cyc(t, ["APL", "10Mb", "2-wire"], 2)), fxSlot: "green", caption: "is wiring Ethernet-APL" }),
348 radarLevel: (t) => {
349 const level = 1 + (Math.floor(t / 3) % 4);
350 const tank = ["u.bb.u", ...Array.from({ length: 4 }, (_, i) => (4 - i <= level ? "uccccu" : "u....u")), "uuuuuu"];
351 return { pose: "read", props: [[tank, 13, 0]], fx: mid(`▼ ${level * 25}%`), fxSlot: "cyan", caption: "is reading a radar level gauge" };
352 },
353 pressure: (t) => ({ pose: "thinking", props: [["pipe", 12, 3], ["transmitter", 13, 0]], fx: mid(`${(4 + (t % 5) * 0.1).toFixed(1)} bar`), fxSlot: "blue", caption: "is checking a pressure transmitter" }),
354 coriolis: (t) => {
355 const path = [[0, 0], [0, 1], [1, 2], [2, 2], [3, 2], [4, 2], [5, 1], [5, 0]];
356 const [x, y] = cyc(t, path);
357 return { pose: "read", props: [["coriolis", 13, 1], [["c"], 13 + x, 1 + y]], fx: mid("kg/h"), fxSlot: "cyan", caption: "is measuring flow with a Coriolis meter" };
358 },
359 plc: (t) => ({ pose: "responding", props: [["plcRack", 13, 1, { light: t % 6 }]], fx: mid(cyc(t, ["-[ ]-", "-[/]-", "-( )-"], 2)), fxSlot: "green", caption: "is programming the PLC" }),
360 valve: (t) => {
361 const open = t % 8 < 4;
362 return { pose: "tool-use", props: [[open ? "valveOpen" : "valveShut", 14, 1]], fx: mid(open ? "OPEN 80%" : "20%"), fxSlot: "yellow", caption: "is stroking a control valve" };
363 },
364 pid: (t) => ({ pose: "thinking", fx: [cyc(t, ["SP ───", "SP ───"]), cyc(t, ["PV ╱╲╱", "PV ╲╱─", "PV ───"], 3), cyc(t, ["Kp Ti", "Kp Ti Td"], 4)], fxSlot: "accent", caption: "is tuning a PID loop" }),
365 calibrator: (t) => ({ pose: "read", props: [["calibrator", 13, 1]], fx: mid(cyc(t, ["4.00 mA", "12.00 mA", "20.00 mA"], 3)), fxSlot: "yellow", caption: "is calibrating a 4-20 mA loop" }),
366 scada: (t) => {
367 const alarm = t % 10 >= 7;
368 return { pose: alarm ? "blocked" : "read", props: [["scada", 13, 0]], fx: alarm ? mid("ALM!") : BLANK, fxSlot: "red", caption: alarm ? "acknowledges a SCADA alarm" : "is watching the SCADA screen" };
369 },
370};
371
372export const POOLS_MORE = {
373 requesting: ["smoke", "paperplane", "walkie", "lighthouse", "beacon", "friday", "offon", "thisisfine", "hart", "apl"],
374 thinking: ["abacus", "puzzle", "map", "compass", "hourglass", "maze", "sudoku", "math", "vim", "tabs", "notfound", "merge", "pid", "scada", "pressure"],
375 tool: ["bricks", "screwdriver", "solder", "sewing", "crane", "printer3d", "anvil", "conveyor", "excavator", "rmrf", "docker", "kubernetes", "npm", "forcepush", "bsod", "valve", "calibrator", "plc"],
376 responding: ["stamp", "graffiti", "clapper", "neon", "fax", "notebook", "postits", "origami", "copypaste", "womm", "radarLevel", "coriolis"],
377};
378types/index.d.ts 68 lines1export type TamaStatusPiece = { text: string; tone: 'dim' | 'yellow' | 'red' | 'green' }
2
3export type TamaPalette = {
4 id: string
5 label: string
6 mode: string
7 bg: string
8 fg: string
9 muted: string
10 accent: string
11 red: string
12 orange: string
13 yellow: string
14 green: string
15 cyan: string
16 blue: string
17 magenta: string
18}
19
20export type TamaMonster = {
21 seed: number
22 name: string
23 body: string
24 legs: string
25 color: string
26 accent: string
27 face: { left: string; right: string; eye: string; mouth: string }
28 /** The sidebar icon's name (icons.mjs). */
29 icon: string
30}
31
32/** The turn the spinner is drawing: when it started and the output so far. */
33export type TamaTurn = { startedAt: number }
34
35export type TamaUsageDay = { date: string; tokens: number; output: number }
36export type TamaUsage = {
37 generatedAt: number
38 days: TamaUsageDay[]
39 today: { date: string; tokens: number; output: number; hours: { hour: number; tokens: number }[] }
40 session: { start: number; end: number; tokens: number } | null
41 week: { start: number; tokens: number }
42}
43export type TamaLimit = { kind: string; percentUsed: number; resetsAt?: string }
44
45declare module 'claude-code' {
46 interface PluginState {
47 /** usage-percent's memory line, drawn on the band's third line when that mod runs. */
48 'usage-percent': { memory: TamaStatusPiece[] | null }
49 tamagoxtix: {
50 palette: TamaPalette | null
51 monster: TamaMonster | null
52 turn: TamaTurn | null
53 usage: TamaUsage | null
54 limits: TamaLimit[]
55 /** When Claude last went quiet; the idle band counts from it. */
56 idleSince: number
57 /** Whether the idle band shows (/tamagoxtix pet on|off). */
58 showPet: boolean
59 /** True when `node` could not be started: the usage numbers need it. */
60 nodeMissing: boolean
61 /** A prompt is being typed: the band cheers instead of idling. */
62 drafting: boolean
63 /** The agent session herdr recorded for the pane: the monster's seed. */
64 herdrSeed: { pane: string; key: string } | null
65 }
66 }
67}
68