SLOPSHOPPER

TamaGoxtix

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…

bandspinnerrowscommandpromptprocess
★ 3v2.4.2MITupdated 2026-10-20schreibse/claude-code-mods/tamagoxtix
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tamagoxtix
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tamagoxtix ⎿ tamagoxtix: Token usage is not available yet. ▣ client module ./spinner.tsx ▣ client module ./idle.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./idle.tsx ⟨Claude Code's own drawing⟩
Spinner
▣ client module ./spinner.tsx
README

tamagoxtix for Claude Code

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.

What it runs, reads, writes and sends

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.

Programs it runs

Always by argument list ($.process.run), never through a shell, and never a command that comes from the network:

ProgramWhenWhy
node scripts/usage-node.mjs --days 14 (this folder)at the start of a session and after each turnto 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-refreshonly 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 --versiononly when you run /tamagoxtix doctorto 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.

Environment variables it reads

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.

Files it reads

  • Claude Code's transcripts in ~/.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)
  • the active Omarchy theme's colors (~/.config/omarchy/current/theme), only with the omarchy palette and only where the file exists
  • its own config and state (below)

Files it writes

Every 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 on
  • once, for people upgrading from TamaGoxtix's old name: config.json, rerolls.json, icons.json and limits.json copied from tamaclaude/ to tamagoxtix/ in the same folders, never over a file already there

This 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.
  • The one-time upgrade from tamaclaude repoints those same two keys, only when they still point at tamaclaude's old status line or theme.

Before uninstalling, run /tamagoxtix statusline off and /tamagoxtix theme off to give those settings back.

What its hooks do

HookWhat it does
session.start, session.endloads your palette and monster; registers /tamagoxtix
turn.start, turn.completestarts and stops the spinner's clock; refreshes usage
session.measurekeeps Claude Code's 5-hour and weekly limit readings for the usage card
prompt.editlooks 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.

License

MIT, see LICENSE.

Source 10 files
register.tsx 1029 lines
1import { 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
1029
lib/icons.mjs 86 lines
1// 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}
86
lib/monsters.mjs 300 lines
1// 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}
300
lib/palettes.mjs 216 lines
1// 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}
216
spinner.tsx 156 lines
1// 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
156
idle.tsx 151 lines
1// 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
151
lib/scenes.mjs 503 lines
1// 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}
503
lib/scenes-cheer.mjs 117 lines
1// 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};
117
lib/scenes-more.mjs 378 lines
1// 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};
378
types/index.d.ts 68 lines
1export 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