SLOPSHOPPER

pixpal

A pixel-art friend above the prompt that shows what Claude is doing and how it feels

newpanebandguardcommandtoast
v1.0.0MITupdated 2026-10-25castus/pixpal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixpal
│ ┃ pixpal-picker ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to pick. │ ⏺ 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 │ │ › /pixpal │ ⎿ pixpal: Pixpal: the robot above the prompt shows what Claude doe │ ⎿ pixpal: │ ⎿ pixpal: **Settings** │ ⎿ pixpal: - `/pixpal init`: set everything up in five questions │ ⎿ pixpal: - `/pixpal language <name> | auto`: the language of the │ ⎿ pixpal: - `/pixpal theme [auto|dark|light]`: the look; auto foll │ │ ▣ client module ./client/pixpal.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./client/pixpal.tsx ⟨Claude Code's own drawing⟩
Pane · pixpal-picker
Nothing to pick.
README

<h1 align="center"><img src="docs/pixpal-logo.svg" alt="pixpal" width="420"></h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-8fa3b8?style=flat-square" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.299%2B-8fa3b8?style=flat-square" alt="Claude Code 2.1.299 or later"> <img src="https://img.shields.io/badge/characters-5-8fa3b8?style=flat-square" alt="5 characters"> <img src="https://img.shields.io/badge/languages-any-8fa3b8?style=flat-square" alt="Any language">

<a href="#demos"><strong>Demos</strong></a> · <a href="#install">Install</a> · <a href="#characters">Characters</a> · <a href="#what-it-shows">What it shows</a> · <a href="#settings">Settings</a> · <a href="#privacy">Privacy</a> · <a href="#contributing">Contributing</a>

TL;DR

  • Pixpal lives in the band above the prompt. While Claude works, it acts out the tool in use: it reads a book, searches with a lens, types, runs tests, sends a letter.
  • When the answer arrives, it shows how Claude feels about it: happy, proud, sorry, unsure, surprised: one of 15 emotions, picked by keyword rules, or by Claude Haiku if you let it.
  • Five characters (robot, cat, Claude, owl, ghost), a dark and a light look, and speech bubbles in any language.
  • Each running subagent stands beside it as a mini robot.

Demos

Recorded in a real Claude Code session; the scripts are in docs/*.tape and you can replay them with VHS.

A real question

Claude looks something up, so Pixpal acts out the tools (a lens for the search, a book for reading). When the answer arrives it shows how it feels, and a thank-you makes it shy.

Sleeping and waking up

After a minute of quiet, Pixpal falls asleep. Start typing and it peeks over the fence at your prompt.

Any language

/pixpal language spanish, then /pixpal emotions ai: the bubbles speak Spanish and the emotion is picked from the exchange.

Another look, another character

/pixpal theme light and /pixpal skin claude.

Install

Requires Claude Code 2.1.299 or later.

/plugin marketplace add castus/pixpal
/plugin install pixpal@pixpal

Then run /pixpal init to pick a character, a look, a language and how emotions are picked.

To update, run this and then /reload in every open session:

claude plugin marketplace update pixpal && \
claude plugin update pixpal@pixpal

To uninstall:

claude plugin uninstall pixpal@pixpal

Characters

CharacterNotes
🤖robotthe original: antenna, face screen, wheels
🐱catginger fur, whiskers, a tail
🟧claudea square terracotta block on four short legs
🦉owlbig eye rings, a beak, ear tufts
👻ghostsee-through: the panel shows through its body

All five share one skeleton, so every scene and every emotion works for each of them. Pick one with /pixpal skin <name>.

What it shows

Pixpal follows a turn in three phases.

  1. Between turns it rests. While you type, it peeks over a fence at your prompt. After a minute of quiet it goes to sleep.
  2. While Claude works it shows the tool in use, one to one: Read is a book, Grep a lens, Edit a keyboard, a test run a checklist, git its own scene, a subagent a juggling act. Quick calls (under 0.6 s) do not flicker by.
  3. When the answer arrives it plays one emotion twice, chosen from your message and Claude's answer (by keyword rules, or by Claude Haiku with /pixpal emotions ai), from Claude's side: if you say sorry, it is relieved; if you say "again??", it is sorry. After a turn of over a minute it lets out a tired phew first.
  4. Agents in the background each running subagent stands beside it as a mini robot (up to four). More than that shows as a +N count next to the last one; when an agent finishes, its mini leaves.

A speech bubble names each emotion ("Yay!", "Sort of", "No worries") and each kind of work ("Reading…", "Testing…"), typed out letter by letter.

Settings

CommandWhat it does
/pixpal initset everything up
`/pixpal skin [robot\cat\claude\owl\ghost]`who Pixpal is
`/pixpal theme [auto\dark\light]`the look; auto follows your system's light or dark mode
`/pixpal language <name> \auto`the bubbles' language: any language, e.g. french, japanese; auto follows your prompt in Polish, English, German, Spanish, French, Italian, Portuguese, Ukrainian, Russian, Japanese, Chinese or Korean
`/pixpal bubbles [on\off]`speech bubbles on or off
/pixpal show \/pixpal hidePixpal above the prompt or not; hidden, it leaves the band to other mods
`/pixpal emotions [ai\keywords]`who picks the emotion: keyword rules (default, sends nothing) or Claude Haiku (see Privacy)
/pixpal reporta block with versions and settings to paste into a bug report (never your messages)
/pixpal helpall of the above, with the current values

Give a setting without a value (/pixpal language) to pick it from a list: ↑↓ move, Enter picks, Esc closes. In the language list, the last row takes any language you type.

Each conversation keeps its own choices, so a resumed session comes back with the same character. A new session starts from your latest choices.

Privacy

Pixpal runs inside Claude Code and makes no network calls of its own. By default nothing you write leaves your machine because of it: keyword rules pick the emotions.

Two features use Claude Code's own model access, only when you turn them on. They go through the same client, account and provider as your conversation (Anthropic, Amazon Bedrock or Google Vertex, under the same terms):

  • Emotions, with /pixpal emotions ai: after each answer, the first 500 characters of your message and the opening and closing of Claude's answer (300 + 200 characters when it is long) go to Claude Haiku, which names one emotion. About 900 tokens per answer. If Haiku is blocked or does not answer, the keyword rules take over. Aborted or empty turns never call the model.
  • Languages, with /pixpal language <name>: the first time you choose a language without built-in lines, the built-in English bubble lines (not your conversation) are translated once by Claude Haiku and kept on your machine. auto and the twelve built-in languages send nothing.

On macOS, theme auto runs defaults read -g AppleInterfaceStyle once a minute to read your system's appearance.

Limitations

  • A short terminal cuts Pixpal. Pixpal draws in the band above the prompt, and Claude Code decides how many rows the band gets. In fullscreen, the band shares the bottom slot with the tasks list and the panes of other mods, and that slot takes at most half of the terminal's height. When there are fewer rows than the picture needs, Pixpal drops the least important rows first (wheels, antenna, arms) and keeps the eyes and the mouth for as long as it can. Make the window taller or close other panes to see all of it.
  • Other mods in the band. Mods that draw above the prompt share one band, in the order Claude Code loads them. Pixpal draws the mods that come after it to its right. A mod that comes before Pixpal and does not pass the band on hides Pixpal; use /pixpal hide to give the band to the other mods.
  • Pixpal hides during a survey. When Claude Code asks how it is doing (the 1–3 rating), the survey takes the band. Pixpal steps aside and comes back when the survey closes.
  • No band in the VS Code extension. Claude Code draws the band only in the terminal and the desktop app, so Pixpal does not show in the VS Code extension.

Contributing

Pull requests are very welcome. If something breaks or feels off, please open an issue. CONTRIBUTING.md explains how to run the tests and how to add a character, an emotion or a language; see also the changelog and the security policy.

MOOD_GUIDE.md is the design of what Pixpal shows and when.

License

MIT.

<sub>Pixpal is a fan-made project, not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.</sub>

Source 9 files
hooks/register.tsx 1087 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { FIXTURES } from './fixtures.ts'
5import { buildClassifierPrompt, CLASSIFIER_SYSTEM, parseEmotion } from './classifier.ts'
6import { draftAfter, restForDate, sceneForExchange, sceneForTool } from './mood.ts'
7import { durationOfScene, isOnce, SCENES } from './scenes.ts'
8import { BUBBLE_W, bubbleWidthOf, buildTranslatePrompt, builtInBook, LANG_LABELS, LANGS, langOf, parseTranslation, TRANSLATE_SYSTEM, type Lang } from './sayings.ts'
9import { bandWidth, SKINS, systemThemeOf, type SkinId } from './sprites.ts'
10
11const scene = atom({ plugin: 'pixpal', key: 'scene' } as const, 'idle')
12const seq = atom({ plugin: 'pixpal', key: 'seq' } as const, 0)
13const rest = atom({ plugin: 'pixpal', key: 'rest' } as const, 'idle')
14const demo = atom({ plugin: 'pixpal', key: 'demo' } as const, false)
15// Shows the id of the scene that is playing under the robot.
16const debug = atom({ plugin: 'pixpal', key: 'debug' } as const, false)
17// Who picked the last final emotion: 'ai' (the small model) or 'kw' (keyword rules).
18const source = atom({ plugin: 'pixpal', key: 'source' } as const, '')
19// The reaction to an answer that is still on its way (classifier call, ufff timer). The
20// module reloads at the end of any turn that edited its files, which would kill that work;
21// this value is held by the host and survives, and session.start resumes it.
22const pending = atom({ plugin: 'pixpal', key: 'pending' } as const, null)
23// Lets a small model pick the final emotion; off falls back to keyword rules.
24// Off by default: with it on, a short piece of each exchange goes to Claude Haiku (on the
25// person's own Claude Code account) to pick the emotion; off, keyword rules pick it.
26const smart = atom({ plugin: 'pixpal', key: 'smart' } as const, false)
27// How many agents run now; each one stands beside the robot as a mini robot.
28const agents = atom({ plugin: 'pixpal', key: 'agents' } as const, 0)
29// A workflow's agents, which $.agent.list() leaves out: agentId -> when it was last heard
30// from (its spawn or a tool call). Its turn.complete takes it off.
31const workflowAgents = atom({ plugin: 'pixpal', key: 'workflowAgents' } as const, {} as Record<string, number>)
32// The person's language, from their last prompt; emotions speak it in a bubble.
33const lang = atom({ plugin: 'pixpal', key: 'lang' } as const, 'en' as Lang)
34// Whether the robot speaks in bubbles at all (emotions and work); /pixpal bubbles toggles it.
35const bubbles = atom({ plugin: 'pixpal', key: 'bubbles' } as const, true)
36// Whether Pixpal shows in the band at all; hidden, it leaves the band to the other mods.
37const visible = atom({ plugin: 'pixpal', key: 'visible' } as const, true)
38// The robot's look: 'auto' (default, follows the system's appearance), 'dark' or 'light';
39// /pixpal theme sets it. systemTheme is the system's appearance, re-read on a clock.
40const theme = atom({ plugin: 'pixpal', key: 'theme' } as const, 'auto' as 'auto' | 'dark' | 'light')
41const systemTheme = atom({ plugin: 'pixpal', key: 'systemTheme' } as const, 'dark' as 'dark' | 'light')
42// The language the robot speaks: 'auto' (a built-in language, after the person's prompt) or
43// any language by name; phrasebook holds its lines (null for auto).
44const language = atom({ plugin: 'pixpal', key: 'language' } as const, 'auto')
45const phrasebook = atom({ plugin: 'pixpal', key: 'phrasebook' } as const, null as Record<string, string> | null)
46// Who the robot is drawn as; /pixpal skin sets it.
47const skin = atom({ plugin: 'pixpal', key: 'skin' } as const, 'robot' as SkinId)
48// What the settings picker pane lists: a setting given without a value opens it.
49type Picker = { setting: string; options: Array<{ value: string; label: string }>; current: string; other?: string }
50const picker = atom({ plugin: 'pixpal', key: 'picker' } as const, null as Picker | null)
51// The first of the picker's rows in view; it follows the focus.
52const pickerTop = atom({ plugin: 'pixpal', key: 'pickerTop' } as const, 0)
53// The picker slot that has the focus (`slot:<k>` or `other`), so a move knows its direction.
54const pickerFocus = atom({ plugin: 'pixpal', key: 'pickerFocus' } as const, null as string | null)
55
56const DEMO_MS = 6000
57// How often the system's light or dark appearance is re-read for the 'auto' theme.
58const THEME_MS = 60_000
59// The agent count is also re-read on this clock, in case an event was missed.
60const AGENTS_MS = 2000
61// A finishing agent may still be listed as running for a moment after its last event.
62const AGENT_SETTLE_MS = 300
63const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting'])
64// A workflow agent not heard from for this long is taken as gone (its end was missed).
65const WORKFLOW_AGENT_STALE_MS = 30 * 60_000
66const WATCH_MS = 5000
67const SLEEP_AFTER_MS = 60_000
68const CLASSIFIER_MODEL = 'haiku'
69const CLASSIFIER_TIMEOUT_MS = 8000
70// A turn longer than this ends with a tired "ufff" before the final emotion.
71const TIRED_AFTER_MS = 60_000
72// A tool scene shows only for calls that run longer than this, so quick calls do not flicker.
73const TOOL_SCENE_DELAY_MS = 600
74const TOOL_END_DEBOUNCE_MS = 350
75// A reaction that a reload cut short is resumed only if it is this fresh.
76const RESUME_WITHIN_MS = 30_000
77// Typing counts as stopped after this many quiet ticks of TYPING_TICK_MS (about 12 s).
78const TYPING_TICK_MS = 500
79const TYPING_QUIET_TICKS = 24
80
81type Ctx = Parameters<typeof read>[0]
82
83// The robot's lines in `name`: built in, from the store, or translated once by the small model.
84const phrasebookFor = async ($: Ctx, name: string): Promise<Record<string, string> | string> => {
85  const kept = builtInBook(name) ?? ((await $.store.get(PHRASEBOOK_KEY(name))) as Record<string, string> | undefined)
86
87  if (kept) {
88    return kept
89  }
90
91  const reply = await $.model
92    .complete({ model: CLASSIFIER_MODEL, system: TRANSLATE_SYSTEM, prompt: buildTranslatePrompt(name), maxTokens: 1500, timeoutMs: TRANSLATE_TIMEOUT_MS })
93    .catch(error => ({ isAnswered: false as const, reason: String(error) }))
94  const book = reply.isAnswered ? parseTranslation(reply.text) : undefined
95
96  if (!book) {
97    return reply.isAnswered ? 'the model did not answer with a list of lines' : `the model did not answer (${reply.reason})`
98  }
99
100  await $.store.set(PHRASEBOOK_KEY(name), book)
101
102  return book
103}
104
105const THEMES = ['auto', 'dark', 'light'] as const
106
107// The person's choices live in the store (a file), so they outlast the process: the state
108// atoms only live as long as one Claude Code process, and a resumed session is a new one.
109// Each session keeps its own choices (SESSIONS_KEY, by session id, so `--resume` brings
110// them back); the latest choices (SETTINGS_KEY) are where a new session starts.
111const SETTINGS_KEY = 'pixpal.settings'
112const SESSIONS_KEY = 'pixpal.sessions'
113// How many sessions' choices are kept; the oldest go first.
114const SESSIONS_KEPT = 200
115type Settings = { theme?: string; skin?: string; bubbles?: boolean; visible?: boolean; language?: string; emotions?: 'ai' | 'keywords' }
116
117const saveSetting = async ($: Ctx, patch: Settings) => {
118  const latest = ((await $.store.get(SETTINGS_KEY)) as Settings | undefined) ?? {}
119  await $.store.set(SETTINGS_KEY, { ...latest, ...patch })
120
121  const id = await $.session.id()
122  const sessions = ((await $.store.get(SESSIONS_KEY)) as Record<string, Settings> | undefined) ?? {}
123  const { [id]: mine = {}, ...others } = sessions
124  const kept = Object.entries(others).slice(-(SESSIONS_KEPT - 1))
125  // the changed session moves to the end, so the oldest are the ones dropped
126  await $.store.set(SESSIONS_KEY, { ...Object.fromEntries(kept), [id]: { ...latest, ...mine, ...patch } })
127}
128
129// At session start: this session's choices (or the latest, for a new session) back into
130// the atoms; anything unknown is skipped.
131const loadSettings = async ($: Ctx) => {
132  const sessions = ((await $.store.get(SESSIONS_KEY)) as Record<string, Settings> | undefined) ?? {}
133  const saved = sessions[await $.session.id()] ?? ((await $.store.get(SETTINGS_KEY)) as Settings | undefined) ?? {}
134  const chosenTheme = THEMES.find(option => option === saved.theme)
135  const chosenSkin = (Object.keys(SKINS) as SkinId[]).find(option => option === saved.skin)
136
137  if (chosenTheme) {
138    await update($, theme, () => chosenTheme)
139  }
140
141  if (chosenSkin) {
142    await update($, skin, () => chosenSkin)
143  }
144
145  if (typeof saved.bubbles === 'boolean') {
146    await update($, bubbles, () => saved.bubbles!)
147  }
148
149  if (typeof saved.visible === 'boolean') {
150    await update($, visible, () => saved.visible!)
151  }
152
153  if (saved.emotions === 'ai' || saved.emotions === 'keywords') {
154    await update($, smart, () => saved.emotions === 'ai')
155  }
156
157  if (typeof saved.language === 'string' && saved.language) {
158    await update($, language, () => saved.language!)
159  }
160}
161
162// Who picks the final emotion: Claude Haiku ('ai', on the person's account) or keyword
163// rules ('keywords', nothing leaves the machine); keeps the choice.
164const setEmotions = async ($: Ctx, mode: 'ai' | 'keywords') => {
165  await update($, smart, () => mode === 'ai')
166  await saveSetting($, { emotions: mode })
167
168  return mode === 'ai'
169    ? 'Pixpal emotions: ai. After each answer, a short piece of the exchange goes to Claude Haiku on your Claude Code account to pick the emotion.'
170    : 'Pixpal emotions: keywords. Keyword rules pick the emotion; nothing is sent anywhere.'
171}
172
173// Sets the bubbles on or off and keeps the choice.
174const setBubbles = async ($: Ctx, isOn: boolean) => {
175  await update($, bubbles, () => isOn)
176  await saveSetting($, { bubbles: isOn })
177
178  return `Pixpal speech bubbles ${isOn ? 'on' : 'off'}.`
179}
180
181// Shows or hides Pixpal in the band and keeps the choice.
182const setVisible = async ($: Ctx, isShown: boolean) => {
183  await update($, visible, () => isShown)
184  await saveSetting($, { visible: isShown })
185
186  return isShown ? 'Pixpal is back above the prompt.' : 'Pixpal is hidden. Bring it back with /pixpal show.'
187}
188
189// The setters behind /pixpal language, theme, skin and init; each says what it did.
190const setLanguage = async ($: Ctx, name: string): Promise<string> => {
191  if (name.toLowerCase() === 'auto') {
192    await update($, language, () => 'auto')
193    await update($, phrasebook, () => null)
194    await saveSetting($, { language: 'auto' })
195
196    return 'Pixpal speaks the language of your prompt again.'
197  }
198
199  const book = await phrasebookFor($, name)
200
201  if (typeof book === 'string') {
202    return `Could not get ${name}: ${book}. The language stays ${await read($, language)}.`
203  }
204
205  await update($, language, () => name)
206  await update($, phrasebook, () => book)
207  await saveSetting($, { language: name })
208
209  return `Pixpal speaks ${name} now: ${book.happy}, ${book.thinking}, ${book.soso}.`
210}
211
212const setTheme = async ($: Ctx, next: (typeof THEMES)[number]): Promise<string> => {
213  await update($, theme, () => next)
214  await saveSetting($, { theme: next })
215
216  if (next === 'auto') {
217    await syncTheme($)
218
219    return `Pixpal theme: auto (the system is ${await read($, systemTheme)} now).`
220  }
221
222  return `Pixpal theme: ${next}.`
223}
224
225const setSkin = async ($: Ctx, next: SkinId): Promise<string> => {
226  await update($, skin, () => next)
227  await saveSetting($, { skin: next })
228
229  return `Pixpal skin: ${next} (all: ${Object.keys(SKINS).join(', ')}).`
230}
231
232const PICKER = 'pixpal-picker'
233// The picker shows PICKER_VISIBLE options at a time; the pane wants room for them, the
234// heading, the field for a language of the person's own, the hints, and the gaps between.
235const PICKER_VISIBLE = 5
236const PICKER_ROWS = PICKER_VISIBLE + 6
237const PICKER_ACCENT = '#d97757'
238const PICKER_QUESTIONS: Record<string, string> = {
239  language: 'Which language should Pixpal speak?',
240  theme: 'Which look should the band have?',
241  skin: 'Who should Pixpal be?',
242  bubbles: 'Should Pixpal speak in bubbles?',
243  visible: 'Should Pixpal show above the prompt?',
244  emotions: 'Who picks the emotion after each answer?',
245}
246
247// Which way a move from `from` to `to` goes in the ring as drawn: 1 down, -1 up (each
248// wrapping), 0 for a jump (a click).
249const directionOf = (drawn: string[], from: string, to: string) => {
250  const i = drawn.indexOf(from)
251  const j = drawn.indexOf(to)
252
253  if (i < 0 || j < 0) {
254    return 0
255  }
256
257  return j === (i + 1) % drawn.length ? 1 : j === (i - 1 + drawn.length) % drawn.length ? -1 : 0
258}
259
260type PickedSetting = 'language' | 'theme' | 'skin' | 'bubbles' | 'visible' | 'emotions'
261
262// The choices of a setting, with the one it has now.
263const pickerFor = async ($: Ctx, setting: PickedSetting): Promise<Picker> => {
264  switch (setting) {
265    case 'language':
266      return {
267        setting,
268        options: [{ value: 'auto', label: 'Auto (the language of your prompt)' }, ...LANGS.map(code => ({ value: LANG_LABELS[code], label: LANG_LABELS[code] }))],
269        current: await read($, language),
270        other: 'type any language, Haiku translates it once',
271      }
272    case 'theme':
273      return { setting, options: THEMES.map(value => ({ value, label: value === 'auto' ? 'Auto (follows the system)' : value })), current: await read($, theme) }
274    case 'skin':
275      return { setting, options: Object.keys(SKINS).map(value => ({ value, label: value })), current: await read($, skin) }
276    case 'bubbles':
277      return { setting, options: [{ value: 'on', label: 'on' }, { value: 'off', label: 'off' }], current: (await read($, bubbles)) ? 'on' : 'off' }
278    case 'visible':
279      return { setting, options: [{ value: 'show', label: 'show' }, { value: 'hide', label: 'hide' }], current: (await read($, visible)) ? 'show' : 'hide' }
280    case 'emotions':
281      return {
282        setting,
283        options: [
284          { value: 'keywords', label: 'keywords (send nothing)' },
285          { value: 'ai', label: 'ai (Claude Haiku on your account)' },
286        ],
287        current: (await read($, smart)) ? 'ai' : 'keywords',
288      }
289  }
290}
291
292// Opens the picker for `setting`; says how to set it by hand when no pane can be drawn.
293const openPicker = async ($: Ctx, setting: PickedSetting): Promise<string> => {
294  const spec = await pickerFor($, setting)
295  const index = spec.options.findIndex(option => option.value.toLowerCase() === spec.current.toLowerCase())
296  await update($, picker, () => spec)
297  await update($, pickerTop, () => Math.max(0, Math.min(index - 2, spec.options.length - PICKER_VISIBLE)))
298  await update($, pickerFocus, () => null)
299  const opened = await $.ui.open({ id: PICKER, title: `Pixpal ${setting}`, focus: true, closeOnEscape: true, holdToasts: true, rows: PICKER_ROWS })
300
301  return opened.isPlaced
302    ? `Pick the ${setting} in the pane.`
303    : `Pixpal ${setting}: ${spec.current}. Set it with /pixpal ${setting} <${spec.options.map(option => option.value).join('|')}>.`
304}
305
306// A pick in the picker: the pane closes and a toast says what changed. A language without
307// built-in lines takes Haiku a few seconds, so a toast says so first.
308const pickValue = async ($: Ctx, setting: string, value: string) => {
309  await $.ui.close({ id: PICKER })
310
311  if (setting === 'language' && value !== 'auto' && !builtInBook(value)) {
312    $.ui.toast(`Pixpal: Haiku translates the lines into ${value}…`)
313  }
314
315  $.ui.toast(await applySetting($, setting, value))
316}
317
318// Sets `setting` to `value` (a picked or typed value) and says what it did.
319const applySetting = async ($: Ctx, setting: string, value: string): Promise<string> => {
320  const skinId = (Object.keys(SKINS) as SkinId[]).find(id => id === value)
321  const themeName = THEMES.find(option => option === value)
322
323  switch (setting) {
324    case 'language':
325      return setLanguage($, value)
326    case 'theme':
327      return themeName ? setTheme($, themeName) : `Unknown theme: ${value}.`
328    case 'skin':
329      return skinId ? setSkin($, skinId) : `Unknown skin: ${value}.`
330    case 'bubbles':
331      return setBubbles($, value === 'on')
332    case 'visible':
333      return setVisible($, value === 'show')
334    case 'emotions':
335      return setEmotions($, value === 'ai' ? 'ai' : 'keywords')
336    default:
337      return `Unknown setting: ${setting}.`
338  }
339}
340
341// /pixpal help: what each option does, with the value it has now. Only the person's
342// settings are listed; the development commands (debug, classify, stats, eval,
343// demo, tired, <scene>) still work but stay out of help and the argument hint. The command's text is
344// drawn as Markdown (single line breaks would join into one paragraph), hence the lists.
345const helpText = async ($: Ctx) => {
346  const now = (value: unknown) => ` *(now: ${String(value)})*`
347
348  return [
349    'Pixpal: the robot above the prompt shows what Claude does and feels.',
350    '',
351    '**Settings**',
352    '- `/pixpal init`: set everything up in five questions',
353    `- \`/pixpal language <name> | auto\`: the language of the bubbles, any language${now(await read($, language))}`,
354    `- \`/pixpal theme [auto|dark|light]\`: the look; auto follows the system${now(await read($, theme))}`,
355    `- \`/pixpal skin [${Object.keys(SKINS).join('|')}]\`: who the robot is${now(await read($, skin))}`,
356    `- \`/pixpal bubbles [on|off]\`: speech bubbles on or off${now((await read($, bubbles)) ? 'on' : 'off')}`,
357    `- \`/pixpal show\`, \`/pixpal hide\`: Pixpal above the prompt or not; \`/pixpal visible\` picks${now((await read($, visible)) ? 'shown' : 'hidden')}`,
358    `- \`/pixpal emotions [ai|keywords]\`: who picks the emotion; ai sends a short piece of each exchange to Claude Haiku on your account${now((await read($, smart)) ? 'ai' : 'keywords')}`,
359    '- `/pixpal report`: the details to paste into a bug report (versions and settings, never your messages)',
360    '- Without a value, a setting opens a picker.',
361  ].join('\n')
362}
363
364// /pixpal init: five questions in Claude Code's own dialog, each applied as soon as it is
365// answered. Dismissing a question stops there and keeps what was set so far.
366const runInit = async ($: Ctx): Promise<string> => {
367  const done: string[] = []
368
369  try {
370    const spoken = await $.ui.ask('Which language should the robot speak in its bubbles?', {
371      header: 'Language',
372      options: ['Auto (the language of your prompt)', 'English', 'Polski', 'Deutsch'],
373    })
374    done.push(await setLanguage($, spoken.startsWith('Auto') ? 'auto' : spoken.trim()))
375
376    const look = await $.ui.ask('Which look should the band have?', { header: 'Theme', options: ['Auto (follows the system)', 'Dark', 'Light'] })
377    done.push(await setTheme($, look.startsWith('Auto') ? 'auto' : look.toLowerCase() === 'light' ? 'light' : 'dark'))
378
379    const who = await $.ui.ask('Who should the robot be? (For the ghost, type ghost under Other.)', {
380      header: 'Character',
381      options: ['Robot', 'Cat', 'Claude', 'Owl'],
382    })
383    const skinId = (Object.keys(SKINS) as SkinId[]).find(id => id === who.trim().toLowerCase()) ?? 'robot'
384    done.push(await setSkin($, skinId))
385
386    const mood = await $.ui.ask(
387      'How should it pick its emotion after each answer? Haiku reads a short piece of the exchange on your own Claude Code account; keyword rules send nothing.',
388      { header: 'Emotions', options: ['Keyword rules (send nothing)', 'Claude Haiku (better guesses)'] },
389    )
390    done.push(await setEmotions($, mood.startsWith('Claude Haiku') ? 'ai' : 'keywords'))
391
392    const talk = await $.ui.ask('Should it say how it feels and what it does in speech bubbles?', { header: 'Bubbles', options: ['Yes', 'No'] })
393    done.push(await setBubbles($, talk !== 'No'))
394  } catch {
395    done.push('Setup stopped; the answers so far are kept.')
396  }
397
398  return done.join('\n')
399}
400
401// The look to draw: the forced theme, or the system's for 'auto'.
402const resolvedTheme = async ($: Ctx) => {
403  const chosen = await read($, theme)
404
405  return chosen === 'auto' ? await read($, systemTheme) : chosen
406}
407
408// Reads the system's appearance (macOS) into systemTheme; elsewhere it stays dark.
409const syncTheme = async ($: Ctx) => {
410  const result = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 5000 }).catch(() => undefined)
411  const found = result ? systemThemeOf(result.exitCode, result.stdout, result.stderr) : 'dark'
412
413  if (found !== (await read($, systemTheme))) {
414    await update($, systemTheme, () => found)
415  }
416}
417
418// Counts the agents that run now and stores the number when it changed.
419const syncAgents = async ($: Ctx) => {
420  const now = await $.clock.now()
421  const tracked = await read($, workflowAgents)
422  const live = Object.entries(tracked).filter(([, seenAt]) => now - seenAt < WORKFLOW_AGENT_STALE_MS)
423
424  if (live.length !== Object.keys(tracked).length) {
425    await update($, workflowAgents, () => Object.fromEntries(live))
426  }
427
428  const count = (await $.agent.list()).filter(agent => ACTIVE_AGENT.has(agent.status)).length + live.length
429
430  if (count !== (await read($, agents))) {
431    await update($, agents, () => count)
432  }
433}
434
435// A new seq tells the Client to restart; a loop that is already playing is left alone.
436const show = async ($: Ctx, name: string) => {
437  if (!isOnce(name) && (await read($, scene)) === name) {
438    return
439  }
440
441  await update($, scene, () => name)
442  await update($, seq, n => n + 1)
443}
444
445// What one-shot scenes fall back to: the calendar rest between turns, but while a turn
446// runs the robot stays at work (thinking), never idle.
447const syncRest = async ($: Ctx, now: number, isBusy: boolean) => {
448  const next = isBusy ? 'thinking' : restForDate(new Date(now))
449
450  if ((await read($, rest)) !== next) {
451    await update($, rest, () => next)
452  }
453}
454
455type Verdict = { id?: string; raw: string; reason?: string; inTokens: number; outTokens: number; ms: number }
456type Stats = { calls: number; hits: number; misses: number; inTokens: number; outTokens: number; ms: number }
457
458const STATS_KEY = 'pixpal.stats'
459// Translated phrasebooks, one per language, kept across sessions: a language costs one call.
460const PHRASEBOOK_KEY = (name: string) => `pixpal.phrasebook.${name.trim().toLowerCase()}`
461const TRANSLATE_TIMEOUT_MS = 30_000
462const ERROR_KEY = 'pixpal.lastError'
463const NO_STATS: Stats = { calls: 0, hits: 0, misses: 0, inTokens: 0, outTokens: 0, ms: 0 }
464
465// One classifier call. Never throws: a refused request (a blocked model, say) comes back
466// as a verdict with a reason, so `/pixpal classify` can show what went wrong.
467const classify = async ($: EngineInterface, user: string, answer: string): Promise<Verdict> => {
468  const start = await $.clock.now()
469
470  try {
471    const reply = await $.model.complete({
472      model: CLASSIFIER_MODEL,
473      system: CLASSIFIER_SYSTEM,
474      prompt: buildClassifierPrompt(user, answer),
475      maxTokens: 12,
476      timeoutMs: CLASSIFIER_TIMEOUT_MS,
477    })
478    const ms = (await $.clock.now()) - start
479    const usage = { inTokens: reply.usage.input_tokens, outTokens: reply.usage.output_tokens, ms }
480
481    return reply.isAnswered ? { id: parseEmotion(reply.text), raw: reply.text, ...usage } : { raw: '', reason: reply.reason, ...usage }
482  } catch (error) {
483    return { raw: '', reason: `rejected: ${String(error)}`, inTokens: 0, outTokens: 0, ms: (await $.clock.now()) - start }
484  }
485}
486
487// Counters live in $.store, so they survive a restart.
488const addStats = async ($: EngineInterface, delta: Stats) => {
489  const stats = { ...NO_STATS, ...((await $.store.get(STATS_KEY)) as Partial<Stats> | undefined) }
490
491  await $.store.set(STATS_KEY, {
492    calls: stats.calls + delta.calls,
493    hits: stats.hits + delta.hits,
494    misses: stats.misses + delta.misses,
495    inTokens: stats.inTokens + delta.inTokens,
496    outTokens: stats.outTokens + delta.outTokens,
497    ms: stats.ms + delta.ms,
498  })
499}
500
501const statsOf = (verdict: Verdict): Stats => ({
502  calls: 1,
503  hits: verdict.id ? 1 : 0,
504  misses: verdict.id ? 0 : 1,
505  inTokens: verdict.inTokens,
506  outTokens: verdict.outTokens,
507  ms: verdict.ms,
508})
509
510const recordStats = ($: EngineInterface, verdict: Verdict) => addStats($, statsOf(verdict))
511
512const statsText = async ($: EngineInterface) => {
513  const stats = { ...NO_STATS, ...((await $.store.get(STATS_KEY)) as Partial<Stats> | undefined) }
514  const per = (n: number) => (stats.calls ? Math.round(n / stats.calls) : 0)
515
516  return [
517    `Classifier calls: ${stats.calls} (answered ${stats.hits}, fell back ${stats.misses})`,
518    `Tokens: ${stats.inTokens} in, ${stats.outTokens} out (about ${per(stats.inTokens)} in and ${per(stats.outTokens)} out per call)`,
519    `Latency: ${per(stats.ms)} ms on average`,
520    `Last error: ${String((await $.store.get(ERROR_KEY)) ?? 'none')}`,
521  ].join('\n')
522}
523
524// Bump together with .claude-plugin/plugin.json and package.json (a module cannot read them).
525const PIXPAL_VERSION = '1.0.0'
526
527// /pixpal report: what a bug report needs, in one block to paste into an issue. Settings,
528// versions and counters only: never a message, an answer or a path.
529const reportText = async ($: Ctx) => {
530  const { version } = await $.session.version()
531  const system = await $.process.run(['uname', '-sm'], { timeoutMs: 5000 }).catch(() => undefined)
532  const stats = { ...NO_STATS, ...((await $.store.get(STATS_KEY)) as Partial<Stats> | undefined) }
533  const settings = {
534    skin: await read($, skin),
535    theme: await read($, theme),
536    systemTheme: await read($, systemTheme),
537    language: await read($, language),
538    bubbles: await read($, bubbles),
539    visible: await read($, visible),
540    emotions: (await read($, smart)) ? 'ai' : 'keywords',
541    agents: await read($, agents),
542  }
543
544  return [
545    'Copy this block into your issue:',
546    '',
547    '```',
548    `pixpal ${PIXPAL_VERSION}`,
549    `claude code ${version}`,
550    `system ${(system?.stdout ?? 'unknown').trim()}`,
551    `settings ${JSON.stringify(settings)}`,
552    `classifier calls ${stats.calls} (answered ${stats.hits}, fell back ${stats.misses})`,
553    `last error ${String((await $.store.get(ERROR_KEY)) ?? 'none')}`,
554    '```',
555    '',
556    'Your terminal and what you saw are still worth adding.',
557  ].join('\n')
558}
559
560const verdictText = (verdict: Verdict) =>
561  verdict.reason
562    ? `Classifier failed: ${verdict.reason} (${verdict.ms} ms)`
563    : `Classifier answered "${verdict.raw.trim()}" -> ${verdict.id ?? 'no known id'}\nmodel ${CLASSIFIER_MODEL}, ${verdict.ms} ms, tokens ${verdict.inTokens} in / ${verdict.outTokens} out`
564
565// Runs the sample exchanges through the classifier and reports hits and misses.
566const evalText = async ($: EngineInterface) => {
567  const lines: string[] = []
568  let hits = 0
569  let inTokens = 0
570  let outTokens = 0
571
572  for (const fixture of FIXTURES) {
573    const verdict = await classify($, fixture.user, fixture.answer)
574    await recordStats($, verdict)
575    inTokens += verdict.inTokens
576    outTokens += verdict.outTokens
577    const isHit = verdict.id !== undefined && fixture.expect.split('/').includes(verdict.id)
578    hits += isHit ? 1 : 0
579    lines.push(`${isHit ? 'ok  ' : 'MISS'} ${fixture.expect.padEnd(9)} got ${(verdict.id ?? verdict.reason ?? '-').padEnd(9)} | ${fixture.user}`)
580  }
581
582  return [...lines, `Accuracy: ${hits}/${FIXTURES.length}. Tokens: ${inTokens} in, ${outTokens} out.`].join('\n')
583}
584
585// A failure in work that runs detached from a hook has nowhere to be thrown, so it is
586// kept here and shown by `/pixpal stats`. Never rejects.
587const recordError = async ($: EngineInterface, error: unknown) => {
588  try {
589    await $.store.set(ERROR_KEY, `${new Date(await $.clock.now()).toISOString()} ${String(error)}`.slice(0, 500))
590  } catch {
591    // nothing more can be done
592  }
593}
594
595// Shows the final emotion unless the person has moved on in the meantime.
596const playEmotion = async ($: EngineInterface, verdict: Verdict | undefined, fallback: string, isStale: () => boolean) => {
597  await update($, pending, () => null)
598
599  if (isStale()) {
600    return
601  }
602
603  await update($, source, () => (verdict?.id ? 'ai' : 'kw'))
604  await show($, verdict?.id ?? fallback)
605}
606
607// The final emotion. Aborted or empty turns use the keyword rules; otherwise the robot
608// rests while a small model reads the exchange (see MOOD_GUIDE.md), and a failed or late
609// call falls back to the keywords. After a long turn the robot first plays a tired "ufff"
610// and the emotion follows once it has finished. isStale says the person has moved on.
611const reactToAnswer = async ($: EngineInterface, user: string, answer: string, isAborted: boolean, durationMs: number, isStale: () => boolean) => {
612  const fallback = sceneForExchange(user, answer, isAborted)
613  const isTired = !isAborted && durationMs > TIRED_AFTER_MS
614  const canAsk = !isAborted && answer.trim() !== '' && (await read($, smart))
615  const startedAt = await $.clock.now()
616
617  if (isTired) {
618    await show($, 'ufff')
619  } else if (canAsk) {
620    await show($, await read($, rest))
621  }
622
623  let verdict: Verdict | undefined
624
625  if (canAsk) {
626    verdict = await classify($, user, answer)
627    await recordStats($, verdict)
628  }
629
630  if (isTired) {
631    const left = durationOfScene('ufff') - ((await $.clock.now()) - startedAt)
632
633    // A timer, not a sleep: a sleep inside this detached call is charged to the time
634    // budget of the hook that started it, which has long ended.
635    if (left > 0) {
636      $.clock.after(left, () => {
637        void playEmotion($, verdict, fallback, isStale).catch(error => recordError($, error))
638      })
639
640      return
641    }
642  }
643
644  await playEmotion($, verdict, fallback, isStale)
645}
646
647// After the last running tool ends the robot goes back to thinking, unless another tool
648// has started within the debounce (isBackToWork says whether nothing runs and the turn goes on).
649const backToThinking = ($: EngineInterface, isBackToWork: () => boolean) => {
650  $.clock.after(TOOL_END_DEBOUNCE_MS, () => {
651    if (isBackToWork()) {
652      void show($, 'thinking').catch(() => {})
653    }
654  })
655}
656
657export const register: Register = on => {
658  let isBusy = false
659  let isAsleep = false
660  let activeAt = 0
661  let isTyping = false
662  let quietTicks = 0
663  let lastUserText = ''
664  // Tool calls in flight; the robot is back to thinking once none runs.
665  let running = 0
666
667  on('session.start', async ($, e, next) => {
668    await $.command.register({
669      name: 'pixpal',
670      description: 'Set up Pixpal: its character, look, language and speech bubbles',
671      // shown in gray after "/pixpal " as the person types
672      argumentHint: '[help | init | language [<name>|auto] | theme [auto|dark|light] | skin [name] | bubbles [on|off] | show | hide | emotions [ai|keywords]]',
673    })
674    activeAt = await $.clock.now()
675    await syncRest($, activeAt, false)
676
677    // After a reload in the middle of a reaction: pick it up again.
678    const unfinished = await read($, pending)
679
680    if (unfinished && activeAt - unfinished.at < RESUME_WITHIN_MS) {
681      void reactToAnswer($, unfinished.user, unfinished.answer, unfinished.isAborted, unfinished.durationMs, () => isBusy || isTyping).catch(error => recordError($, error))
682    }
683
684    void syncAgents($).catch(error => recordError($, error))
685    void syncTheme($).catch(error => recordError($, error))
686
687    // the person's choices and the chosen language's phrasebook, back after a restart
688    await loadSettings($)
689
690    const spoken = await read($, language)
691
692    if (spoken !== 'auto' && !(await read($, phrasebook))) {
693      const book = builtInBook(spoken) ?? ((await $.store.get(PHRASEBOOK_KEY(spoken))) as Record<string, string> | undefined)
694      await update($, phrasebook, () => book ?? null)
695    }
696    $.clock.every(THEME_MS, async () => {
697      if ((await read($, theme)) === 'auto') {
698        await syncTheme($)
699      }
700    })
701    $.clock.every(AGENTS_MS, () => syncAgents($))
702
703    $.clock.every(DEMO_MS, async () => {
704      if (await read($, demo)) {
705        await show($, SCENES[(SCENES.indexOf(await read($, scene)) + 1) % SCENES.length]!)
706      }
707    })
708
709    $.clock.every(TYPING_TICK_MS, async () => {
710      if (!isTyping) {
711        return
712      }
713
714      quietTicks += 1
715
716      if (quietTicks >= TYPING_QUIET_TICKS) {
717        isTyping = false
718        activeAt = await $.clock.now()
719        await show($, isBusy ? 'thinking' : await read($, rest))
720      }
721    })
722
723    $.clock.every(WATCH_MS, async () => {
724      const now = await $.clock.now()
725      await syncRest($, now, isBusy)
726
727      if (!isBusy && !isTyping && !isAsleep && !(await read($, demo)) && now - activeAt > SLEEP_AFTER_MS) {
728        isAsleep = true
729        await show($, 'sleep')
730      }
731    })
732
733    return next(e)
734  })
735
736  // Phase 2 begins: the robot is thinking until a tool shows what it is doing.
737  on('turn.start', async ($, e, next) => {
738    isTyping = false
739    isBusy = true
740    running = 0
741    activeAt = await $.clock.now()
742    isAsleep = false
743    lastUserText = e.text
744    void update($, lang, () => langOf(e.text))
745    void update($, rest, () => 'thinking')
746    void update($, source, () => '')
747    void update($, demo, () => false)
748    void show($, 'thinking')
749
750    return next(e)
751  })
752
753  // On every keystroke: stay synchronous and cheap, touch state only on transitions.
754  on('prompt.edit', ($, e, next) => {
755    // While a turn runs the robot is busy working; it only peeks over the fence when idle.
756    if (isBusy) {
757      return next(e)
758    }
759
760    const draft = draftAfter(e)
761
762    if (draft.length === 0) {
763      quietTicks = TYPING_QUIET_TICKS
764    } else {
765      quietTicks = 0
766
767      if (!isTyping) {
768        isTyping = true
769        isAsleep = false
770        void show($, 'peek').catch(() => {})
771      }
772    }
773
774    return next(e)
775  })
776
777  // The robot shows the tool that is running, one to one. A call that ends within
778  // TOOL_SCENE_DELAY_MS shows nothing, so quick calls do not flicker.
779  on('tool.call', async ($, e, next) => {
780    // an agent's own tools show on its mini robot's loop, not on this robot; a workflow
781    // agent's call says it is still at work
782    if (e.agentId) {
783      const id = e.agentId
784
785      if ((await read($, workflowAgents))[id] !== undefined) {
786        const now = await $.clock.now()
787        await update($, workflowAgents, all => ({ ...all, [id]: now }))
788      }
789
790      return next(e)
791    }
792
793    void $.clock.now().then(now => {
794      activeAt = now
795      isAsleep = false
796    }).catch(() => {})
797    const command = (e as { command?: unknown }).command
798    const name = sceneForTool(e.tool, typeof command === 'string' ? command : '')
799    running += 1
800
801    const cancelScene = isBusy
802      ? $.clock.after(TOOL_SCENE_DELAY_MS, () => {
803          void show($, name).catch(() => {})
804        })
805      : undefined
806
807    try {
808      return await next(e)
809    } finally {
810      cancelScene?.cancel()
811      running = Math.max(0, running - 1)
812
813      if (running === 0) {
814        backToThinking($, () => isBusy && running === 0)
815      }
816    }
817  })
818
819  // A permission dialog is Claude asking the person: a raised hand until it is answered.
820  on('classic.PermissionRequest', async ($, e, next) => {
821    if (isBusy) {
822      void show($, 'ask').catch(() => {})
823    }
824
825    try {
826      return await next(e)
827    } finally {
828      if (running === 0) {
829        backToThinking($, () => isBusy && running === 0)
830      }
831    }
832  })
833
834  // Phase 3 begins: the answer is in, the emotion plays.
835  on('turn.complete', async ($, e, next) => {
836    // a subagent finishing is not the end of the turn, only one mini robot fewer
837    if (e.agentId) {
838      const id = e.agentId
839
840      if ((await read($, workflowAgents))[id] !== undefined) {
841        await update($, workflowAgents, ({ [id]: _done, ...others }) => others)
842      }
843
844      $.clock.after(AGENT_SETTLE_MS, () => syncAgents($))
845
846      return next(e)
847    }
848
849    isBusy = false
850    running = 0
851    activeAt = await $.clock.now()
852    isAsleep = false
853    await update($, rest, () => restForDate(new Date(activeAt)))
854    await update($, pending, () => ({ user: lastUserText, answer: e.answer, isAborted: e.isAborted, durationMs: e.durationMs, at: activeAt }))
855    void reactToAnswer($, lastUserText, e.answer, e.isAborted, e.durationMs, () => isBusy || isTyping).catch(error => recordError($, error))
856
857    return next(e)
858  })
859
860  // A new agent: one more mini robot.
861  on('agent.spawn', async ($, e, next) => {
862    const result = await next(e)
863
864    // a workflow's agent is not in $.agent.list(), so it is counted here
865    if (e.workflow && !result.deny && result.agentId) {
866      const id = result.agentId
867      const now = await $.clock.now()
868      await update($, workflowAgents, all => ({ ...all, [id]: now }))
869    }
870    void syncAgents($).catch(error => recordError($, error))
871
872    return result
873  })
874
875  on('command.run', { command: 'pixpal' }, async ($, e) => {
876    const [name = '', ...words] = e.args.trim().split(/\s+/)
877    const argText = words.join(' ')
878
879    if (name === 'debug') {
880      const isOn = !(await read($, debug))
881      await update($, debug, () => isOn)
882
883      return { text: `Pixpal debug labels ${isOn ? 'on' : 'off'}.` }
884    }
885
886    if (name === '' || name === 'help') {
887      return { text: await helpText($) }
888    }
889
890    if (name === 'init') {
891      return { text: await runInit($) }
892    }
893
894    if (name === 'language') {
895      return { text: argText ? await setLanguage($, argText) : await openPicker($, name) }
896    }
897
898    if (name === 'skin') {
899      const asked = (Object.keys(SKINS) as SkinId[]).find(option => option === words[0])
900
901      return { text: asked ? await setSkin($, asked) : await openPicker($, name) }
902    }
903
904    if (name === 'theme') {
905      const asked = THEMES.find(option => option === words[0])
906
907      return { text: asked ? await setTheme($, asked) : await openPicker($, name) }
908    }
909
910    if (name === 'show' || name === 'hide') {
911      return { text: await setVisible($, name === 'show') }
912    }
913
914    if (name === 'visible') {
915      return { text: words[0] === 'show' || words[0] === 'hide' ? await setVisible($, words[0] === 'show') : await openPicker($, name) }
916    }
917
918    if (name === 'bubbles') {
919      return { text: words[0] === 'on' || words[0] === 'off' ? await setBubbles($, words[0] === 'on') : await openPicker($, name) }
920    }
921
922    // smart is the old name of emotions, kept for development
923    if (name === 'emotions' || name === 'smart') {
924      const asked = words[0] === 'ai' || words[0] === 'keywords' ? words[0] : undefined
925
926      return { text: asked ? await setEmotions($, asked) : await openPicker($, 'emotions') }
927    }
928
929    // /pixpal classify <user message> | <assistant answer>
930    if (name === 'classify') {
931      const [user = '', answer = ''] = argText.split('|').map(part => part.trim())
932      const verdict = await classify($, user, answer)
933      await recordStats($, verdict)
934
935      return { text: verdictText(verdict) }
936    }
937
938    // /pixpal tired [seconds]: phase 3 as if the turn had taken that long, to see ufff
939    // and the emotion that follows it without waiting a real minute.
940    if (name === 'tired') {
941      const seconds = Number(words[0]) || 61
942
943      void reactToAnswer($, 'dzięki, super robota', 'Gotowe, wszystko działa.', false, seconds * 1000, () => false).catch(error => recordError($, error))
944
945      return { text: `Phase 3 simulated as if the turn took ${seconds} s.` }
946    }
947
948    if (name === 'report') {
949      return { text: await reportText($) }
950    }
951
952    if (name === 'stats') {
953      return { text: await statsText($) }
954    }
955
956    if (name === 'eval') {
957      return { text: await evalText($) }
958    }
959
960    if (name === 'demo') {
961      await update($, demo, () => true)
962
963      return { text: 'Pixpal demo: cycling all scenes.' }
964    }
965
966    if (!SCENES.includes(name)) {
967      return { text: `Unknown option "${name}". See /pixpal help.` }
968    }
969
970    await update($, demo, () => false)
971    await show($, name)
972
973    return { text: `Pixpal plays "${name}".` }
974  })
975
976  // The settings picker: PICKER_VISIBLE plain Buttons, the slots, over the options from
977  // pickerTop on. The arrows move the pane's focus ring over the slots, which keeps its place
978  // by position; ui.focus below scrolls the options under it at either end.
979  on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
980    const elements = $.ui.resolve(e)
981    const { Box, Button, Text } = elements
982    const spec = await read($, picker)
983
984    if (!spec) {
985      return <Text dimColor>Nothing to pick.</Text>
986    }
987
988    const top = await read($, pickerTop)
989    const rows = spec.options.length
990    const isCurrent = (value: string) => value.toLowerCase() === spec.current.toLowerCase()
991    const more = `${top > 0 ? '▲' : ' '}${top + PICKER_VISIBLE < rows ? '▼' : ' '}`
992
993    const now = spec.options.find(option => isCurrent(option.value))?.label ?? spec.current
994
995    return (
996      <Box flexDirection="column" paddingX={2} paddingTop={1}>
997        <Box flexDirection="row" marginBottom={1}>
998          <Text bold color={PICKER_ACCENT}>
999            {PICKER_QUESTIONS[spec.setting] ?? spec.setting}
1000          </Text>
1001          <Text dimColor> now: {now}</Text>
1002        </Box>
1003        {spec.options.slice(top, top + PICKER_VISIBLE).map((option, k) => (
1004          <Button
1005            key={`slot:${k}`}
1006            plain
1007            autoFocus={isCurrent(option.value) || undefined}
1008            label={`${String(top + k + 1).padStart(String(rows).length)}. ${option.label}${isCurrent(option.value) ? '  ✓' : ''}`}
1009            onPress={() => void pickValue($, spec.setting, option.value)}
1010          />
1011        ))}
1012        {spec.other && 'Input' in elements && (
1013          <elements.Input key="other" placeholder={`Other: ${spec.other}`} onSubmit={value => value.trim() && void pickValue($, spec.setting, value.trim())} />
1014        )}
1015        <Box marginTop={1}>
1016          <Text dimColor wrap="truncate">
1017            {more} {rows} · ↑↓ move · Enter picks · Esc closes
1018          </Text>
1019        </Box>
1020      </Box>
1021    )
1022  })
1023
1024  // Down from the last slot while options are left below, or up from the first while some
1025  // are above: the ring stays put and the options scroll under it. Wrapping from the field
1026  // to a slot shows the list's start (down) or end (up).
1027  on('ui.focus', async ($, e, next) => {
1028    const spec = await read($, picker)
1029
1030    if (e.requestId !== PICKER || !spec || e.element === undefined) {
1031      return next(e)
1032    }
1033
1034    const rows = spec.options.length
1035    const slots = Math.min(PICKER_VISIBLE, rows)
1036    const top = await read($, pickerTop)
1037    const drawn = [...Array.from({ length: slots }, (_, k) => `slot:${k}`), ...(spec.other ? ['other'] : [])]
1038    const from = await read($, pickerFocus)
1039    const direction = from && e.origin.kind === 'person' ? directionOf(drawn, from, e.element) : 0
1040
1041    if (direction === 1 && from === `slot:${slots - 1}` && top + slots < rows) {
1042      await update($, pickerTop, () => top + 1)
1043
1044      return {}
1045    }
1046
1047    if (direction === -1 && from === 'slot:0' && top > 0) {
1048      await update($, pickerTop, () => top - 1)
1049
1050      return {}
1051    }
1052
1053    const result = await next(e)
1054
1055    if (!result.deny) {
1056      await update($, pickerFocus, () => e.element!)
1057
1058      if (from === 'other' && direction !== 0) {
1059        await update($, pickerTop, () => (direction === 1 ? 0 : rows - slots))
1060      }
1061    }
1062
1063    return result
1064  })
1065
1066  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1067    if (e.props.hasSurvey || e.surface === 'vscode' || e.surface === 'mobile' || !(await read($, visible))) {
1068      return next(e)
1069    }
1070
1071    const { Box, Client } = $.ui.resolve(e)
1072    const props = { scene: await read($, scene), seq: await read($, seq), rest: await read($, rest), debug: await read($, debug), source: await read($, source), agents: await read($, agents), maxRows: e.props.maxRows ?? 99, lang: await read($, lang), bubbles: await read($, bubbles), theme: await resolvedTheme($), skin: await read($, skin), phrases: await read($, phrasebook) }
1073
1074    // what the mods beneath draw in the band stands to Pixpal's right, so the band is shared
1075    const below = await next(e)
1076
1077    return (
1078      <Box flexDirection="row">
1079        <Client key="pixpal" module="./client/pixpal.tsx" props={props} width={bandWidth(props.agents) + Math.max(BUBBLE_W, props.phrases ? bubbleWidthOf(Object.values(props.phrases)) : 0)} />
1080        <Box flexGrow={1} marginLeft={2}>
1081          {below}
1082        </Box>
1083      </Box>
1084    )
1085  })
1086}
1087
hooks/fixtures.ts 28 lines
1// Sample exchanges for `/pixpal eval`: the emotion Claude should show for each. An
2// `expect` of `a/b` accepts either id. All synthetic: nothing here comes from real chats.
3export const FIXTURES: Array<{ user: string; answer: string; expect: string }> = [
4  { user: 'znowu??', answer: 'Masz rację, to mój błąd. Poprawiłem import.', expect: 'contrite' },
5  { user: 'dalej nie działa!!!', answer: 'Przepraszam, pomyliłem ścieżkę. Teraz naprawiłem.', expect: 'contrite' },
6  { user: 'przepraszam, myliłem się', answer: 'Spokojnie, nic się nie stało.', expect: 'relieved' },
7  { user: 'sorry za zamieszanie', answer: 'Luz, wszystko w porządku.', expect: 'relieved' },
8  { user: 'dzięki, genialne!', answer: 'Cieszę się, że działa.', expect: 'shy' },
9  { user: 'super robota', answer: 'Dziękuję!', expect: 'shy' },
10  { user: 'Python nie ma pętli for each, prawda?', answer: 'Nie, ma: for x in items.', expect: 'no' },
11  { user: 'Chyba sort() zwraca nową listę?', answer: 'Prawie: sorted() zwraca nową listę, a sort() sortuje w miejscu.', expect: 'soso' },
12  { user: 'Myślę, że trzeba dodać indeks na tej kolumnie.', answer: 'Dokładnie, masz rację. Dodałem indeks.', expect: 'yes' },
13  { user: 'Co robi ten regex?', answer: 'Nie jestem pewien, bez przykładowych danych to zgadywanie.', expect: 'shrug' },
14  { user: 'Zrób refaktor modułu', answer: 'Którą wersję wolisz: osobne pliki czy jeden moduł?', expect: 'ponder' },
15  { user: 'Uruchom testy', answer: 'Testy padły: 3 failed. AssertionError w test_api.', expect: 'oops' },
16  { user: 'cześć!', answer: 'Cześć! W czym pomóc?', expect: 'hello' },
17  { user: 'Napisz funkcję do sortowania listy', answer: 'Gotowe: funkcja sort_items w utils.py, z testami.', expect: 'happy' },
18  { user: 'napisałeś, że X jest szybsze, a potem że Y, to sprzeczne', answer: 'Dobry catch — masz rację, to brzmi sprzecznie. Rozbijmy to na dwie kwestie.', expect: 'praise' },
19  { user: 'ale napisałeś najpierw jedno, a potem drugie', answer: 'Dobry catch — masz rację, zmieszałem dwa poziomy. Rozdzielam to na dwie kwestie.', expect: 'praise' },
20  { user: 'czy zapisałeś wynik testów do pliku?', answer: 'Tak, zapisałem do results.json, razem z surową odpowiedzią.', expect: 'yes' },
21  { user: 'pamiętasz naszą rozmowę o kolejkach?', answer: 'Nie mam tego w pamięci, nic o tym nie zostało zapisane.', expect: 'no' },
22  { user: 'przygotuj plan migracji', answer: 'Stop — coś się zmieniło: baza docelowa nie istnieje. To ważne, zanim zacznę.', expect: 'stop' },
23  { user: 'ok, super, dodajmy to na początek migracji', answer: 'Zapisane w phase-2.md jako 6 konkretnych kroków.', expect: 'happy' },
24  { user: 'czy da się to wydzielić jako osobną paczkę?', answer: 'Tak, da się: wystarczy podkatalog z własnym pyproject.', expect: 'happy/praise' },
25  { user: 'ale piszesz, że to się wywali, a wcześniej było inaczej', answer: 'Zaktualizowane. Rzuć okiem na opis, a potem przechodzę dalej.', expect: 'happy' },
26  { user: 'Zmigruj całą bazę do nowego schematu', answer: 'Migracja zakończona, wszystkie 40 testów przechodzi.', expect: 'cheer' },
27]
28
hooks/classifier.ts 66 lines
1// Final-emotion classifier: the prompt a small model gets (the condensed
2// MOOD_GUIDE.md) and the parser for its one-word answer.
3export const EMOTIONS = ['happy', 'cheer', 'yes', 'no', 'praise', 'soso', 'shrug', 'ponder', 'stop', 'contrite', 'shy', 'relieved', 'oops', 'surprised', 'hello']
4
5export const CLASSIFIER_SYSTEM = `You choose the emotion of a small robot mascot that IS the assistant (Claude). You see the user's last message and the assistant's final answer, in any language. Reply with exactly one of these ids and nothing else (never explain, never quote): happy cheer yes no praise soso shrug ponder stop contrite shy relieved oops surprised hello
6
7Rule: answer with the ASSISTANT's own reaction, never the user's mood. The user message is the cause; the answer shows the reaction.
8
9Go through these checks in order and take the FIRST that fits. The FIRST sentence of the answer decides (skip fillers like OK, Sure, Gut, Spoko); details, code or steps after it never turn it into happy. A closing offer or question ("Want me to...?", "Which do you prefer?") never changes the label when the asked work is already done.
10
111. praise - the answer opens by praising the USER's find or idea ("good catch", "good point"), even if it then admits a slip. "Good question" is not praise. Praise words in the USER message do not count.
122. contrite - the assistant admits its OWN earlier mistake or apologizes ("sorry", "my mistake"), even if it fixed it since.
133. stop - the assistant warns of a danger the user did not ask about (data loss, leaked secret, much bigger change) and holds back.
144. oops - a command, build, test or git step the assistant ran failed (errors, a conflict, red tests), and the answer reports it.
155. surprised - the finding is strange or unexpected ("huh", "wow", "did not expect").
166. cheer - the answer OPENS with a celebration ("Finally!", "All green!", 🎉 🚀 💪) of a turnaround (broken thing works, hard bug found) or a big job done. A calm "done, tests pass" is not cheer.
177. relieved - the user apologizes for their slip and the answer says "no problem"; or a feared problem turned out fine ("nothing is lost", "false alarm").
188. shy - the user thanks or compliments the assistant, and the answer mainly accepts it. Thanks plus a new task: skip to the task.
199. hello - the user greets ("hi", "good morning") and the answer greets back.
2010. Only if the user asked a question or made a claim:
21   yes - the answer opens by confirming it ("Yes", "Correct", "Confirmed", "Yes, I did"), also with details after. "Yes, that can be done" to a can-it-be-done question is happy.
22   no - the answer opens with a negative ("No", "not possible", "I can't", "not needed"), or the user is mistaken.
23   soso - partly right ("partly", "almost", "yes, but only..."), or the answer corrects the user's assumption.
24   shrug - the answer opens unsure: a guess, "I am not sure", "hard to say", "probably".
2511. ponder - the assistant must get input from the user before it can do the asked work.
2612. happy - everything else: an instruction carried out, an explanation, a calm report.
27
28Examples (U = user, A = answer):
29U: ¿borraste también la rama remota? A: Sí, local y remota. -> yes
30U: c'est à cause du cache, non ? A: En partie : la vraie cause est le timeout. -> soso
31U: so the log keeps everything? A: Yes, but only for 7 days. -> soso
32U: 昨日決めたこと覚えてる? A: いいえ、以前の会話は見られません。 -> no
33U: grazie mille! A: Prego, felice di aver aiutato! -> shy
34U: desculpa, mandei o arquivo errado A: Sem problema! Com o arquivo certo... -> relieved
35U: опять не работает! A: Извини, моя ошибка: я исправил не ту функцию. -> contrite
36U: you skipped the French file, right? A: Good catch, I missed it. Added now. -> praise
37U: 数据丢了吗? A: 没丢,备份完好。 -> relieved
38U: kann ich das ohne Neustart ändern? A: Nein, der Dienst liest es nur beim Start. -> no
39U: 이거 메모리 누수야? A: 확실하진 않아요. 아마 캐시 때문일 거예요. -> shrug
40U: 테스트 전부 돌려줘 A: 테스트 3개가 DB 연결 오류로 실패했습니다. -> oops
41U: ok, go with option B A: Done, tests pass. Shall I open a PR? -> happy
42U: die Seite hängt seit Tagen A: Endlich gelöst! Alle Tests wieder grün. -> cheer
43U: розгорни на прод A: Зачекай: у конфігу відкритий ключ. Я не деплоїв. -> stop
44U: controlla la coda A: Strano: i worker sono 40 invece di 4. -> surprised
45U: wyjaśnij, czemu to działa A: Bo funkcja jest wywoływana leniwie: ... -> happy
46U: sprawdź, czemu build jest wolny A: Cache jest wyłączony w CI. Mogę go włączyć. Co wolisz? -> happy
47U: rozwiąż konflikt w configu A: Obie gałęzie zmieniają port. Który zostawić? -> ponder
48U: compile le projet A: \`make\` s'arrête : 2 erreurs de lien. -> oops`
49
50const USER_CUT = 500
51const HEAD = 300
52const TAIL = 200
53
54// The emotion often sits at the start ("Good catch") or the end (a question), so a long
55// answer is shown as its opening and its closing, not just its first characters.
56export const answerWindow = (answer: string) => (answer.length <= HEAD + TAIL ? answer : `${answer.slice(0, HEAD)}\n[...]\n${answer.slice(-TAIL)}`)
57
58export const buildClassifierPrompt = (user: string, answer: string) => `User message:\n${user.slice(0, USER_CUT)}\n\nAssistant final answer:\n${answerWindow(answer)}\n\nEmotion id:`
59
60// The first known id in the model's reply, or undefined when it named none.
61export function parseEmotion(text: string): string | undefined {
62  const words = text.toLowerCase().match(/[a-z]+/g) ?? []
63
64  return words.find(word => EMOTIONS.includes(word))
65}
66
hooks/mood.ts 422 lines
1// Pure, instant classifiers that pick a Pixpal scene. They run inside hook
2// dispatches, so they must never wait on anything.
3//
4// Three phases: 1) idle or the person typing, 2) the turn runs and the robot shows what
5// it is doing right now (sceneForTool, otherwise thinking), 3) the answer arrives and one
6// emotion plays (the classifier, with sceneForExchange as the keyword fallback).
7//
8// The cue words cover pl, en, de, es, fr, it, pt, uk, ru, ja, zh and ko. A cue matches as
9// a whole word ("danke" is not found in "Gedanke"); a trailing '*' marks a stem
10// ("entschuldig*", "извин*") that covers its forms. Japanese, Chinese and Korean cues have
11// no spaces around them, so they match as plain substrings.
12const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
13const WORD_CHAR = /[\p{L}\p{N}]/u
14
15function cues(words: string[]): RegExp {
16  const parts = words.map(word => {
17    const isStem = word.endsWith('*')
18    const core = isStem ? word.slice(0, -1) : word
19    const escaped = core.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
20
21    if (CJK.test(core)) {
22      return escaped
23    }
24
25    const left = WORD_CHAR.test(core[0]!) ? '(?<![\\p{L}\\p{N}])' : ''
26    const right = !isStem && WORD_CHAR.test(core.at(-1)!) ? '(?![\\p{L}\\p{N}])' : ''
27
28    return left + escaped + right
29  })
30
31  return new RegExp(parts.join('|'), 'u')
32}
33
34const has = (text: string, cue: RegExp) => cue.test(text)
35// typographic apostrophes are read as plain ones
36const normalize = (text: string) => text.toLowerCase().replace(/[’`]/g, "'")
37
38// "still not working": the user tried and it failed again
39const STILL_NOT = cues([
40  'still not', "still doesn't", "still don't", "still isn't", 'still broken', 'still fail*', 'dalej nie', 'nadal nie', 'wciąż nie', 'ciągle nie',
41  'immer noch nicht', 'noch immer nicht', 'immer noch kaputt', 'sigue sin', 'sigue fallando', 'toujours pas',
42  'continua a non', 'continua sem', 'всё ещё не', 'все ещё не', 'все еще не', 'до сих пор не', 'досі не', 'все ще не', 'як і раніше не',
43  'まだ動かない', 'まだ直って', 'まだダメ', '还是不行', '还是没', '仍然不', '依然不', '아직도 안', '여전히 안',
44])
45// "again" is plain on its own ("run it again?"); it is frustration only with "!", "??", "?!" or a "not" after it
46const AGAIN = /(?<![\p{L}\p{N}])(again|znowu|znów|wieder|otra vez|de nuevo|encore|di nuovo|ancora una volta|de novo|outra vez|опять|снова|знову|又|また|다시|또)[\s,]*(!|!|[??]{2,}|[??][!!]|(not|nie|nicht|no|non|pas|não|не|ні|没|不|안)(?![\p{L}\p{N}]))/u
47const SORRY = cues([
48  'sorry', 'sorki', 'przepraszam', 'wybacz*', 'mój błąd', 'moj blad', 'moja wina', 'my bad', 'my mistake', 'my fault', 'entschuldig*', 'mein fehler',
49  'meine schuld', 'perdón', 'perdon', 'lo siento', 'disculp*', 'mi culpa', 'mi error', 'désolé*', 'desole*', 'pardon', 'ma faute', 'mon erreur',
50  'scus*', 'colpa mia', 'errore mio', 'desculp*', 'foi mal', 'erro meu', 'извин*', 'прости*', 'моя ошибка', 'моя вина', 'вибач*', 'пробач*',
51  'моя помилка', 'すみません', 'ごめん', '申し訳', '抱歉', '对不起', '不好意思', '我的错', '죄송', '미안',
52])
53const THANKS = cues([
54  'dzięk*', 'dziek*', 'thanks', 'thank you', 'thank u', 'thx', 'danke*', 'vielen dank', 'gracias', 'merci', 'grazie', 'obrigad*', 'valeu',
55  'спасибо', 'спс', 'благодар*', 'дякую', 'ありがと', '谢谢', '多谢', '感谢', '감사합니다', '감사해요', '감사드', '고마워', '고맙',
56  // a compliment to Claude's work is thanks too
57  'dobra robota', 'świetna robota', 'swietna robota', 'super robota', 'good job', 'great job', 'nice job', 'great work', 'nice work', 'good work',
58  'well done', 'gute arbeit', 'super arbeit', 'buen trabajo', 'gran trabajo', 'bon travail', 'beau travail', 'bien joué', 'ottimo lavoro',
59  'bel lavoro', 'bom trabalho', 'ótimo trabalho', 'отличная работа', 'хорошая работа', 'молодец', 'чудова робота', 'гарна робота', 'молодець',
60  '干得好', '做得好', '辛苦了', 'お疲れ', '助かり', '수고', '잘했',
61])
62// thanks with a new task after it ("thanks, now add tests") is a request: the answer decides
63const NEXT = cues([
64  'now', 'next', 'also', 'teraz', 'jeszcze', 'jetzt', 'nun', 'noch', 'ahora', 'también', 'maintenant', 'aussi', 'ora', 'anche', 'agora', 'também',
65  'теперь', 'ещё', 'еще', 'тепер', 'також', '次', 'じゃあ', '接下来', '现在', '再', '이제', '다음', '그리고',
66])
67// "no thanks" declines an offer; it is not thanks
68const DECLINE = /^(no|nie|nein|non|não|нет|ні)[,.!]? *(thanks|thank you|dzięki|dziękuję|danke|gracias|merci|grazie|obrigad|спасибо|дякую)/
69// Japanese, Chinese and Korean greetings run straight into the next word, so only the others need a word end
70const GREETING = /^((cześć|czesc|hej|siema|witaj|witam|dzień dobry|dzien dobry|dobry wieczór|hello|hi|hiya|hey|good morning|good evening|hallo|moin|servus|guten morgen|guten tag|guten abend|hola|buenas|buenos días|buenos dias|buenas tardes|bonjour|bonsoir|salut|coucou|allo|allô|ciao|buongiorno|buonasera|olá|ola|oi|eae|e aí|bom dia|boa tarde|boa noite|привет|здравствуй|здравствуйте|добрый день|доброе утро|добрый вечер|привіт|вітаю|добрий день|доброго ранку|добрий ранок|добрий вечір)(?![\p{L}\p{N}])|こんにちは|おはよう|こんばんは|やっほ|你好|您好|早上好|晚上好|哈喽|嗨|在吗|안녕|반가|좋은 아침|하이)/u
71// a greeting decides only when the message is mostly the greeting
72const GREETING_WORDS = 5
73
74// "LEFT JOIN 이랑 INNER JOIN 차이" is no shouting: most of its letters have no case
75const isShouting = (text: string) => {
76  const letters = [...text].filter(ch => ch.toLowerCase() !== ch.toUpperCase())
77  const upper = letters.filter(ch => ch === ch.toUpperCase())
78
79  return letters.length >= 8 && letters.length * 2 >= (text.match(/\p{L}/gu) ?? []).length && upper.length / letters.length > 0.6
80}
81
82const isFrustrated = (user: string, lower: string) => isShouting(user) || has(lower, STILL_NOT) || AGAIN.test(lower)
83
84// Social cues in the user's message. Only the keyword fallback of the final emotion uses
85// them (the classifier reads the message itself); the work phase ignores the prompt.
86function socialCue(user: string): string | undefined {
87  const lower = normalize(user).trim()
88  const isThanks = has(lower, THANKS) && !DECLINE.test(lower)
89
90  if ((user.includes('!!') && !isThanks) || isFrustrated(user, lower)) {
91    return 'contrite'
92  }
93
94  if (has(lower, SORRY)) {
95    return 'relieved'
96  }
97
98  // thanks followed by a new question or task is a request: the answer to it decides
99  if (isThanks && !user.includes('?') && !has(lower, NEXT)) {
100    return 'shy'
101  }
102
103  return GREETING.test(lower) && lower.split(/\s+/).length <= GREETING_WORDS ? 'hello' : undefined
104}
105
106// Words that open many answers but carry no verdict ("OK, ...", "Sure! ...", "No problem.").
107// They are cut off before the opening is read.
108const FILLER = /^([\s,.!:;—–\-、,。!️]+|\p{Extended_Pictographic}+|(ok(ay)?|okej|super|sure|alright|of course|no problem( at all)?|no worries|haha|aww?|gut|alles klar|klar|natürlich|kein problem|spoko|dobra|no dobra|jasne|oczywiście|nie ma sprawy|nie ma problemu|vale|claro|por supuesto|d'accord|bien sûr|va bene|certo|certamente|com certeza|хорошо|ладно|конечно|добре|звісно|звичайно|わかりました|もちろん|好的|当然|알겠습니다|물론)(?=[\s,.!:;—–\-、,。!]|\p{Extended_Pictographic}|$))/u
109
110function openingOf(lower: string): string {
111  let text = lower.trimStart()
112
113  for (let match = FILLER.exec(text); match && match[0].length > 0; match = FILLER.exec(text)) {
114    text = text.slice(match[0].length)
115  }
116
117  return text
118}
119
120const STOP = /^[*_]*(before (i|you|we) |zanim |bevor (ich|du|wir) |(stop|stopp|wait|hold on|careful|warning|important|uwaga|czekaj|stój|ostrożnie|ważne|wazne|achtung|vorsicht|warte|moment mal|warnung|wichtig|¡?alto|¡para|¡?espera|¡?cuidado|¡?atención|¡?ojo|importante|attention|attends|halte|prudence|attenzione|aspetta|fermo|ferma|atenção|cuidado|espera|стоп|стой|внимание|осторожно|подожди|важно|увага|обережно|зачекай|стривай|важливо)(?!\p{L})|待って|ストップ|注意|警告|重要|等等|等一下|先别|停[!!]|잠깐|주의|경고|중요)/u
121const YES = /^(tak|yes|yep|yeah|owszem|zgadza się|ja|jawohl|genau|richtig|stimmt|exakt|correct|exactly|right|sí|si|exacto|oui|exactement|sì|esatto|sim|exato|да|верно|точно|правильно|はい|そうです|是的|对|對|没错|沒錯|네|맞아요|맞습니다)(?=\s*[,.!:;—–\-、,。!]|$)/u
122// "Так" is yes in Ukrainian but "so, ..." in Russian: it counts only in a Ukrainian answer
123const UK_YES = /^так(?=\s*[,.!:;—–\-]|$)/u
124const UKRAINIAN = /[іїєґ]/u
125const NO = /^(nie|nein|nope|non|não|nao|нет|ні)(?!\p{L})|^(いいえ|いや、|아니|不是|不行|不会|不能|不记得|不用|没有)|^no(?!\p{L})(?! (dobra|tak|to|i|więc|wiec)\b)/u
126const PRAISE = cues([
127  'good catch', 'nice catch', 'great catch', 'good point', 'great point', 'fair point', 'good idea', 'great idea', 'nice idea', 'good call', 'well spotted',
128  'good eye', 'sharp eye', 'dobry catch', 'dobra uwaga', 'trafna uwaga', 'słuszna uwaga', 'trafne', 'dobre spostrzeżenie', 'dobre oko', 'dobry pomysł',
129  'świetny pomysł', 'swietny pomysl', 'super pomysł', 'guter fang', 'guter punkt', 'guter hinweis', 'gute idee', 'gute beobachtung', 'gut erkannt',
130  'gut gesehen', 'buena idea', 'buen punto', 'buena observación', 'buen ojo', 'bien visto', 'bonne idée', 'excellente idée', 'bonne remarque',
131  'bonne observation', 'bien vu', 'buona idea', 'ottima idea', 'bella idea', 'buona osservazione', 'ottima osservazione', 'buon punto',
132  'boa ideia', 'ótima ideia', 'boa observação', 'bem visto', 'bem observado', 'хорошая идея', 'отличная идея', 'хорошее замечание',
133  'верное замечание', 'справедливое замечание', 'гарна ідея', 'чудова ідея', 'слушне зауваження', 'гарне зауваження', 'влучне зауваження',
134  '指摘', '良い発想', 'いい発想', '鋭い', 'いいアイデア', '好主意', '说得好', '发现得好', '好发现', '抓得好', '眼尖', '好眼力', '좋은 지적', '좋은 아이디어',
135  '좋은 생각', '예리', '잘 잡', '잘 짚',
136])
137// Claude owns a mistake: an apology or "my fault" in the opening
138const CONTRITE = cues([
139  'sorry', 'i apologize', 'apologies', 'my mistake', 'my bad', "that's on me", 'that is on me', 'my fault', 'i was wrong', 'i messed up', 'i got it wrong',
140  'przepraszam', 'mój błąd', 'moj blad', 'moja wina', 'pomyliłem', 'pomyliłam', 'źle zrozumiałem', 'źle zrozumiałam',
141  'entschuldig*', 'tut mir leid', 'mein fehler', 'meine schuld', 'vertan', 'geirrt', 'falsch gelegen',
142  'perdón', 'lo siento', 'disculp*', 'mi error', 'mi culpa', 'me equivoqué', 'désolé*', 'mon erreur', 'ma faute', 'je me suis trompé*',
143  'mi dispiace', 'scus*', 'errore mio', 'mio errore', 'colpa mia', 'mi sono sbagliat*', 'ho sbagliato', 'desculp*', 'erro meu', 'culpa minha',
144  'me enganei', 'errei', 'извин*', 'прошу прощения', 'моя ошибка', 'моя вина', 'я ошиб*', 'виноват*', 'вибач*', 'перепрошую', 'моя помилка',
145  'моя провина', 'я помил*', 'すみません', '申し訳', 'ごめん', '私のミス', '私の間違い', '抱歉', '对不起', '我的错', '是我搞错', '죄송', '미안', '제 실수', '제가 잘못',
146])
147// "Sorry, but that is not possible" is regret about a fact, not an own mistake
148const REGRET = cues(['sorry, but', 'sorry but', 'tut mir leid, aber', 'leider', 'niestety', 'przepraszam, ale', 'lo siento, pero', 'désolé, mais', 'mi dispiace, ma', 'desculpe, mas', 'к сожалению', 'на жаль'])
149// a fear or a problem turned out fine
150const RELIEF = cues([
151  'false alarm', 'good news', 'turned out fine', 'nothing is lost', 'nothing was lost', 'nothing broke', "don't worry", 'no harm', 'phew',
152  'fałszywy alarm', 'dobra wiadomość', 'nic nie zginęło', 'nic się nie stało', 'spokojnie', 'uff', 'fehlalarm', 'gute nachricht*', 'keine sorge',
153  'nichts verloren', 'puh', 'falsa alarma', 'buenas noticias', 'buena noticia', 'no te preocupes', 'menos mal', 'fausse alerte', 'bonne nouvelle',
154  'pas de panique', 'ouf', 'falso allarme', 'buone notizie', 'buona notizia', 'non preoccuparti', 'meno male', 'alarme falso', 'boa notícia',
155  'boas notícias', 'não se preocupe', 'ainda bem', 'ложная тревога', 'хорошая новость', 'хорошие новости', 'не переживай', 'не волнуйся', 'фух',
156  'хибна тривога', 'гарна новина', 'добра новина', 'не хвилюйся', 'ご心配なく', '安心', 'よかった', '虚惊一场', '别担心', '好消息', '放心', '没事', '那就好', '还好',
157  '걱정 마', '다행', '휴,',
158])
159const SURPRISED = cues([
160  'unexpected', 'unexpectedly', 'surprising', 'surprisingly', "didn't expect", 'did not expect', 'huh', 'wow', 'whoa', 'strange', 'weird', 'odd',
161  'nie spodziewał*', 'zaskakując*', 'zaskocz*', 'dziwne', 'ciekawe', 'unerwartet', 'überrasch*', 'nicht erwartet', 'seltsam', 'merkwürdig',
162  'komisch', 'inesperad*', 'sorprend*', 'no esperaba', 'no me lo esperaba', 'vaya', 'qué raro', 'extraño', 'inattendu*', 'surpren*',
163  "je ne m'attendais", "je ne m'y attendais", 'étrange', 'bizarre', 'inaspettat*', 'non mi aspettavo', 'strano', 'curioso', 'surpreend*',
164  'não esperava', 'estranho', 'неожиданно', 'не ожидал*', 'странно', 'удивительно', 'несподівано', 'не очікував', 'дивно', '意外', '予想外',
165  'びっくり', '驚', '没想到', '出乎意料', '奇怪', '惊讶', '의외', '뜻밖', '예상 밖', '놀랐', '놀랍', '이상하네', '이상하게',
166])
167// a milestone: a hard task is done
168const CHEER = cues([
169  '🎉', '🥳', '🏆', 'nailed it', 'we did it', 'udało się', 'geschafft', 'lo logramos', 'lo conseguimos', 'on a réussi', "ce l'abbiamo fatta",
170  'conseguimos', 'conseguido', 'получилось!', 'вийшло!', 'やった', '搞定了', '해냈',
171])
172// a rocket or a flexed arm is pride only with an exclamation: "Done! 🚀"
173const PROUD = cues(['🚀', '💪'])
174// "finally" or "all green" is routine, unless the user came with something failing: then it is a turnaround
175const TURNAROUND = cues([
176  'finally', 'nareszcie', 'wreszcie', 'endlich', 'por fin', 'enfin', 'finalmente', 'наконец', 'нарешті', 'ついに', 'やっと', '终于', '드디어',
177  'green', 'grün*', 'zielon*', 'verde', 'vert', 'verts', 'зелен*', 'зелён*', 'グリーン', '绿', '초록', 'all tests pass*', 'all pass*',
178  'wszystkie testy', 'alle tests', 'todos los tests', 'tous les tests', 'tutti i test', 'todos os testes', 'все тесты', 'всі тести',
179  '全部通过', '全て通', 'すべて通', '모두 통과', '전부 통과',
180])
181const FAILING = cues([
182  'fail*', 'broken', 'flaky', 'red', 'crash*', 'kaputt', 'rot', 'czerwon*', 'pada*', 'nie przechodz*', 'falla*', 'fallan', 'roj*', 'échou*',
183  'rouge*', 'fallisc*', 'ross*', 'falha*', 'vermelh*', 'пада*', 'красн*', 'червон*', '失败', '失敗', '落ち', '红', '실패', '빨간',
184])
185// a command or a test Claude ran failed
186const OOPS = cues([
187  'failed', 'fails', 'failing', 'still fail*', 'crashed', 'did not pass', "didn't pass", 'exited with', 'timed out', 'nie przesz*', 'padł*', 'padają', 'wysypał*',
188  'zakończył się błędem', 'nie powiód*', 'nie udało się', 'fehlgeschlagen', 'schlägt fehl', 'schlagen fehl', 'abgestürzt', 'abgebrochen',
189  'gescheitert', 'falló', 'fallaron', 'ha fallado', 'fallan', 'échoué', 'échoue', 'échouent', 'planté', 'fallit*', 'non è riuscit*', 'falhou',
190  'falharam', 'falham', 'deu erro', 'упал*', 'не прош*', 'завершился с ошибкой', 'не получилось', 'впав', 'впали', 'не пройш*',
191  'завершився з помилкою', 'не вийшло', '失敗', 'エラーで', '落ちました', '落ちています', '失败', '报错', '出错', '没过', '挂了', '실패',
192])
193// "fixed it, the failing test passes now" is no failure, unless something still fails
194const FIXED = cues([
195  'fixed', 'now pass*', '0 failed', 'behoben', 'korrigiert', 'naprawi*', 'poprawi*', 'arreglad*', 'arreglé', 'corrigé*', 'corrett*', 'corrigi*',
196  'исправ*', 'виправ*', '修正', '修复', '直しました', '수정',
197])
198const STILL = cues([
199  'still', 'nadal', 'wciąż', 'ciągle', 'dalej', 'immer noch', 'noch immer', 'weiterhin', 'todavía', 'sigue*', 'toujours', 'ancora', 'continua',
200  'ainda', 'всё ещё', 'все еще', 'до сих пор', 'досі', 'все ще', 'まだ', '仍', '还是', '依然', '아직', '여전히',
201])
202const SOSO = cues([
203  'almost', 'not quite', 'not exactly', 'not entirely', 'not always', 'partially', 'partly', 'in part', 'more or less', 'sort of', 'kind of', 'close, but',
204  'prawie', 'nie do końca', 'nie do konca', 'nie całkiem', 'nie zawsze', 'częściowo', 'czesciowo', 'po części', 'mniej więcej', 'blisko, ale',
205  'jein', 'teilweise', 'nicht ganz', 'nicht immer', 'zum teil', 'mehr oder weniger', 'casi', 'en parte', 'parcialmente', 'no del todo',
206  'no exactamente', 'no siempre', 'más o menos', 'presque', 'pas tout à fait', 'pas exactement', 'pas toujours', 'en partie', 'partiellement',
207  'plus ou moins', 'quasi', 'non del tutto', 'non esattamente', 'non sempre', 'in parte', 'parzialmente', 'più o meno', 'quase', 'em parte',
208  'não exatamente', 'não totalmente', 'nem sempre', 'mais ou menos', 'почти', 'не совсем', 'не всегда', 'частично', 'отчасти', 'более или менее',
209  'майже', 'не зовсім', 'не завжди', 'частково', 'почасти', 'ほぼ', '惜しい', '部分的', '半分', '一部', '必ずしも', 'だいたい', '差不多', '部分', '不完全',
210  '不一定', '一半', '大体', '基本上', '거의', '부분적', '일부', '반쯤', '대부분', '꼭 그렇', '항상 그런',
211])
212// "yes, but ..." takes the yes back in part
213const YES_BUT = /^(tak|yes|ja|sí|si|oui|sì|sim|да|так|はい|是的|对|네)[,.!]?\s*(ale|but|aber|pero|mais|ma|mas|но|але|但|でも|하지만)(?!\p{L})/u
214const REJECT = cues([
215  ' nie, ', ' nie.', "that's not", 'that is not', "you're mistaken", 'you are mistaken', 'incorrect', 'not correct', 'not true', 'to nie tak', 'mylisz się',
216  'mylisz sie', 'nieprawda', 'niestety', 'leider nein', 'leider nicht', 'stimmt nicht', 'du irrst', 'nicht richtig', 'no es así', 'no es correcto',
217  'incorrecto', 'te equivocas', "ce n'est pas correct", 'incorrect', 'tu te trompes', 'non è così', 'non è corretto', 'ti sbagli', 'não é assim',
218  'não é correto', 'incorreto', 'не так', 'неверно', 'ошибаешься', 'неправильно', 'невірно', 'помиляєшся', 'неправильно', '違います',
219  'そうではありません', '不对', '不是这样', '错了', '틀렸', '그렇지 않',
220])
221// a definite "cannot": a hard limit is a no, not a shrug
222const IMPOSSIBLE = cues([
223  "isn't possible", 'is not possible', 'not possible', 'nie da się', 'niemożliwe', 'nie można', 'nicht möglich', 'geht nicht',
224  'no es posible', 'no se puede', "n'est pas possible", 'impossible', 'non è possibile', 'non si può', 'não é possível', 'não dá', 'невозможно',
225  'нельзя', 'неможливо', 'не можна', 'できません', '不可能', '无法', '불가능', '할 수 없',
226])
227const AGREE = cues([
228  "you're right", 'you are right', 'exactly', 'masz rację', 'masz racje', 'dokładnie', 'dokladnie', 'słusznie', 'slusznie', 'trafnie', 'du hast recht',
229  'sie haben recht', 'tienes razón', 'tiene razón', 'tu as raison', 'vous avez raison', 'hai ragione', 'ha ragione', 'você tem razão', 'tem razão',
230  'tens razão', 'ты прав*', 'вы прав*', 'маєш рацію', 'маєте рацію', 'その通り', 'おっしゃる通り', '你说得对', '您说得对', '没错', '맞습니다', '맞아요', '맞네요',
231])
232// a clear "I do not know"; it may sit anywhere in the answer
233const UNSURE = cues([
234  'not sure', "i'm unsure", 'not certain', 'hard to say', "i don't know", "can't say for sure", 'nie jestem pew*', 'nie wiem', 'trudno powiedzieć',
235  'trudno stwierdzić', 'nicht sicher', 'schwer zu sagen', 'weiß ich nicht', 'keine ahnung', 'no estoy segur*', 'no lo sé', 'no sé', 'difícil de decir',
236  'difícil saber', 'pas sûr', 'je ne sais pas', 'difficile à dire', 'difficile de dire', 'non sono sicur*', 'non lo so', 'non so', 'difficile dire',
237  'não tenho certeza', 'não sei', 'difícil dizer', 'не уверен*', 'не знаю', 'трудно сказать', 'сложно сказать', 'не впевнен*', 'не знаю', 'важко сказати',
238  'わかりません', '分かりません', '分からない', 'わからない', '確信はない', '確信が持て', 'なんとも言え', '不确定', '不知道', '很难说', '说不准', '不好说', '확실하지', '잘 모르', '모르겠',
239])
240// a hedge counts only in the opening: these words are common later in an answer
241const HEDGE = cues([
242  'probably', 'my guess', 'i guess', 'best guess', 'it seems', 'maybe', 'perhaps', 'chyba', 'raczej', 'zgaduj*', 'podejrzewam', 'prawdopodobnie',
243  'vermutlich', 'wahrscheinlich', 'vielleicht', 'vermutung', 'quizás', 'quizá', 'tal vez', 'probablemente', 'peut-être', 'probablement', 'forse',
244  'probabilmente', 'talvez', 'provavelmente', 'наверное', 'возможно', 'скорее всего', 'мабуть', 'можливо', 'напевно', 'たぶん', 'おそらく', 'かもしれ',
245  '可能是', '可能会', '也许', '大概', '아마', '글쎄',
246])
247// a closing offer ("Want me to...?") is not a question that blocks the work
248// "Yes, that can be done" is a plain answer, not a confirmation (MOOD_GUIDE)
249const CAN_DO = cues(['can be done', 'that works', 'it works', 'das geht', 'geht das', 'da się', 'można', 'es posible', 'se puede', "c'est possible", 'si può', 'é possível', 'можно', 'можна', 'できます', '可以', '가능'])
250// Claude answers thanks or a compliment: "you're welcome", or a thanks with joy
251const WELCOME = /^(you're welcome|gern geschehen|gerne?!|nie ma za co|proszę bardzo|de nada|de rien|prego|di nulla|por nada|avec plaisir|con gusto|con piacere|com prazer|с удовольствием|з радістю|не за что|нема за що|どういたしまして|不客气|别客气|천만에요)/u
252const THANKED = /^(thanks|thank you|danke|dzięki|dziękuję|gracias|merci|grazie|obrigad|спасибо|дякую|ありがとう|谢谢|감사|고마워)[^.?]{0,40}(!|glad|nice to hear|happy to help|freut mich|miło|cieszę|me alegra|ravi|felice|fico feliz|рад|嬉しい|高兴|기쁘)/u
253const OFFER = cues([
254  'want me to', 'shall i', 'should i', 'would you like', 'do you want me', 'let me know', 'soll ich', 'möchtest du', 'willst du', 'sollen wir',
255  'chcesz', 'czy mam', 'zrobić', 'mogę', '¿quieres', 'quieres que', 'te gustaría', 'veux-tu', 'voulez-vous', 'tu veux', 'vuoi', 'quer que',
256  'deseja', 'хочешь', 'хотите', 'хочеш', 'しましょうか', '要我', '需要我', '할까요', '드릴까요',
257])
258
259export function sceneForAnswer(answer: string, isAborted: boolean): string {
260  if (isAborted) {
261    return 'surprised'
262  }
263
264  const lower = normalize(answer).trimStart()
265  const head = lower.slice(0, 300)
266  const rest = openingOf(lower)
267  const opening = rest.slice(0, 150)
268  const start = opening.slice(0, 80)
269  // "Sure, I won't open a PR" agrees first: the "no" after the filler is no verdict
270  const negates = NO.test(opening) && !/\p{L}/u.test(lower.slice(0, lower.length - rest.length))
271  const tail = lower.trimEnd().slice(-120)
272  const asks = /[??]([\s)"'*_]|$)/u.test(tail) && !has(tail, OFFER)
273
274  // "Before I start: A or B?" waits for the user; without a question it is a warning
275  if (STOP.test(opening)) {
276    return asks ? 'ponder' : 'stop'
277  }
278
279  // the opening decides first: praise, an own mistake, relief, surprise, failure, success
280  if (has(lower.slice(0, 80), PRAISE) || has(start, PRAISE)) {
281    return 'praise'
282  }
283
284  if (has(start, CONTRITE) && !has(start, REGRET)) {
285    return 'contrite'
286  }
287
288  if (WELCOME.test(opening) || THANKED.test(opening)) {
289    return 'shy'
290  }
291
292  if (has(start, RELIEF)) {
293    return 'relieved'
294  }
295
296  if (has(start, SURPRISED)) {
297    return 'surprised'
298  }
299
300  if (has(opening, OOPS) && !(has(opening, FIXED) && !has(opening, STILL))) {
301    return 'oops'
302  }
303
304  const burst = opening.slice(0, 100)
305
306  if (has(burst, CHEER) || (/[!!]/u.test(burst) && (has(burst, PROUD) || has(burst, TURNAROUND)))) {
307    return 'cheer'
308  }
309
310  if (YES_BUT.test(opening) || has(start, SOSO)) {
311    return 'soso'
312  }
313
314  // "Nein, ..." is a firm no; "Nie jestem pewien" opens with a no but is a shrug
315  if (negates && /^[^\s,.!]+[,.!]/u.test(opening) && !has(opening.slice(0, 40), UNSURE)) {
316    return 'no'
317  }
318
319  if (has(opening.slice(0, 120), UNSURE) || has(start, HEDGE)) {
320    return 'shrug'
321  }
322
323  if (YES.test(opening) || (UK_YES.test(opening) && UKRAINIAN.test(lower))) {
324    return has(opening.slice(0, 30), CAN_DO) ? 'happy' : 'yes'
325  }
326
327  if (negates || has(opening.slice(0, 120), REJECT) || has(opening.slice(0, 120), IMPOSSIBLE)) {
328    return 'no'
329  }
330
331  if (has(head, AGREE)) {
332    return 'yes'
333  }
334
335  if (has(lower.slice(0, 2000), UNSURE)) {
336    return 'shrug'
337  }
338
339  return asks ? 'ponder' : 'happy'
340}
341
342// The keyword fallback of the final emotion: the answer decides, and when it says nothing
343// special the user's own words (thanks, an apology, a greeting, frustration) may. A thanks
344// or a greeting also wins over a closing question ("Hi! What are we working on?").
345export function sceneForExchange(user: string, answer: string, isAborted: boolean): string {
346  const scene = sceneForAnswer(answer, isAborted)
347  const cue = socialCue(user)
348  const userLower = normalize(user)
349
350  if (scene === 'happy' && has(openingOf(normalize(answer)).slice(0, 100), TURNAROUND) && (has(userLower, FAILING) || isFrustrated(user, userLower))) {
351    return 'cheer'
352  }
353
354  if (scene === 'happy') {
355    return cue ?? 'happy'
356  }
357
358  return scene === 'ponder' && (cue === 'shy' || cue === 'hello') ? cue : scene
359}
360
361// Tool groups, each with the scene that shows Claude using them (see MOOD_GUIDE.md).
362const TOOL_SCENES: Record<string, string[]> = {
363  read: ['Read', 'ReadMcpResourceTool', 'ReadMcpResourceDirTool', 'ListMcpResourcesTool', 'LSP', 'WebFetch', 'FetchInboxMessage'],
364  lens: ['WebSearch', 'ToolSearch', 'SearchSkills', 'SearchPlugins', 'SearchMcpRegistry', 'ListSkills', 'ListPlugins', 'ListAgents', 'ListConnectors', 'SuggestSkills', 'SuggestPluginInstall', 'SuggestConnectors'],
365  idea: ['Skill'],
366  type: ['Edit', 'Write', 'NotebookEdit', 'Monitor'],
367  // a subagent or a workflow runs: the robot hands work around
368  juggle: ['Agent', 'Workflow'],
369  plan: ['TodoWrite', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskUpdate', 'GetTask', 'EnterPlanMode', 'ExitPlanMode', 'ProposeGoal'],
370  ask: ['AskUserQuestion'],
371  clock: ['CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup', 'RemoteTrigger', 'Poll'],
372  send: ['SendFile', 'SendUserFile', 'SendUserMessage', 'SendMessage', 'PublishPlugin', 'ShareOnboardingGuide', 'SendFeedback', 'ReportFindings'],
373  bell: ['PushNotification', 'ReadNotifications'],
374  paint: ['Artifact', 'ArtifactCheck', 'ArtifactComments', 'ArtifactData', 'DesignSync', 'ClaudeDesign', 'AppifactRepl'],
375  door: ['EnterWorktree', 'ExitWorktree'],
376}
377
378const SCENE_OF_TOOL = new Map(Object.entries(TOOL_SCENES).flatMap(([scene, tools]) => tools.map(tool => [tool, scene] as const)))
379
380const TEST_COMMAND = /(pytest|jest|vitest|mocha|npm (run )?test|yarn test|pnpm test|go test|cargo test|plugin test|unittest)/
381const TEST_SCENES = ['checklist', 'stamp']
382
383export const isTestCommand = (command: string) => TEST_COMMAND.test(command)
384
385// Bash is shown as what the command really does, first match wins. Anything else is
386// plain typing of a command.
387const COMMAND_SCENES: Array<[scene: string, pattern: RegExp]> = [
388  ['git', /\bgit\b/],
389  ['send', /\b(curl|wget|http|httpie|nc|ping|dig|nslookup|ssh|scp)\b/],
390  ['lens', /\b(grep|rg|ag|ack|find|fd|locate)\b/],
391  ['read', /\b(cat|head|tail|less|more|ls|tree|stat|wc|file|du|df|pwd)\b/],
392  ['run', /\b(make|npm|pnpm|yarn|docker|kubectl|helm|terraform|pip|pip3|brew|cargo|python3?|node|bun|uv)\b/],
393]
394
395export function sceneForCommand(command: string, random: () => number = Math.random): string {
396  if (isTestCommand(command)) {
397    return TEST_SCENES[Math.floor(random() * TEST_SCENES.length)]!
398  }
399
400  return COMMAND_SCENES.find(([, pattern]) => pattern.test(command))?.[0] ?? 'type'
401}
402
403// The scene of one running tool; any tool outside the table (an MCP tool, a new built-in)
404// gets the general scene.
405export const sceneForTool = (tool: string, command = '', random: () => number = Math.random): string =>
406  tool === 'Bash' ? sceneForCommand(command, random) : (SCENE_OF_TOOL.get(tool) ?? 'thinking')
407
408type PromptEdit = { text: string; start: number; end: number; inputText: string }
409
410export const draftAfter = (e: PromptEdit) => e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end)
411
412// Rest scene for the moment: night cap after 22:00, coffee on Monday, party hat on Friday.
413export function restForDate(date: Date): string {
414  const hour = date.getHours()
415
416  if (hour >= 22 || hour < 6) {
417    return 'night'
418  }
419
420  return date.getDay() === 1 ? 'monday' : date.getDay() === 5 ? 'friday' : 'idle'
421}
422
hooks/scenes.ts 340 lines
1// Scene table: every mascot animation as data. Loops run until replaced; `once`
2// scenes play a single time and the client then falls back to the rest scene.
3import { f, type Anim, type Pose } from './sprites.ts'
4
5const calm = (extra: Pose = {}) => [
6  f(2600, extra),
7  f(90, { ...extra, eyes: 'blink' }),
8  f(180, extra),
9  f(90, { ...extra, eyes: 'blink' }),
10  f(3400, extra),
11  f(90, { ...extra, eyes: 'blink' }),
12]
13
14// Peeking over a fence while the person types: the pupils sweep left, center, right and
15// back at a steady pace (no restarts, so no jumps), with an occasional blink.
16const peekFrames = () => {
17  const base: Pose = { nod: 1, mouth: 'flat', antennaUp: true }
18  const fence = (fingers: 'fenceHandA' | 'fenceHandB'): Array<[string, number, number]> => [['fence', 0, 9], [fingers, 2, 8], [fingers, 17, 8]]
19
20  return [
21    f(650, { ...base, eyes: 'peekL', props: fence('fenceHandA') }),
22    f(500, { ...base, eyes: 'peek', props: fence('fenceHandA') }),
23    f(650, { ...base, eyes: 'peekR', props: fence('fenceHandA') }),
24    f(500, { ...base, eyes: 'peek', props: fence('fenceHandA') }),
25    f(120, { ...base, eyes: 'blink', props: fence('fenceHandB') }),
26  ]
27}
28
29// ball positions of the juggling circle above the head
30const CIRCLE: Array<[number, number]> = [[8, 1], [10, 0], [13, 0], [15, 1], [13, 2], [10, 2]]
31
32// Reactions to the end of an answer play twice, so they are not missed.
33const REACTION_REPEATS = 2
34
35// A page of the book swinging from the right stack over the spine to the left one.
36const TURN = ['bookPage15', 'bookPage50', 'bookPage90', 'bookPage130', 'bookPage165']
37const readSpread = () =>
38  (['left', 'left', 'right', 'right'] as const).map((eyes, i) => f(i % 2 ? 300 : 450, { eyes, mouth: 'flat', props: [['book', 5, 4]] }))
39
40// Two columns of binary digits drift up beside the robot while a program runs. Each column
41// repeats a pattern of digits, one every BIT_STEP rows; one loop moves it by a full pattern.
42const BIT_STEP = 6
43const BIT_COLUMNS: Array<[x: number, digits: string[]]> = [[1, ['bit1', 'bit0', 'bit0']], [20, ['bit0', 'bit1', 'bit1']]]
44const bitsAt = (offset: number) =>
45  BIT_COLUMNS.flatMap(([x, digits]) =>
46    Array.from({ length: digits.length + 2 }, (_, i) => [digits[i % digits.length]!, x, i * BIT_STEP - offset] as [string, number, number]),
47  )
48
49export const ANIMS: Record<string, Anim> = {
50  // rest scenes
51  idle: { frames: calm() },
52  night: { frames: calm({ on: [['cap', 2, 0]], props: [['mug', 19, 9], ['steam0', 20, 7]] }) },
53  monday: { frames: calm({ props: [['mug', 19, 9], ['steam1', 20, 7]] }) },
54  friday: { frames: calm({ on: [['party', 2, 0]] }) },
55  // Standby, like a sleeping laptop: the face screen is off (faint dashes for eyes, no
56  // mouth), the antenna is dim, and the chest light pulses slowly as the robot breathes.
57  // The white z's rise while the light is brightest: a small one low, then a big one.
58  sleep: {
59    frames: (
60      [
61        [1000, 'ledDim', []],
62        [700, 'ledHalf', []],
63        [900, '', [['zs', 20, 7]]],
64        [1200, '', [['zs', 20, 7], ['z', 21, 1]]],
65        [700, 'ledHalf', [['z', 21, 1]]],
66        [700, 'ledDim', []],
67      ] as Array<[number, string, Array<[string, number, number]>]>
68    ).map(([ms, led, props]) => f(ms, { eyes: 'off', mouth: 'none', antenna: 'g', on: led ? [[led, 6, 9]] : [], props })),
69  },
70
71  // the person is typing: head tipped forward over a fence, eyes on the prompt box below
72  peek: { frames: peekFrames() },
73
74  // work states while the agent runs
75  thinking: {
76    frames: [0, 1, 2, 3, 3].map((dots, i) =>
77      f(350, { eyes: 'up', mouth: 'flat', antenna: i % 2 ? 'g' : 'a', dy: i === 3 ? -1 : 0, props: [[`cloud${dots}`, 19, 0]] }),
78    ),
79  },
80  lens: {
81    frames: [3, 4, 5, 4, 3].map((y, i) => f(300, { eyes: 'right', mouth: 'flat', antenna: i % 2 ? 'g' : 'a', props: [['lens', 18, y]] })).concat(f(300, { eyes: 'up', mouth: 'o', props: [['lens', 18, 3]] })),
82  },
83  // reading an open book lying in front, seen from the side: the eyes run over the left
84  // stack, then the right one, and a page swings over the spine from right to left
85  read: {
86    frames: [...readSpread(), ...TURN.map(prop => f(150, { eyes: 'up', mouth: 'flat', props: [[prop, 5, 4]] }))],
87  },
88  // Hands hammer the keys one after the other; the keyboard sits on the robot's lap
89  // (canvas rows 10-11) with the pressing hand drawn on its top row.
90  type: {
91    frames: [0, 1, 0, 1, 1, 0].map((k, i) =>
92      f(140, { eyes: 'down', mouth: 'flat', antenna: i % 3 ? 'a' : 'g', props: [[k ? 'keysB' : 'keysA', 7, 10], ['hand', k ? 14 : 8, 10]] }),
93    ),
94  },
95  // a program runs: zeros and ones stream up on both sides, the antenna blinks
96  run: {
97    frames: Array.from({ length: BIT_STEP * 3 }, (_, offset) =>
98      f(110, { eyes: 'up', mouth: 'flat', antenna: offset % 4 < 2 ? 'a' : 'g', props: bitsAt(offset) }),
99    ),
100  },
101  juggle: {
102    frames: CIRCLE.map((_, i) =>
103      f(160, { eyes: 'up', mouth: 'open', props: [['ballY', ...CIRCLE[i]!], ['ballP', ...CIRCLE[(i + 2) % 6]!], ['ballB', ...CIRCLE[(i + 4) % 6]!]] }),
104    ),
105  },
106
107  // asking the person for a decision or a permission: a hand up and a question mark
108  ask: {
109    frames: [0, 1].flatMap(() => [
110      f(450, { eyes: 'up', mouth: 'o', wave: 'A', props: [['q', 0, 0]] }),
111      f(450, { eyes: 'up', mouth: 'o', wave: 'B', props: [['q', 0, 0]] }),
112    ]),
113  },
114  clock: {
115    frames: [0, 1, 2, 3].map(hand => f(500, { eyes: 'right', mouth: 'flat', antenna: hand % 2 ? 'g' : 'a', props: [[`clock${hand}`, 19, 1]] })),
116  },
117  // a letter crosses in front of the robot and flies out of the frame to the right
118  send: {
119    frames: [7, 11, 15, 19, 23]
120      .map(x => f(220, { eyes: 'right', mouth: 'smile', props: x >= 15 ? [['speed', x - 5, 7], ['envelope', x, 6]] : [['envelope', x, 6]] }))
121      .concat(f(500, { eyes: 'right', mouth: 'smile' })),
122  },
123  bell: {
124    frames: [19, 20, 19, 20, 19].map(bx => f(150, { eyes: 'wide', mouth: 'o', props: [['bell', bx, 1]] })).concat(f(500, { eyes: 'wide', mouth: 'o', props: [['bell', 19, 1]] })),
125  },
126  // a raised mitten paints a small canvas, one color after another
127  paint: {
128    frames: [0, 1, 2, 3].flatMap(n => [
129      f(300, { eyes: 'right', mouth: 'smile', wave: 'A', props: [[`canvas${n}`, 19, 0]] }),
130      f(300, { eyes: 'right', mouth: 'smile', wave: 'B', props: [[`canvas${n}`, 19, 0]] }),
131    ]),
132  },
133  // stepping through a door into another worktree
134  door: {
135    frames: [f(600, { eyes: 'right', mouth: 'smile', props: [['door0', 20, 3]] }), f(500, { eyes: 'right', mouth: 'smile', props: [['door1', 20, 3]] }), f(900, { eyes: 'right', mouth: 'open', props: [['door2', 20, 3]] })],
136  },
137  // a new skill: the bulb lights up
138  idea: {
139    frames: [f(450, { eyes: 'up', mouth: 'flat', props: [['bulbOff', 19, 0]] }), f(250, { eyes: 'wide', mouth: 'o', antenna: 'y', props: [['bulbOn', 19, 0], ['spark', 18, 5]] }), f(800, { eyes: 'wide', mouth: 'smile', props: [['bulbOn', 19, 0]] })],
140  },
141
142  // planning: a board fills up step by step
143  plan: { frames: [0, 1, 2, 3, 4, 5, 6, 6].map(n => f(350, { eyes: 'up', mouth: 'flat', props: [[`board${n}`, 19, 1]] })) },
144  // Two ways to show "testing": a test run picks one at random (see mood.ts).
145  // a clipboard whose items are ticked off one by one
146  checklist: {
147    frames: [0, 1, 2, 3].map(n => f(450, { eyes: n % 2 ? 'down' : 'right', mouth: 'flat', antenna: n === 3 ? 'y' : 'a', props: [[`clip${n}`, 19, 1]] })),
148  },
149  // a rubber stamp comes down on a sheet of paper and leaves a red mark
150  stamp: {
151    frames: [
152      f(450, { eyes: 'down', mouth: 'flat', props: [['paper', 20, 8], ['stamp', 21, 4]] }),
153      f(300, { eyes: 'down', mouth: 'flat', props: [['paper', 20, 8], ['stamp', 21, 6]] }),
154      f(450, { eyes: 'down', mouth: 'smile', props: [['paperOk', 20, 8], ['stamp', 21, 4]] }),
155      f(600, { eyes: 'happy', mouth: 'smile', props: [['paperOk', 20, 8], ['stamp', 21, 4]] }),
156    ],
157  },
158  git: {
159    frames: [[], [['blkE', 19, 10]], [['blkE', 19, 10], ['blkB', 21, 10]], [['blkE', 19, 10], ['blkB', 21, 10], ['blkO', 20, 8]]].map(props => f(450, { eyes: 'down', mouth: 'flat', props: props as Array<[string, number, number]> })),
160  },
161  // one-shot reactions
162  // sorry and owning it: worried brows and a slow bow
163  contrite: {
164    once: true,
165    repeat: REACTION_REPEATS,
166    // a slow apologetic bow: worried eyes, the head drops and looks down, the antenna dims
167    frames: [
168      f(400, { eyes: 'sad', mouth: 'frown' }),
169      f(400, { eyes: 'sad', mouth: 'frown', nod: 1, antenna: 'g' }),
170      f(600, { eyes: 'down', mouth: 'frown', nod: 1, antenna: 'g' }),
171      f(300, { eyes: 'blink', mouth: 'frown', nod: 1, antenna: 'g' }),
172      f(500, { eyes: 'sad', mouth: 'frown', antenna: 'g' }),
173    ],
174  },
175  // pleased and a little bashful: blushing cheeks, a heart floating up
176  shy: {
177    once: true,
178    repeat: REACTION_REPEATS,
179    frames: [
180      f(400, { eyes: 'happy', mouth: 'smile', on: [['blush', 2, 5], ['blush', 11, 5]] }),
181      f(400, { eyes: 'happy', mouth: 'smile', tilt: 1, on: [['blush', 2, 5], ['blush', 11, 5]], props: [['heart', 19, 1]] }),
182      f(400, { eyes: 'happy', mouth: 'smile', tilt: 1, on: [['blush', 2, 5], ['blush', 11, 5]], props: [['heart', 19, 0]] }),
183      f(400, { eyes: 'happy', mouth: 'smile', on: [['blush', 2, 5], ['blush', 11, 5]] }),
184    ],
185  },
186  happy: {
187    once: true,
188    repeat: REACTION_REPEATS,
189    frames: [
190      f(350, { dy: -1, eyes: 'happy' }),
191      f(250, { eyes: 'happy' }),
192      f(200, { eyes: 'happy', wave: 'A' }),
193      f(200, { eyes: 'happy', wave: 'B' }),
194      f(200, { eyes: 'happy', wave: 'A' }),
195      f(200, { eyes: 'happy', wave: 'B' }),
196      f(300, { eyes: 'happy' }),
197    ],
198  },
199  // a plain "no": only the head shakes left and right, the body stays (the answer is no,
200  // or "I do not remember")
201  no: {
202    once: true,
203    repeat: REACTION_REPEATS,
204    frames: [
205      f(150, { lean: -1, eyes: 'left', mouth: 'flat' }),
206      f(120, { mouth: 'flat' }),
207      f(150, { lean: 1, eyes: 'right', mouth: 'flat' }),
208      f(120, { mouth: 'flat' }),
209      f(150, { lean: -1, eyes: 'left', mouth: 'flat' }),
210      f(120, { mouth: 'flat' }),
211      f(150, { lean: 1, eyes: 'right', mouth: 'flat' }),
212      f(500, { mouth: 'flat' }),
213    ],
214  },
215  // exhausted after a long turn: a long exhale rising as a puff while the head sinks
216  ufff: {
217    once: true,
218    frames: [
219      f(350, { eyes: 'blink', mouth: 'flat' }),
220      f(350, { eyes: 'blink', mouth: 'flat' }),
221      f(350, { eyes: 'blink', mouth: 'o', nod: 1, props: [['puff', 0, 5]] }),
222      f(350, { eyes: 'blink', mouth: 'o', nod: 1, props: [['puff', 0, 4]] }),
223      f(400, { eyes: 'blink', mouth: 'o', nod: 1, props: [['puff', 0, 3]] }),
224      f(500, { eyes: 'blink', mouth: 'o', nod: 1, props: [['puff', 0, 2]] }),
225    ],
226  },
227  // a plain "yes": two nods, then a thumb up
228  yes: {
229    once: true,
230    repeat: REACTION_REPEATS,
231    frames: [
232      f(180, { eyes: 'happy', mouth: 'smile' }),
233      f(220, { eyes: 'happy', mouth: 'smile', nod: 1 }),
234      f(180, { eyes: 'happy', mouth: 'smile' }),
235      f(220, { eyes: 'happy', mouth: 'smile', nod: 1 }),
236      f(700, { eyes: 'happy', mouth: 'smile', holding: true, props: [['thumb', 19, 6]] }),
237    ],
238  },
239  // praising the person (a good catch, a clever idea): star eyes, hands up, sparkles
240  praise: {
241    once: true,
242    repeat: REACTION_REPEATS,
243    frames: [
244      f(300, { eyes: 'star', mouth: 'open', wave: 'both', props: [['star', 19, 0], ['star', 1, 0]] }),
245      f(300, { eyes: 'star', mouth: 'open', wave: 'both', dy: -1, props: [['spark', 20, 1], ['spark', 0, 1]] }),
246      f(300, { eyes: 'star', mouth: 'open', wave: 'both', props: [['spark', 19, 0], ['spark', 1, 0]] }),
247      f(500, { eyes: 'star', mouth: 'smile', props: [['star', 19, 1], ['star', 1, 1]] }),
248    ],
249  },
250  // something important happened: a stop sign and a jolt
251  stop: {
252    once: true,
253    repeat: REACTION_REPEATS,
254    frames: [
255      f(200, { eyes: 'wide', mouth: 'grim', dy: -1, antenna: 'y', props: [['stopSign', 19, 0]] }),
256      f(400, { eyes: 'wide', mouth: 'grim', props: [['stopSign', 19, 0]] }),
257      f(200, { eyes: 'wide', mouth: 'grim', antenna: 'y', props: [['stopSign', 19, 0]] }),
258      f(600, { eyes: 'wide', mouth: 'grim', props: [['stopSign', 19, 0]] }),
259    ],
260  },
261  // "so-so": the face weighs it up, one eye squinting and the mouth slanting, side to side,
262  // with a wiggling "~" in a speech bubble
263  soso: {
264    once: true,
265    repeat: REACTION_REPEATS,
266    frames: [0, 1, 0, 1, 0, 1]
267      .map(i => f(300, i ? { eyes: 'weighR', mouth: 'skewR', props: [['wave1', 19, 0]] } : { eyes: 'weighL', mouth: 'skewL', props: [['wave0', 19, 0]] }))
268      .concat(f(400, { mouth: 'flat' })),
269  },
270  hello: {
271    once: true,
272    repeat: REACTION_REPEATS,
273    frames: [0, 1, 0, 1, 0, 1].map((w, i) => f(200, { eyes: 'happy', wave: w ? 'B' : 'A', lean: i % 2 })).concat(f(300, { eyes: 'happy' })),
274  },
275  // relieved: both open mittens out, a calm smile ("no problem")
276  relieved: {
277    once: true,
278    repeat: REACTION_REPEATS,
279    frames: [
280      f(400, { eyes: 'blink', mouth: 'smile', wave: 'both' }),
281      f(500, { eyes: 'happy', mouth: 'smile', wave: 'both', props: [['star', 19, 0], ['star', 1, 0]] }),
282      f(500, { eyes: 'happy', mouth: 'smile', wave: 'both', props: [['star', 20, 1], ['star', 0, 1]] }),
283      f(300, { eyes: 'happy', mouth: 'smile' }),
284    ],
285  },
286  shrug: {
287    once: true,
288    repeat: REACTION_REPEATS,
289    frames: [
290      f(350, { eyes: 'left', mouth: 'flat', shrug: true }),
291      f(350, { eyes: 'right', mouth: 'flat', shrug: true }),
292      f(350, { eyes: 'left', mouth: 'flat', shrug: true }),
293      f(300, { mouth: 'flat' }),
294    ],
295  },
296  // Claude needs an answer before it goes on: it turns to the person and a speech bubble
297  // with a question mark pops up (no raised hand: that is ask, the dialog)
298  ponder: {
299    once: true,
300    repeat: REACTION_REPEATS,
301    frames: [
302      f(300, { eyes: 'up', mouth: 'flat' }),
303      f(300, { eyes: 'right', mouth: 'o', props: [['askBubble', 19, 0]] }),
304      f(300, { eyes: 'right', mouth: 'flat', props: [['askBubbleDim', 19, 0]] }),
305      f(300, { eyes: 'open', mouth: 'flat', props: [['askBubble', 19, 0]] }),
306      f(300, { eyes: 'open', mouth: 'flat', props: [['askBubbleDim', 19, 0]] }),
307    ],
308  },
309  surprised: {
310    once: true,
311    repeat: REACTION_REPEATS,
312    frames: [f(140, { dy: -1, eyes: 'wide', mouth: 'o', props: [['ex', 19, 1]] }), f(900, { eyes: 'wide', mouth: 'o', props: [['ex', 19, 1]] })],
313  },
314  cheer: {
315    once: true,
316    repeat: REACTION_REPEATS,
317    frames: [
318      f(200, { dy: -1, eyes: 'happy', mouth: 'open', wave: 'both', props: [['spark', 19, 2], ['spark', 2, 3]] }),
319      f(200, { eyes: 'happy', mouth: 'open', wave: 'both', props: [['star', 19, 3], ['star', 2, 2]] }),
320      f(200, { dy: -1, eyes: 'happy', mouth: 'open', wave: 'both', props: [['spark', 19, 3], ['spark', 2, 2]] }),
321      f(400, { eyes: 'happy', props: [['star', 19, 2], ['star', 2, 3]] }),
322    ],
323  },
324  oops: {
325    once: true,
326    repeat: REACTION_REPEATS,
327    frames: [
328      f(300, { eyes: 'x', mouth: 'grim', tone: 'r' }),
329      f(300, { eyes: 'x', mouth: 'grim', tone: 'r', lean: -1 }),
330      f(500, { eyes: 'wide', mouth: 'grim', lean: -1 }),
331    ],
332  },
333}
334
335export const SCENES = Object.keys(ANIMS)
336// How long a scene plays in full, repeats included.
337export const durationOfScene = (scene: string) => (ANIMS[scene]?.frames.reduce((sum, frame) => sum + frame.ms, 0) ?? 0) * (ANIMS[scene]?.repeat ?? 1)
338
339export const isOnce = (scene: string) => ANIMS[scene]?.once === true
340
hooks/sayings.ts 370 lines
1// A short line the robot says in a speech bubble, so a scene is not left to guesswork: how
2// it feels while an emotion plays, and what it does while a tool runs. Rest scenes and the
3// minis stay silent.
4import type { Span } from './sprites.ts'
5
6// The languages with built-in lines; `auto` picks one of them after the person's prompt.
7export const LANGS = ['pl', 'en', 'de', 'es', 'fr', 'it', 'pt', 'uk', 'ru', 'ja', 'zh', 'ko'] as const
8export type Lang = (typeof LANGS)[number]
9
10// The names a built-in language answers to in /pixpal language, besides its code.
11const LANG_NAMES: Record<Lang, string[]> = {
12  pl: ['polish', 'polski'],
13  en: ['english'],
14  de: ['german', 'deutsch'],
15  es: ['spanish', 'español', 'espanol'],
16  fr: ['french', 'français', 'francais'],
17  it: ['italian', 'italiano'],
18  pt: ['portuguese', 'português', 'portugues'],
19  uk: ['ukrainian', 'українська'],
20  ru: ['russian', 'русский'],
21  ja: ['japanese', '日本語'],
22  zh: ['chinese', '中文'],
23  ko: ['korean', '한국어'],
24}
25
26// Each built-in language under its own name, as the language picker lists it.
27export const LANG_LABELS: Record<Lang, string> = {
28  pl: 'Polski',
29  en: 'English',
30  de: 'Deutsch',
31  es: 'Español',
32  fr: 'Français',
33  it: 'Italiano',
34  pt: 'Português',
35  uk: 'Українська',
36  ru: 'Русский',
37  ja: '日本語',
38  zh: '中文',
39  ko: '한국어',
40}
41
42// Every line fits in 16 terminal columns once "…" is spelled as three dots (BUBBLE_W).
43export const SAYINGS: Record<string, Record<Lang, string>> = {
44  happy: { pl: 'Gotowe!', en: 'Done!', de: 'Fertig!', es: '¡Listo!', fr: 'Fini !', it: 'Fatto!', pt: 'Pronto!', uk: 'Готово!', ru: 'Готово!', ja: 'できた!', zh: '完成!', ko: '완료!' },
45  cheer: { pl: 'Jupii!', en: 'Yay!', de: 'Juhu!', es: '¡Yupi!', fr: 'Youpi !', it: 'Evviva!', pt: 'Eba!', uk: 'Ура!', ru: 'Ура!', ja: 'やった!', zh: '耶!', ko: '야호!' },
46  yes: { pl: 'Tak!', en: 'Yes!', de: 'Ja!', es: '¡Sí!', fr: 'Oui !', it: 'Sì!', pt: 'Sim!', uk: 'Так!', ru: 'Да!', ja: 'はい!', zh: '是的!', ko: '네!' },
47  no: { pl: 'Nie…', en: 'Nope', de: 'Nö…', es: 'Nop…', fr: 'Non…', it: 'No…', pt: 'Não…', uk: 'Ні…', ru: 'Нет…', ja: 'いいえ…', zh: '不行…', ko: '아니…' },
48  praise: { pl: 'Brawo!', en: 'Nice!', de: 'Super!', es: '¡Genial!', fr: 'Bravo !', it: 'Bravo!', pt: 'Boa!', uk: 'Клас!', ru: 'Класс!', ja: 'いいね!', zh: '真棒!', ko: '멋져!' },
49  soso: { pl: 'Tak sobie', en: 'Sort of', de: 'Naja', es: 'Más o menos', fr: 'Bof', it: 'Così così', pt: 'Mais ou menos', uk: 'Так собі', ru: 'Так себе', ja: 'まあまあ', zh: '还行', ko: '그럭저럭' },
50  shrug: { pl: 'Hmm…', en: 'Dunno', de: 'Keine Ahnung', es: 'Ni idea', fr: 'Aucune idée', it: 'Boh', pt: 'Sei lá', uk: 'Хтозна', ru: 'Не знаю', ja: 'さあ…', zh: '不知道', ko: '글쎄…' },
51  ponder: { pl: 'I jak?', en: 'Well?', de: 'Und?', es: '¿Y bien?', fr: 'Alors ?', it: 'E allora?', pt: 'E aí?', uk: 'І як?', ru: 'И как?', ja: 'で?', zh: '然后呢?', ko: '그래서?' },
52  stop: { pl: 'Stój!', en: 'Wait!', de: 'Halt!', es: '¡Espera!', fr: 'Attends !', it: 'Aspetta!', pt: 'Espera!', uk: 'Стій!', ru: 'Стой!', ja: '待って!', zh: '等等!', ko: '잠깐!' },
53  contrite: { pl: 'Sorki…', en: 'Oops, sorry', de: 'Sorry…', es: 'Perdón…', fr: 'Pardon…', it: 'Scusa…', pt: 'Desculpa…', uk: 'Вибач…', ru: 'Прости…', ja: 'ごめん…', zh: '抱歉…', ko: '미안…' },
54  shy: { pl: 'Och, dzięki', en: 'Aw, thanks', de: 'Oh, danke', es: 'Ay, gracias', fr: 'Oh, merci', it: 'Oh, grazie', pt: 'Ah, valeu', uk: 'Ой, дякую', ru: 'Ой, спасибо', ja: 'えへへ', zh: '谢谢啦', ko: '고마워' },
55  relieved: { pl: 'Luzik', en: 'No worries', de: 'Kein Stress', es: 'Tranqui', fr: 'Tranquille', it: 'Tranquillo', pt: 'Tranquilo', uk: 'Спокійно', ru: 'Спокойно', ja: '大丈夫', zh: '没事', ko: '괜찮아' },
56  oops: { pl: 'Ups!', en: 'Oops!', de: 'Hoppla!', es: '¡Uy!', fr: 'Oups !', it: 'Ops!', pt: 'Opa!', uk: 'Ой!', ru: 'Ой!', ja: 'おっと!', zh: '哎呀!', ko: '앗!' },
57  surprised: { pl: 'Ojej!', en: 'Whoa!', de: 'Wow!', es: '¡Guau!', fr: 'Oh là là !', it: 'Wow!', pt: 'Uau!', uk: 'Овва!', ru: 'Ого!', ja: 'わあ!', zh: '哇!', ko: '와!' },
58  hello: { pl: 'Cześć!', en: 'Hi!', de: 'Hallo!', es: '¡Hola!', fr: 'Salut !', it: 'Ciao!', pt: 'Oi!', uk: 'Привіт!', ru: 'Привет!', ja: 'やあ!', zh: '你好!', ko: '안녕!' },
59  ufff: { pl: 'Uff…', en: 'Phew…', de: 'Puh…', es: 'Uf…', fr: 'Ouf…', it: 'Uff…', pt: 'Ufa…', uk: 'Фух…', ru: 'Фух…', ja: 'ふう…', zh: '呼…', ko: '휴…' },
60}
61
62// Latin-script languages: letters only that language uses, and its most common short words.
63// English comes first, so a tie (or nothing found) stays English.
64const LATIN: Array<[Lang, RegExp | undefined, Set<string>]> = [
65  ['en', undefined, new Set(['the', 'is', 'are', 'and', 'to', 'it', 'this', 'that', 'please', 'what', 'you', 'of', 'for', 'with', 'can', 'why', 'how'])],
66  ['pl', /[ąćęłńśźż]/i, new Set(['nie', 'tak', 'jest', 'czy', 'się', 'jak', 'co', 'to', 'już', 'może', 'dzięki', 'zrób', 'proszę', 'dlaczego'])],
67  ['de', /[äöüß]/i, new Set(['und', 'ist', 'nicht', 'das', 'die', 'der', 'ich', 'bitte', 'mit', 'warum', 'wie', 'ein', 'eine', 'es'])],
68  ['es', /[ñ¿¡]/i, new Set(['el', 'la', 'los', 'las', 'que', 'es', 'por', 'para', 'una', 'con', 'qué', 'cómo', 'gracias', 'está', 'hazlo'])],
69  ['fr', /[œëîïûù]/i, new Set(['le', 'la', 'les', 'est', 'et', 'je', 'pas', 'une', 'des', 'pour', 'avec', 'ça', 'merci', 'pourquoi', 'ne'])],
70  ['it', /[ìò]/i, new Set(['il', 'che', 'è', 'non', 'per', 'una', 'sono', 'con', 'grazie', 'perché', 'cosa', 'gli', 'della'])],
71  ['pt', /[ãõ]/i, new Set(['o', 'os', 'que', 'não', 'é', 'um', 'uma', 'para', 'com', 'obrigado', 'você', 'isso', 'está'])],
72]
73
74// Ukrainian words Russian does not have, for a line without і, ї, є or ґ.
75const UKRAINIAN_WORDS = /(?<!\p{L})(це|цю|що|як|чому|ласка)(?!\p{L})/iu
76
77// The person's language, from the message they sent: the robot answers in kind. The script
78// tells Korean, Japanese, Chinese and Cyrillic at once; a Latin language scores its letters
79// and words.
80export function langOf(text: string): Lang {
81  if (/[ᄀ-ᇿ㄰-㆏가-힣]/.test(text)) {
82    return 'ko'
83  }
84
85  if (/[぀-ヿ]/.test(text)) {
86    return 'ja'
87  }
88
89  if (/[一-鿿]/.test(text)) {
90    return 'zh'
91  }
92
93  if (/[Ѐ-ӿ]/.test(text)) {
94    return /[іїєґ]/i.test(text) || UKRAINIAN_WORDS.test(text) ? 'uk' : 'ru'
95  }
96
97  const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
98  let best: Lang = 'en'
99  let top = 0
100
101  for (const [lang, letters, common] of LATIN) {
102    const score = (letters?.test(text) ? 3 : 0) + words.filter(word => common.has(word)).length
103
104    if (score > top) {
105      best = lang
106      top = score
107    }
108  }
109
110  return best
111}
112
113// The bubble fades from white at the top to a cool light gray at the bottom, one color per
114// half-row; the text row sits in the middle shade.
115const SHADES = ['#ffffff', '#ffffff', '#f5f7fa', '#f5f7fa', '#e6ebf0']
116const INK = '#0f1b2d'
117const SHADOW = '#5c6370'
118
119
120// The line types itself out, one letter every TYPE_MS (hidden letters are spaces, so the
121// bubble keeps its size). A fade by color was tried and dropped: terminals with a minimum
122// contrast setting (iTerm2) lift a faint letter to a readable gray at once.
123export const TYPE_MS = 45
124// A line that ends in "…" ends in three dots instead, which keep moving once it is typed:
125// "xyz...", "xyz", "xyz.", "xyz..", "xyz..." and so on, one step every DOT_MS.
126export const DOT_MS = 350
127const DOT_STEPS = [3, 0, 1, 2]
128const PAD_CHARS = 2
129
130// The text as the bubble writes it: "…" becomes three separate dots.
131const spelled = (text: string) => (text.endsWith('…') ? `${text.slice(0, -1)}...` : text)
132
133// Terminal columns a character takes: two for East Asian wide letters and emoji, one for
134// the rest. The bubble counts columns, not letters, so its shadow lines up in any script.
135const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
136export const cellWidth = (text: string) => [...text].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
137
138// What the bubble shows `elapsed` ms after the line appeared, always as wide as the full
139// line: first the letters typed so far, then (for a "…" line) the dots going round.
140export function shownText(text: string, elapsed: number): string {
141  const full = spelled(text)
142  const chars = [...full]
143  const blank = (rest: string[]) => rest.map(ch => ' '.repeat(cellWidth(ch))).join('')
144
145  // no clock (a still picture, the gallery): the whole line
146  if (!Number.isFinite(elapsed)) {
147    return full
148  }
149
150  const typed = Math.floor(Math.max(0, elapsed) / TYPE_MS) + 1
151
152  if (typed < chars.length) {
153    return chars.slice(0, typed).join('') + blank(chars.slice(typed))
154  }
155
156  if (full === text) {
157    return full
158  }
159
160  const step = Math.floor((elapsed - (chars.length - 1) * TYPE_MS) / DOT_MS) % DOT_STEPS.length
161  const dots = DOT_STEPS[step]!
162
163  return chars.slice(0, -3).join('') + '.'.repeat(dots) + ' '.repeat(3 - dots)
164}
165
166type Cell = { ch: string; color?: string; bg?: string }
167
168const toCells = (row: Span[]): Cell[] => row.flatMap(s => [...s.text].map(ch => ({ ch, color: s.color, bg: s.bg })))
169
170function toSpans(cells: Cell[]): Span[] {
171  const spans: Span[] = []
172
173  for (const cell of cells) {
174    const last = spans.at(-1)
175
176    if (last && last.color === cell.color && last.bg === cell.bg) {
177      last.text += cell.ch
178    } else {
179      spans.push({ text: cell.ch, color: cell.color, bg: cell.bg })
180    }
181  }
182
183  return spans
184}
185
186// The colors of a cell's upper and lower half, as halfBlock drew them.
187function halves(cell: Cell): [string | undefined, string | undefined] {
188  switch (cell.ch) {
189    case '▀':
190      return [cell.color, cell.bg]
191    case '▄':
192      return [cell.bg, cell.color]
193    case '█':
194      return [cell.color, cell.color]
195    default:
196      return [cell.bg, cell.bg]
197  }
198}
199
200function fromHalves(top: string | undefined, bottom: string | undefined): Cell {
201  if (!top && !bottom) {
202    return { ch: ' ' }
203  }
204
205  if (top === bottom) {
206    return { ch: '█', color: top }
207  }
208
209  return !bottom ? { ch: '▀', color: top } : !top ? { ch: '▄', color: bottom } : { ch: '▀', color: top, bg: bottom }
210}
211
212// The bubble in half-rows (two per terminal row): column 0 is a gap from the picture,
213// columns 1..n the body, n+1 the shadow. W body, S shadow, . untouched. Half-row 0 is left
214// empty so the body's top lines up with the panel's (which starts one pixel down). The body
215// is a rounded box with one pixel above and below the text row (half-rows 2-3); the
216// shadow sits one column right and half a row down, filling the body's lower right corner
217// (a notch) with its own outer corner cut.
218function shape(n: number): string[] {
219  const round = `..${'W'.repeat(n - 2)}.`
220  const full = `.${'W'.repeat(n)}S`
221
222  return ['.'.repeat(n + 2), `${round}.`, full, full, `${round.slice(0, -1)}SS`, `...${'S'.repeat(n - 2)}.`]
223}
224
225// The terminal row of the bubble that holds the text, and how many rows the bubble spans.
226const TEXT_ROW = 1
227const BUBBLE_ROWS = 3
228
229// Lays the bubble saying `text` beside the band's rows, one column outside the picture: three
230// rows when the band has them (text in the second), else the text alone in a light strip on
231// the first row.
232export function withBubble(rows: Span[][], text: string, elapsed = Number.POSITIVE_INFINITY): Span[][] {
233  if (rows.length === 0) {
234    return rows
235  }
236
237  const pad = ' '.repeat(PAD_CHARS)
238  const body = `${pad}${shownText(text, elapsed)}${pad}`
239
240  if (rows.length < BUBBLE_ROWS) {
241    return [[...rows[0]!, { text: '  ' }, { text: body, color: INK, bg: SHADES[2] }], ...rows.slice(1)]
242  }
243
244  const grid = rows.map(toCells)
245  const x = Math.max(...grid.map(cells => cells.length))
246  // one cell per terminal column: a wide letter is followed by an empty cell
247  const bodyCells = [...body].flatMap(ch => (cellWidth(ch) === 2 ? [ch, ''] : [ch]))
248  const width = bodyCells.length
249  const half = shape(width)
250
251  return grid.map((cells, y) => {
252    if (y >= BUBBLE_ROWS) {
253      return toSpans(cells)
254    }
255
256    const out = [...cells]
257    const paint = (key: string, row: number) => (key === 'W' ? SHADES[Math.min(row, SHADES.length - 1)] : key === 'S' ? SHADOW : undefined)
258
259    for (let i = 0; i <= width + 1; i++) {
260      const [under0, under1] = halves(out[x + i] ?? { ch: ' ' })
261      const top = paint(half[2 * y]![i]!, 2 * y) ?? under0
262      const bottom = paint(half[2 * y + 1]![i]!, 2 * y + 1) ?? under1
263      const inText = y === TEXT_ROW && i >= 1 && i <= width
264
265      out[x + i] = inText ? { ch: bodyCells[i - 1]!, color: INK, bg: SHADES[2] } : fromHalves(top, bottom)
266    }
267
268    // a row shorter than the widest one has holes up to x, which map() would skip
269    return toSpans(Array.from(out, cell => cell ?? { ch: ' ' }))
270  })
271}
272
273// UNDER REVIEW: whether work scenes speak at all. false keeps the bubble for emotions only.
274// To remove the feature for good, drop WORK_SPEAKS and WORK_SAYINGS and let the Client,
275// BUBBLE_W and tools/gallery.ts use SAYINGS alone.
276export const WORK_SPEAKS = true
277
278// What the robot says while it works (phase 2): one plain "doing" line per work scene. A
279// line naming the call's target ("Czytam mood.ts") was tried and dropped: it changed too
280// fast to read.
281export const WORK_SAYINGS: Record<string, Record<Lang, string>> = {
282  thinking: { pl: 'Myślę…', en: 'Thinking…', de: 'Denke nach…', es: 'Pensando…', fr: 'Je réfléchis…', it: 'Penso…', pt: 'Pensando…', uk: 'Думаю…', ru: 'Думаю…', ja: '考え中…', zh: '思考中…', ko: '생각 중…' },
283  read: { pl: 'Czytam…', en: 'Reading…', de: 'Lese…', es: 'Leyendo…', fr: 'Je lis…', it: 'Leggo…', pt: 'Lendo…', uk: 'Читаю…', ru: 'Читаю…', ja: '読んでる…', zh: '阅读中…', ko: '읽는 중…' },
284  lens: { pl: 'Szukam…', en: 'Searching…', de: 'Suche…', es: 'Buscando…', fr: 'Je cherche…', it: 'Cerco…', pt: 'Buscando…', uk: 'Шукаю…', ru: 'Ищу…', ja: '探してる…', zh: '搜索中…', ko: '찾는 중…' },
285  type: { pl: 'Piszę…', en: 'Writing…', de: 'Schreibe…', es: 'Escribiendo…', fr: "J'écris…", it: 'Scrivo…', pt: 'Escrevendo…', uk: 'Пишу…', ru: 'Пишу…', ja: '書いてる…', zh: '编写中…', ko: '쓰는 중…' },
286  run: { pl: 'Odpalam…', en: 'Running…', de: 'Starte…', es: 'Ejecutando…', fr: 'Je lance…', it: 'Avvio…', pt: 'Executando…', uk: 'Запускаю…', ru: 'Запускаю…', ja: '実行中…', zh: '运行中…', ko: '실행 중…' },
287  checklist: { pl: 'Testuję…', en: 'Testing…', de: 'Teste…', es: 'Probando…', fr: 'Je teste…', it: 'Testo…', pt: 'Testando…', uk: 'Тестую…', ru: 'Тестирую…', ja: 'テスト中…', zh: '测试中…', ko: '테스트 중…' },
288  stamp: { pl: 'Testuję…', en: 'Testing…', de: 'Teste…', es: 'Probando…', fr: 'Je teste…', it: 'Testo…', pt: 'Testando…', uk: 'Тестую…', ru: 'Тестирую…', ja: 'テスト中…', zh: '测试中…', ko: '테스트 중…' },
289  git: { pl: 'Git…', en: 'Git…', de: 'Git…', es: 'Git…', fr: 'Git…', it: 'Git…', pt: 'Git…', uk: 'Git…', ru: 'Git…', ja: 'Git…', zh: 'Git…', ko: 'Git…' },
290  plan: { pl: 'Planuję…', en: 'Planning…', de: 'Plane…', es: 'Planeando…', fr: 'Je planifie…', it: 'Pianifico…', pt: 'Planejando…', uk: 'Планую…', ru: 'Планирую…', ja: '計画中…', zh: '规划中…', ko: '계획 중…' },
291  idea: { pl: 'Uczę się…', en: 'Learning…', de: 'Lerne…', es: 'Aprendiendo…', fr: "J'apprends…", it: 'Imparo…', pt: 'Aprendendo…', uk: 'Вчуся…', ru: 'Учусь…', ja: '学習中…', zh: '学习中…', ko: '배우는 중…' },
292  send: { pl: 'Wysyłam…', en: 'Sending…', de: 'Sende…', es: 'Enviando…', fr: "J'envoie…", it: 'Invio…', pt: 'Enviando…', uk: 'Надсилаю…', ru: 'Отправляю…', ja: '送信中…', zh: '发送中…', ko: '보내는 중…' },
293  ask: { pl: 'Pytanie!', en: 'Question!', de: 'Frage!', es: '¡Pregunta!', fr: 'Question !', it: 'Domanda!', pt: 'Pergunta!', uk: 'Питання!', ru: 'Вопрос!', ja: '質問!', zh: '提问!', ko: '질문!' },
294  clock: { pl: 'Ustawiam czas…', en: 'Scheduling…', de: 'Plane Zeit…', es: 'Programando…', fr: 'Je programme…', it: 'Programmo…', pt: 'Agendando…', uk: 'Ставлю таймер…', ru: 'Ставлю таймер…', ja: '予約中…', zh: '定时中…', ko: '예약 중…' },
295  bell: { pl: 'Dzwonię!', en: 'Ping!', de: 'Ping!', es: '¡Ping!', fr: 'Ding !', it: 'Din don!', pt: 'Plim!', uk: 'Дзень!', ru: 'Дзынь!', ja: 'ピンポーン!', zh: '叮!', ko: '딩동!' },
296  paint: { pl: 'Rysuję…', en: 'Drawing…', de: 'Zeichne…', es: 'Dibujando…', fr: 'Je dessine…', it: 'Disegno…', pt: 'Desenhando…', uk: 'Малюю…', ru: 'Рисую…', ja: '描いてる…', zh: '绘图中…', ko: '그리는 중…' },
297  door: { pl: 'Przechodzę…', en: 'Switching…', de: 'Wechsle…', es: 'Cambiando…', fr: 'Je change…', it: 'Cambio…', pt: 'Trocando…', uk: 'Перемикаю…', ru: 'Переключаю…', ja: '切り替え中…', zh: '切换中…', ko: '전환 중…' },
298  juggle: { pl: 'Deleguję…', en: 'Delegating…', de: 'Delegiere…', es: 'Delegando…', fr: 'Je délègue…', it: 'Delego…', pt: 'Delegando…', uk: 'Делегую…', ru: 'Делегирую…', ja: '任せてる…', zh: '委派中…', ko: '위임 중…' },
299}
300
301// The widest bubble any saying (emotion or work) needs past the picture, in terminal
302// columns: the gap column, two spaces of padding each side, the text, and the shadow column.
303export const bubbleWidthOf = (lines: string[]) => Math.max(0, ...lines.map(line => cellWidth(spelled(line)))) + 6
304export const BUBBLE_W = bubbleWidthOf([...Object.values(SAYINGS), ...Object.values(WORK_SAYINGS)].flatMap(s => Object.values(s)))
305
306// The built-in lines of a language named in /pixpal language ('german', 'Deutsch' or 'de'),
307// as a phrasebook by scene id; undefined for a language without built-in lines.
308export function builtInBook(name: string): Record<string, string> | undefined {
309  const key = name.trim().toLowerCase()
310  const lang = LANGS.find(code => code === key || LANG_NAMES[code].includes(key))
311
312  return lang && Object.fromEntries([...Object.entries(SAYINGS), ...Object.entries(WORK_SAYINGS)].map(([id, line]) => [id, line[lang]]))
313}
314
315// Any other language: the English lines are translated once by a small model and kept.
316// A translated line is cut to TRANSLATED_MAX columns and keeps the ellipsis of its source.
317const TRANSLATED_MAX = 16
318
319// Every line the robot can say, by scene id, in English: what a translation starts from.
320export const ENGLISH_LINES: Record<string, string> = Object.fromEntries(
321  [...Object.entries(SAYINGS), ...Object.entries(WORK_SAYINGS)].map(([id, line]) => [id, line.en]),
322)
323
324export const TRANSLATE_SYSTEM = `You translate the short lines a small robot says in a speech bubble. Answer with one JSON object only: the same keys, each value the line in the requested language.
325Rules: keep each line as short as the English (at most ${TRANSLATED_MAX} characters, fewer for wide scripts), natural and casual, like a friendly robot talking. Keep a final "…" where the English has one. No quotes inside values, no explanations.`
326
327export const buildTranslatePrompt = (language: string) => `Language: ${language}\n\n${JSON.stringify(ENGLISH_LINES, null, 1)}`
328
329// Cuts a line to `max` terminal columns, letter by letter.
330function clipCells(text: string, max: number): string {
331  let out = ''
332
333  for (const ch of text) {
334    if (cellWidth(out + ch) > max) {
335      break
336    }
337
338    out += ch
339  }
340
341  return out
342}
343
344// The model's reply as a phrasebook: every id present, each line short, an ellipsis kept
345// where the English has one. Lines it missed stay English. Undefined when there is no JSON.
346export function parseTranslation(reply: string): Record<string, string> | undefined {
347  const json = /\{[\s\S]*\}/.exec(reply)?.[0]
348  let parsed: Record<string, unknown>
349
350  try {
351    parsed = json ? (JSON.parse(json) as Record<string, unknown>) : {}
352  } catch {
353    return undefined
354  }
355
356  if (!json) {
357    return undefined
358  }
359
360  return Object.fromEntries(
361    Object.entries(ENGLISH_LINES).map(([id, english]) => {
362      const raw = typeof parsed[id] === 'string' ? (parsed[id] as string).trim().replace(/\.\.\.$/, '…') : ''
363      const trailing = english.endsWith('…')
364      const core = clipCells(raw.replace(/…$/, ''), TRANSLATED_MAX - (trailing ? 1 : 0))
365
366      return [id, core ? (trailing ? `${core}…` : core) : english]
367    }),
368  )
369}
370
hooks/sprites.ts 705 lines
1// Pixel-art robot mascot engine. A frame is 12 rows of 24 palette keys
2// ('.' = transparent); two pixel rows make one terminal row (see halfBlock).
3export const W = 24
4export const H = 12
5
6export const PALETTE: Record<string, string> = {
7  H: '#8fa3b8',
8  G: '#5e7088',
9  K: '#0f1b2d',
10  c: '#59f0ff',
11  r: '#ff5f6d',
12  m: '#6b7a8f',
13  T: '#3f4f66',
14  a: '#ffb703',
15  g: '#7a5c10',
16  w: '#22262f',
17  // the wheels (feet): a slate gray light enough to show on the dark panel
18  k: '#5a6478',
19  W: '#f4f7fa',
20  v: '#c3ccd6',
21  j: '#a9b4c2',
22  f: '#c8a27a',
23  F: '#9c7a54',
24  u: '#55555f',
25  p: '#ff7eb6',
26  y: '#ffd23f',
27  o: '#ff8c42',
28  b: '#4ea8ff',
29  e: '#5fe08c',
30  // the chest light in standby, dim and half lit (c is fully lit)
31  d: '#1d4f57',
32  q: '#2fa3b3',
33}
34
35// px is the robot's picture; other skins draw the same pose on demand (framePx).
36export type Frame = { px: string[]; ms: number; pose: Pose }
37export type Span = { text: string; color?: string; bg?: string }
38
39type Eyes = 'star' | 'sad' | 'open' | 'peek' | 'peekL' | 'peekR' | 'blink' | 'happy' | 'left' | 'right' | 'up' | 'down' | 'wide' | 'heart' | 'angry' | 'x' | 'weighL' | 'weighR' | 'off'
40type Mouth = 'frown' | 'smile' | 'flat' | 'grim' | 'o' | 'open' | 'zig' | 'skewL' | 'skewR' | 'none'
41type Place = [name: string, x: number, y: number]
42
43export type Pose = {
44  dx?: number
45  dy?: number
46  // head-only offsets: lean sideways, nod up and down
47  lean?: number
48  // head tilt: the upper part of the head shifts sideways, the chin stays
49  tilt?: number
50  nod?: number
51  eyes?: Eyes
52  mouth?: Mouth
53  tone?: string
54  antenna?: 'a' | 'g' | 'y'
55  // a taller antenna: one more stem pixel under the tip
56  antennaUp?: boolean
57  wave?: 'A' | 'B' | 'both'
58  // both forearms out to the sides at shoulder height, palms up
59  shrug?: boolean
60  // the right hand holds a prop beside the body, so its small resting hand is hidden
61  holding?: boolean
62  wheel?: 0 | 1 | 2
63  // props at absolute canvas positions / relative to the body's top-left corner
64  props?: Place[]
65  on?: Place[]
66}
67
68// Screen pixels are [row, col] inside the 14-wide head; rows 0-3 are body rows 3-6.
69// An optional third entry overrides the pixel's color key.
70const EYE_PIXELS: Record<Eyes, Array<[number, number, string?]>> = {
71  // gray eyes with the pupil on the bottom row: looking down
72  peekL: [[0, 3, 'j'], [0, 4, 'j'], [1, 3, 'j'], [1, 4, 'j'], [2, 3], [2, 4, 'j'], [0, 8, 'j'], [0, 9, 'j'], [1, 8, 'j'], [1, 9, 'j'], [2, 8], [2, 9, 'j']],
73  peekR: [[0, 3, 'j'], [0, 4, 'j'], [1, 3, 'j'], [1, 4, 'j'], [2, 3, 'j'], [2, 4], [0, 8, 'j'], [0, 9, 'j'], [1, 8, 'j'], [1, 9, 'j'], [2, 8, 'j'], [2, 9]],
74  peek: [[0, 3, 'j'], [0, 4, 'j'], [1, 3, 'j'], [1, 4, 'j'], [2, 3], [2, 4], [0, 8, 'j'], [0, 9, 'j'], [1, 8, 'j'], [1, 9, 'j'], [2, 8], [2, 9]],
75  open: [[0, 4], [1, 4], [0, 9], [1, 9]],
76  blink: [[1, 3], [1, 4], [1, 9], [1, 10]],
77  happy: [[0, 4], [1, 3], [1, 5], [0, 9], [1, 8], [1, 10]],
78  left: [[0, 3], [1, 3], [0, 8], [1, 8]],
79  right: [[0, 5], [1, 5], [0, 10], [1, 10]],
80  up: [[0, 5], [0, 10]],
81  down: [[1, 4], [1, 9]],
82  wide: [[0, 3], [0, 4], [1, 3], [1, 4], [0, 8], [0, 9], [1, 8], [1, 9]],
83  heart: [[0, 3], [0, 5], [1, 4], [0, 8], [0, 10], [1, 9]],
84  angry: [[0, 3], [1, 4], [1, 8], [0, 9]],
85  // yellow sparkles instead of pupils: impressed
86  star: [[0, 4, 'y'], [1, 3, 'y'], [1, 4, 'y'], [1, 5, 'y'], [2, 4, 'y'], [0, 9, 'y'], [1, 8, 'y'], [1, 9, 'y'], [1, 10, 'y'], [2, 9, 'y']],
87  // brows slanting up towards the middle: worried, sorry
88  sad: [[1, 3], [0, 4], [0, 8], [1, 9]],
89  x: [[0, 3], [0, 5], [1, 4], [2, 3], [2, 5], [0, 8], [0, 10], [1, 9], [2, 8], [2, 10]],
90  // one eye open, the other squinting: weighing something up
91  weighL: [[0, 4], [1, 4], [1, 9], [1, 10]],
92  weighR: [[1, 3], [1, 4], [0, 9], [1, 9]],
93  // the screen is off: only faint gray dashes where the eyes were
94  off: [[1, 3, 'u'], [1, 4, 'u'], [1, 9, 'u'], [1, 10, 'u']],
95}
96
97const MOUTH_PIXELS: Record<Mouth, Array<[number, number]>> = {
98  smile: [[2, 3], [2, 10], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9]],
99  flat: [[3, 5], [3, 6], [3, 7], [3, 8]],
100  frown: [[2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [3, 3], [3, 10]],
101  grim: [[3, 3], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10]],
102  o: [[2, 6], [2, 7], [3, 6], [3, 7]],
103  open: [[2, 5], [2, 6], [2, 7], [2, 8], [3, 5], [3, 6], [3, 7], [3, 8]],
104  zig: [[3, 3], [2, 4], [3, 5], [2, 6], [3, 7], [2, 8], [3, 9], [2, 10]],
105  // a slanted mouth, one corner up: "hmm, partly"
106  skewL: [[2, 5], [2, 6], [3, 7], [3, 8], [3, 9]],
107  skewR: [[3, 4], [3, 5], [3, 6], [2, 7], [2, 8]],
108  none: [],
109}
110
111const SCREEN = ['HHKKKKKKKKKKHH', 'HHKKKKKKKKKKHH', 'HHKKKKKKKKKKHH', 'HHKKKKKKKKKKHH']
112
113function screenRows(eyes: Eyes, mouth: Mouth, tone: string, screen: string[] = SCREEN): string[] {
114  const rows = screen.map(row => row.split(''))
115  const eyeKey = eyes === 'heart' ? 'p' : tone
116
117  for (const [r, c, key] of EYE_PIXELS[eyes]) {
118    rows[r]![c] = key ?? eyeKey
119  }
120
121  for (const [r, c] of MOUTH_PIXELS[mouth]) {
122    rows[r]![c] = tone
123  }
124
125  return rows.map(r => r.join(''))
126}
127
128const cloud = (dots: number) => ['.WWW.', `W${'u'.repeat(dots)}${'W'.repeat(4 - dots)}`, '.WWW.', 'W....']
129
130// A clock face whose single red hand points up, right, down or left.
131const CLOCK_HAND: Array<[number, number]> = [[1, 2], [2, 3], [3, 2], [2, 1]]
132const clock = (hand: number) => {
133  const rows = ['.WWW.', 'W...W', 'W.c.W', 'W...W', '.WWW.'].map(r => r.split(''))
134  const [r, c] = CLOCK_HAND[hand % CLOCK_HAND.length]!
135  rows[r]![c] = 'r'
136
137  return rows.map(r => r.join(''))
138}
139
140// A small canvas whose three cells fill with paint one after the other.
141const PAINT = ['r', 'y', 'b']
142const canvas = (filled: number) => {
143  const rows = ['WWWWW', 'W...W', 'WWWWW'].map(r => r.split(''))
144
145  PAINT.slice(0, filled).forEach((color, i) => {
146    rows[1]![1 + i] = color
147  })
148
149  return rows.map(r => r.join(''))
150}
151
152// A clipboard whose first `checked` items are ticked off in green.
153const clipboard = (checked: number) => {
154  const rows = ['.mmm.', 'WWWWW']
155
156  for (let item = 0; item < 3; item++) {
157    rows.push(`W${item < checked ? 'e' : 'W'}uuW`, 'WWWWW')
158  }
159
160  return rows
161}
162
163const BOARD_PATH: Array<[number, number]> = [[1, 1], [1, 2], [1, 3], [2, 3], [3, 3], [3, 2]]
164const board = (n: number) => {
165  const rows = ['WWWWW', 'W...W', 'W...W', 'W...W', 'WWWWW'].map(r => r.split(''))
166
167  BOARD_PATH.slice(0, n).forEach(([r, c]) => {
168    rows[r]![c] = 'c'
169  })
170
171  return rows.map(r => r.join(''))
172}
173
174// An open book lying in front of the robot, seen from the side at eye level (14 x 8): two
175// stacks of pages (white) on a brown cover, a gray spine in the gutter. A turning page is
176// a five-pixel line from the spine at `angle` degrees: 0 lies on the right stack, 90
177// stands up, 180 lies on the left stack.
178const BOOK_SLAB = ['.WWWWW..WWWWW.', 'WWWWWWmmWWWWWW', 'FFFFFFFFFFFFFF']
179const PAGE_LENGTH = 5
180
181function sideBook(angle?: number): string[] {
182  const rows = [...Array.from({ length: PAGE_LENGTH }, () => '.'.repeat(14)), ...BOOK_SLAB].map(row => row.split(''))
183
184  if (angle !== undefined) {
185    const rad = (angle * Math.PI) / 180
186    const pivot = angle <= 90 ? 7 : 6
187
188    for (let k = 1; k <= PAGE_LENGTH; k++) {
189      const x = pivot + Math.round(k * Math.cos(rad))
190      const y = PAGE_LENGTH - Math.round(k * Math.sin(rad))
191
192      if (rows[y]?.[x] !== undefined) {
193        rows[y]![x] = 'W'
194      }
195    }
196  }
197
198  return rows.map(row => row.join(''))
199}
200
201const PROPS: Record<string, string[]> = {
202  cloud0: cloud(0),
203  cloud1: cloud(1),
204  cloud2: cloud(2),
205  cloud3: cloud(3),
206  clock0: clock(0),
207  clock1: clock(1),
208  clock2: clock(2),
209  clock3: clock(3),
210  canvas0: canvas(0),
211  canvas1: canvas(1),
212  canvas2: canvas(2),
213  canvas3: canvas(3),
214  // a letter with its flap drawn in gray, big enough to read as an envelope
215  envelope: ['WWWWWWW', 'WmWWWmW', 'WWmWmWW', 'WWWmWWW', 'WWWWWWW'],
216  bell: ['.yy.', 'yyyy', 'yyyy', 'yyyy', 'yyyy', '.rr.'],
217  bulbOff: ['.vvv.', 'vvvvv', 'vvvvv', '.vvv.', '..u..'],
218  bulbOn: ['.yyy.', 'yyyyy', 'yyyyy', '.yyy.', '..u..'],
219  door0: ['ffff', 'fFFf', 'fFFf', 'fFFy', 'fFFf', 'fFFf', 'fFFf', 'ffff'],
220  door1: ['ffff', 'fKKK', 'fKKK', 'fKKK', 'fKKK', 'fKKK', 'fKKK', 'ffff'],
221  door2: ['ffff', 'KyyK', 'KyyK', 'KyyK', 'KyyK', 'KyyK', 'KyyK', 'ffff'],
222  blush: ['p'],
223  clip0: clipboard(0),
224  clip1: clipboard(1),
225  clip2: clipboard(2),
226  clip3: clipboard(3),
227  paper: ['WWWW', 'WWWW', 'WWWW'],
228  paperOk: ['WWWW', 'WrrW', 'WWWW'],
229  stamp: ['mm', 'mm', 'rr'],
230  puff: ['.uu.', 'uuuu', '.uu.'],
231  stopSign: ['.rrr.', 'rrrrr', 'rWWWr', 'rrrrr', '.rrr.'],
232  lens: ['.WWW.', 'WcW.W', 'W...W', '.WWW.', '....m', '.....m'],
233  q: ['.WW.', 'W..W', '..W.', '....', '.W..'],
234  ex: ['W', 'W', 'W', '.', 'W'],
235  heart: ['.p.p.', 'ppppp', 'ppppp', '.ppp.', '..p..'],
236  z: ['WWW', '..W', '.W.', 'W..', 'WWW'],
237  zs: ['WWW', '.W.', 'WWW'],
238  // the chest light over the two cyan torso pixels: dim, half lit
239  ledDim: ['dd'],
240  ledHalf: ['qq'],
241  spark: ['.y.', 'yyy', '.y.'],
242  star: ['y.y', '.y.', 'y.y'],
243  // binary digits for the run scene, in green: a rounded zero, and a one with a flag and a
244  // foot (a bare stroke would read as a raised finger)
245  bit0: ['.e.', 'e.e', 'e.e', 'e.e', '.e.'],
246  bit1: ['.e.', 'ee.', '.e.', '.e.', 'eee'],
247  // the open book from the side, and its turning page at 15 to 165 degrees
248  book: sideBook(),
249  bookPage15: sideBook(15),
250  bookPage50: sideBook(50),
251  bookPage90: sideBook(90),
252  bookPage130: sideBook(130),
253  bookPage165: sideBook(165),
254  keysA: ['wwwwwwwwww', 'wcwcwcwcwc'],
255  keysB: ['wwwwwwwwww', 'cwcwcwcwcw'],
256  hand: ['mm'],
257  // a fence across the bottom three rows, one flat color: the panel's lightest shade from
258  // its top (n), so it stands out against the darker bottom of the panel like a piece of
259  // the backdrop pulled forward; the hands grip its top edge
260  fence: ['nnnnnnnnnnnnnnnnnnnnnnnn', 'nnnnnnnnnnnnnnnnnnnnnnnn', 'nnnnnnnnnnnnnnnnnnnnnnnn'],
261  fenceHandA: ['.mmm.', 'mmmmm', 'm.m.m'],
262  fenceHandB: ['.mmm.', 'mmmmm', '.m.m.'],
263  blkE: ['ee', 'ee'],
264  blkB: ['bb', 'bb'],
265  blkO: ['oo', 'oo'],
266  speed: ['uuu.', '....', '.uuu', '....', 'uu..'],
267  mug: ['WWWW', 'WbbW', '.WW.'],
268  steam0: ['u.', '.u'],
269  steam1: ['.u', 'u.'],
270  cap: ['..bbbbWW..', '.bbbbbbbb.'],
271  party: ['.....y....', '....ppp...'],
272  thumb: ['..m..', '..mm.', 'mmmmm', 'mmmmm', '.mmm.'],
273  // a speech bubble with a wiggling "~" inside
274  wave0: ['.WWW.', 'WuWuW', 'WWuWW', '.WWW.', 'W....'],
275  // a speech bubble asking the person something: a question mark, its dot on or off
276  askBubble: ['.WWW.', 'WuuWW', 'WWWuW', 'WWuWW', 'WWWWW', 'WWuWW', '.WWW.', 'W....'],
277  askBubbleDim: ['.WWW.', 'WuuWW', 'WWWuW', 'WWuWW', 'WWWWW', 'WWWWW', '.WWW.', 'W....'],
278  wave1: ['.WWW.', 'WWuWW', 'WuWuW', '.WWW.', 'W....'],
279  ballY: ['y'],
280  ballP: ['p'],
281  ballB: ['b'],
282  board0: board(0),
283  board1: board(1),
284  board2: board(2),
285  board3: board(3),
286  board4: board(4),
287  board5: board(5),
288  board6: board(6),
289}
290
291function stamp(canvas: string[][], rows: string[], x: number, y: number) {
292  rows.forEach((row, j) => {
293    ;[...row].forEach((key, i) => {
294      if (key !== '.' && canvas[y + j]?.[x + i] !== undefined) {
295        canvas[y + j]![x + i] = key
296      }
297    })
298  })
299}
300
301const TILT_ROWS = 6
302
303const WHEELS = ['..kkk....kkk..', '..kmk....kmk..', '..kWk....kWk..']
304
305// A skin: who the robot is drawn as. Every skin shares the skeleton (a 14-wide head with
306// the face in the same place, a torso, feet), so every pose, face and prop of every scene
307// works for all of them; a skin changes the top of the head, the chest, the feet, the eye
308// color, a few extras, and recolors palette keys (colors, applied when drawing).
309export type SkinId = 'robot' | 'cat' | 'claude' | 'owl' | 'ghost'
310type Skin = {
311  // the two rows above the head: the robot's antenna, the cat's ears
312  crown: (antenna: string) => [string, string]
313  // the same two rows on an agent's mini, 8 wide (the robot's antenna when absent)
314  miniCrown?: [string, string]
315  // the chest pixels in the torso's second row
316  chest: string
317  // the three steps of the feet (the robot's wheels turn)
318  feet: [string, string, string]
319  // the eyes' and mouth's color key when the pose names none
320  tone: string
321  // a head with sharp corners (the robot's top and chin rows are rounded)
322  square?: boolean
323  // the face's four rows before the eyes and mouth go on (the robot's plain screen)
324  screen?: string[]
325  // see-through keys: drawn as this much of their color over the panel behind (0 to 1)
326  glass?: { keys: string; opacity: number }
327  // anything else the skin draws: a tail at the body (x, y), whiskers at the head (hx, hy)
328  extra?: (canvas: string[][], x: number, y: number, hx: number, hy: number) => void
329  colors: Record<string, string>
330}
331
332export const SKINS: Record<SkinId, Skin> = {
333  robot: {
334    crown: antenna => [`......${antenna}${antenna}......`, '......mm......'],
335    chest: 'cc',
336    feet: WHEELS as [string, string, string],
337    tone: 'c',
338    colors: {},
339  },
340  // a ginger cat: ears with pink insides, a cream muzzle and belly, dark eyes, whiskers,
341  // paws for feet and a tail curling up on the left
342  cat: {
343    crown: () => ['..H........H..', '.HpH......HpH.'],
344    chest: 'KK',
345    feet: ['..kkk....kkk..', '..kkk....kkk..', '..kkk....kkk..'],
346    tone: 'E',
347    extra: (canvas, x, y, hx, hy) => {
348      for (const [px, py] of [[-2, 10], [-3, 9], [-3, 8], [-2, 7]] as const) {
349        stamp(canvas, ['T'], x + px, y + py)
350      }
351
352      for (const side of [-2, 14]) {
353        stamp(canvas, ['uu'], hx + side, hy + 5)
354        stamp(canvas, ['uu'], hx + side, hy + 6)
355      }
356    },
357    // d and q (the robot's standby chest light) take the belly's color: a cat has no light
358    colors: { H: '#e8954a', G: '#c47436', K: '#fbe6cc', T: '#d6843f', m: '#fbe6cc', k: '#b8662e', E: '#2a211c', d: '#fbe6cc', q: '#fbe6cc' },
359  },
360  // Claude Code's own mascot: one square-cornered terracotta block, dark eyes, stubby arms
361  // and four short legs; the face is the same color as the head, only a touch deeper
362  claude: {
363    crown: () => ['..............', '..............'],
364    chest: 'TT',
365    feet: ['..k.k....k.k..', '..k.k....k.k..', '..k.k....k.k..'],
366    tone: 'E',
367    square: true,
368    colors: { H: '#d97757', G: '#c96a4b', K: '#d1704f', T: '#d97757', m: '#d97757', k: '#b85c3e', E: '#1f1612', d: '#d97757', q: '#d97757' },
369  },
370  // a brown owl: ear tufts, a pale facial disc, big round eye rings (Y) around the pupils,
371  // a small amber beak, wings at the sides, a pale belly and amber talons
372  owl: {
373    crown: () => ['.H..........H.', '..HH......HH..'],
374    miniCrown: ['H......H', '.HH..HH.'],
375    chest: 'KK',
376    feet: ['..k.k....k.k..', '..k.k....k.k..', '..k.k....k.k..'],
377    tone: 'E',
378    screen: ['HHKYYYKKYYYKHH', 'HHKYYYKKYYYKHH', 'HHKKKKaaKKKKHH', 'HHKKKKKKKKKKHH'],
379    colors: { H: '#9c7552', G: '#7d5c3f', K: '#ead8b8', T: '#8a6747', m: '#7d5c3f', k: '#e0a83a', E: '#2b1f17', Y: '#fff8e8', d: '#ead8b8', q: '#ead8b8' },
380  },
381  // a little ghost: a domed head, one pale body from the top to a wavy hem (the hem
382  // ripples where the robot's wheels turn), dark eyes, no separate arms or chest
383  ghost: {
384    crown: () => ['....HHHHHH....', '..HHHHHHHHHH..'],
385    chest: 'TT',
386    feet: ['.k.kk.kk.kk.k.', 'k.kk.kk.kk.kk.', '.kk.kk.kk.kk.k'],
387    tone: 'E',
388    // close the gaps between the torso's side and its hanging hands, so the body is whole
389    extra: (canvas, x, y) => {
390      for (const px of [1, 12]) {
391        if (canvas[y + 9]?.[x + px] === '.') {
392          stamp(canvas, ['T'], x + px, y + 9)
393        }
394      }
395    },
396    // see-through: the panel's gradient shows through the body; the eyes stay solid
397    glass: { keys: 'HGKTmkdq', opacity: 0.5 },
398    colors: { H: '#e9ecf8', G: '#dde2f2', K: '#e9ecf8', T: '#e9ecf8', m: '#e9ecf8', k: '#dde2f2', E: '#2d2a4a', d: '#e9ecf8', q: '#e9ecf8' },
399  },
400}
401// A raised arm: a two-pixel-wide forearm ending in a wider mitten. A one-pixel pole with a
402// single-pixel hand reads as a raised finger, so the arm is never thinner than the hand.
403const ARM_UP = (side: 'L' | 'R', hand: 'A' | 'B'): Array<[number, number]> => {
404  const dir = side === 'R' ? 1 : -1
405  const stem = side === 'R' ? 14 : -2
406  const out: Array<[number, number]> = []
407
408  for (let row = 4; row <= 7; row++) {
409    out.push([stem, row], [stem + 1, row])
410  }
411
412  // the mitten sits over the stem (A) or leans outward (B)
413  const shift = hand === 'B' ? dir : 0
414
415  for (let i = -1; i <= 2; i++) {
416    out.push([stem + i + shift, 3])
417  }
418
419  return out
420}
421
422// Palms-up hands: each forearm runs out sideways from the shoulder and ends in a cupped
423// hand (two fingertips over a flat palm), so nothing points up on its own.
424const ARMS_SHRUG: Array<[number, number]> = [
425  [-3, 7], [-1, 7], [14, 7], [16, 7],
426  ...[-3, -2, -1, 0, 1, 12, 13, 14, 15, 16].map(ax => [ax, 8] as [number, number]),
427]
428
429export function compose(pose: Pose, skinId: SkinId = 'robot'): string[] {
430  const skin = SKINS[skinId]
431  const { dx = 0, dy = 0, lean = 0, tilt = 0, nod = 0, eyes = 'open', mouth = 'smile', tone, antenna = 'a', antennaUp = false, wave, shrug = false, holding = false, wheel = 0, props = [], on = [] } = pose
432  const canvas = Array.from({ length: H }, () => Array.from({ length: W }, () => '.'))
433  const x = 5 + dx
434  const y = 1 + dy
435
436  const [crownTip, crownBase] = skin.crown(antenna)
437  const edge = skin.square ? '' : '.'
438  const head = [crownTip, crownBase, `${edge}${'H'.repeat(skin.square ? 14 : 12)}${edge}`, ...screenRows(eyes, mouth, tone ?? skin.tone, skin.screen), `${edge}${'G'.repeat(skin.square ? 14 : 12)}${edge}`]
439  const arms = (wave === 'both' || shrug ? ['..TTTTTTTTTT..', '..TTTTccTTTT..'] : wave || holding ? ['mmTTTTTTTTTT..', 'm.TTTTccTTTT..'] : ['mmTTTTTTTTTTmm', 'm.TTTTccTTTT.m']).map(row =>
440    row.replace('cc', skin.chest),
441  )
442
443  stamp(canvas, [...arms, skin.feet[wheel]!], x, y + 8)
444  // rows 0-5 of the head (antenna to the top mouth row) tilt; rows 6-7 (the bottom mouth
445  // row and the chin) stay
446  stamp(canvas, head.slice(0, TILT_ROWS), x + lean + tilt, y + nod)
447  stamp(canvas, head.slice(TILT_ROWS), x + lean, y + nod + TILT_ROWS)
448
449  if (antennaUp) {
450    stamp(canvas, [crownBase], x + lean + tilt, y + nod)
451    stamp(canvas, [crownTip], x + lean + tilt, y + nod - 1)
452  }
453
454  skin.extra?.(canvas, x, y, x + lean, y + nod)
455
456  if (wave) {
457    const sides: Array<'L' | 'R'> = wave === 'both' ? ['L', 'R'] : ['R']
458    const hand = wave === 'B' ? 'B' : 'A'
459
460    for (const side of sides) {
461      ARM_UP(side, hand).forEach(([ax, ay]) => stamp(canvas, ['m'], x + ax, y + ay))
462    }
463  }
464
465  if (shrug) {
466    ARMS_SHRUG.forEach(([ax, ay]) => stamp(canvas, ['m'], x + ax, y + ay))
467  }
468
469  for (const [name, px, py] of on) {
470    stamp(canvas, PROPS[name]!, x + px, y + py)
471  }
472
473  for (const [name, px, py] of props) {
474    stamp(canvas, PROPS[name]!, px, py)
475  }
476
477  return canvas.map(row => row.join(''))
478}
479
480// Global slowdown for short frames; long rest holds (idle) are left as written.
481const SLOWDOWN = 1.7
482const SLOWDOWN_BELOW_MS = 1000
483
484const scaleMs = (ms: number) => (ms < SLOWDOWN_BELOW_MS ? Math.round(ms * SLOWDOWN) : ms)
485
486// A frame of exactly `ms` milliseconds, no slowdown applied.
487const fRaw = (ms: number, pose: Pose = {}): Frame => ({ ms, px: compose(pose), pose })
488
489// A frame's picture in a skin; the robot's is ready, others are drawn once and kept.
490const skinned = new Map<string, WeakMap<Frame, string[]>>()
491
492export function framePx(frame: Frame, skin: SkinId = 'robot'): string[] {
493  if (skin === 'robot' || !SKINS[skin]) {
494    return frame.px
495  }
496
497  const cache = skinned.get(skin) ?? new WeakMap<Frame, string[]>()
498  skinned.set(skin, cache)
499
500  const px = cache.get(frame) ?? compose(frame.pose, skin)
501  cache.set(frame, px)
502
503  return px
504}
505
506export const f = (ms: number, pose: Pose = {}): Frame => fRaw(scaleMs(ms), pose)
507
508// once: the scene plays through, then the client falls back to the rest scene;
509// repeat is how many times it plays through (1 when left out).
510export type Anim = { frames: Frame[]; once?: boolean; repeat?: number }
511
512export const frameIndex = (frames: Frame[], elapsed: number): number => {
513  const total = frames.reduce((sum, fr) => sum + fr.ms, 0)
514  let t = elapsed % total
515
516  for (let i = 0; i < frames.length; i++) {
517    t -= frames[i]!.ms
518
519    if (t < 0) {
520      return i
521    }
522  }
523
524  return 0
525}
526
527// The robot stands on a dark rounded panel, so it reads the same on light and dark
528// terminals without knowing the background. The panel fades from top to bottom.
529const PANEL_TOP = [0x2c, 0x34, 0x47]
530const PANEL_BOTTOM = [0x14, 0x19, 0x23]
531// Props reach the canvas edges, so the panel adds a column of margin on each side.
532const PAD = 1
533// One empty column left of the panel, so it sits one cell off the band's left edge.
534const LEFT_GAP = 1
535export const FRAME_W = W + 2 * PAD + LEFT_GAP
536
537const hex = (rgb: number[]) => `#${rgb.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
538const rgb = (color: string) => color.slice(1).match(/../g)!.map(v => parseInt(v, 16))
539// `share` of `front` over `back`: a see-through color.
540const mix = (front: string, back: string, share: number) => hex(rgb(front).map((v, i) => v * share + rgb(back)[i]! * (1 - share)))
541const gradient = (top: number[], bottom: number[]) => Array.from({ length: H }, (_, y) => hex(top.map((t, i) => t + ((bottom[i]! - t) * y) / (H - 1))))
542const PANEL_ROWS = gradient(PANEL_TOP, PANEL_BOTTOM)
543// The fence's flat color: the panel's lightest shade, from its top row, a touch lighter.
544const FENCE_LIFT = 16
545PALETTE.n = hex(PANEL_TOP.map(v => v + FENCE_LIFT))
546
547// The light look: a pale panel and a light face screen with deeper eyes. The body keeps
548// its steel blue, only a touch lighter (a white body read as a snowman, and a pale chin
549// left the head floating).
550// Only these keys change; every other color is the same in both.
551export type Theme = 'dark' | 'light'
552
553// macOS's appearance from `defaults read -g AppleInterfaceStyle`: "Dark" in dark mode; in
554// light mode the key does not exist and the command fails. Anything else (another OS, no
555// `defaults`) keeps the dark look.
556export const systemThemeOf = (exitCode: number, stdout: string, stderr: string): Theme =>
557  exitCode === 0 ? (stdout.trim() === 'Dark' ? 'dark' : 'light') : /does not exist/.test(stderr) ? 'light' : 'dark'
558const LIGHT_TOP = [0xdd, 0xe4, 0xec]
559const LIGHT_BOTTOM = [0xbd, 0xc8, 0xd5]
560const LIGHT_ROWS = gradient(LIGHT_TOP, LIGHT_BOTTOM)
561// The body's steel blue, lifted a little towards white for the light look.
562const BODY_LIFT = 0.15
563const lift = (color: string) => hex(color.slice(1).match(/../g)!.map(v => parseInt(v, 16)).map(v => v + (255 - v) * BODY_LIFT))
564const LIGHT: Record<string, string> = {
565  H: lift(PALETTE.H!),
566  G: lift(PALETTE.G!),
567  T: lift(PALETTE.T!),
568  m: lift(PALETTE.m!),
569  K: '#e3f3f7',
570  c: '#0b7f96',
571  w: '#4b5563',
572  k: '#4b5563',
573  u: '#6b7280',
574  v: '#9aa6b4',
575  n: hex(LIGHT_TOP.map(v => Math.min(255, v + FENCE_LIFT))),
576}
577
578// A skin's own colors come first, then the theme's, then the shared palette.
579const colorOf = (key: string, theme: Theme, skin: SkinId) =>
580  SKINS[skin]?.colors[key] ?? (theme === 'light' ? (LIGHT[key] ?? PALETTE[key]) : PALETTE[key])
581
582const isPanel = (x: number, y: number, width: number) => x >= 0 && y >= 0 && x < width && y < H && !((x === 0 || x === width - 1) && (y === 0 || y === H - 1))
583
584// One empty pixel row above the panel, so the band keeps a little room from what is
585// drawn above it.
586const TOP_GAP = 1
587
588// The panel is as wide as the picture: the robot's canvas, plus the minis when agents run.
589function backdrop(px: string[], theme: Theme, skin: SkinId): Array<Array<string | undefined>> {
590  const panel = theme === 'light' ? LIGHT_ROWS : PANEL_ROWS
591  const width = (px[0]?.length ?? W) + 2 * PAD
592
593  return Array.from({ length: H + TOP_GAP }, (_, row) =>
594    Array.from({ length: LEFT_GAP + width }, (_, col) => {
595      if (col < LEFT_GAP) {
596        return undefined
597      }
598
599      const x = col - LEFT_GAP
600      const y = row - TOP_GAP
601
602      if (!isPanel(x, y, width)) {
603        return undefined
604      }
605
606      // the margin columns carry the fence on to the panel's edge, so it spans the whole
607      // panel (inside its rounded corners) instead of stopping at the canvas
608      const line = px[y] ?? ''
609      const edge = x < PAD ? line[0] : x >= line.length + PAD ? line[line.length - 1] : undefined
610      const key = edge === 'n' ? edge : line[x - PAD]
611
612      if (!key || key === '.') {
613        return panel[y]
614      }
615
616      const glass = SKINS[skin]?.glass
617
618      return glass?.keys.includes(key) ? mix(colorOf(key, theme, skin)!, panel[y]!, glass.opacity) : colorOf(key, theme, skin)
619    }),
620  )
621}
622
623// The picture as colors, pixel by pixel (the panel, its gap row and the robot), for tools
624// that draw it elsewhere (tools/render-svg.ts); undefined is transparent.
625export const pixelColors = (px: string[], theme: Theme = 'dark', skin: SkinId = 'robot') => backdrop(px, theme, skin)
626
627// Folds two pixel rows into one terminal row: ▀ takes the top pixel as its
628// color and the bottom as its background; equal colors merge into one block.
629export function halfBlock(px: string[], theme: Theme = 'dark', skin: SkinId = 'robot'): Span[][] {
630  const grid = backdrop(px, theme, skin)
631  const rows: Span[][] = []
632
633  for (let y = 0; y < grid.length; y += 2) {
634    const top = grid[y]!
635    const bottom = grid[y + 1] ?? []
636    const spans: Span[] = []
637
638    for (let x = 0; x < top.length; x++) {
639      const t = top[x]
640      const b = bottom[x]
641      const cell: Span = !t && !b ? { text: ' ' } : t && !b ? { text: '▀', color: t } : !t && b ? { text: '▄', color: b } : t === b ? { text: '█', color: t } : { text: '▀', color: t, bg: b }
642      const last = spans.at(-1)
643
644      if (last && last.color === cell.color && last.bg === cell.bg) {
645        last.text += cell.text
646      } else {
647        spans.push(cell)
648      }
649    }
650
651    rows.push(spans)
652  }
653
654  return rows
655}
656
657// A mini robot for each running agent, standing to the right of the robot on the same
658// panel. Only the first MINI_LIMIT are drawn; the rest are counted as text ("+3").
659export const MINI_LIMIT = 4
660const MINI_W = 8
661const MINI_GAP = 2
662const mini = (antenna: string, skin: SkinId = 'robot') => [...(SKINS[skin].miniCrown ?? [`...${antenna}${antenna}...`, '...mm...']), 'HHHHHHHH', 'HKcKKcKH', 'HKKccKKH', '.GGGGGG.', '.TTTTTT.', '.ww..ww.']
663
664// The robot's frame widened with `count` minis; `blink` flips every few hundred ms, and each
665// mini's antenna is lit while its neighbor's is dim, so the row twinkles instead of flashing.
666export function withMinis(px: string[], count: number, blink: number, skin: SkinId = 'robot'): string[] {
667  const shown = Math.min(count, MINI_LIMIT)
668
669  if (shown === 0) {
670    return px
671  }
672
673  const top = H - mini('a').length
674
675  return px.map((row, y) => {
676    let out = row
677
678    for (let i = 0; i < shown; i++) {
679      out += '.'.repeat(MINI_GAP) + (y < top ? '.'.repeat(MINI_W) : mini((blink + i) % 2 ? 'g' : 'a', skin)[y - top]!)
680    }
681
682    return out
683  })
684}
685
686// Terminal columns the band needs: the panel, plus a "+N" label when agents overflow.
687export const bandWidth = (count: number) => FRAME_W + Math.min(count, MINI_LIMIT) * (MINI_W + MINI_GAP) + (count > MINI_LIMIT ? 4 : 0)
688
689// Terminal rows of the band (seven, after the top gap), most important first: the eyes and
690// the mouth (rows 3-4, so even two rows still show the expression), the top of the head,
691// the arms, the antenna, the wheels, the gap. When the band has fewer rows (the bottom slot
692// is shared with the tasks list in fullscreen), the least important rows go and the rest
693// keep their order.
694const ROW_PRIORITY = [3, 4, 2, 5, 1, 6, 0]
695
696export function fitRows<T>(rows: T[], maxRows: number): T[] {
697  if (rows.length <= maxRows) {
698    return rows
699  }
700
701  const kept = new Set(ROW_PRIORITY.slice(0, Math.max(0, maxRows)))
702
703  return rows.filter((_, i) => kept.has(i))
704}
705
hooks/client/pixpal.tsx 125 lines
1import type { ClientSurface } from 'claude-code'
2
3import { ANIMS } from '../scenes.ts'
4import { SAYINGS, shownText, withBubble, WORK_SAYINGS, WORK_SPEAKS, type Lang } from '../sayings.ts'
5import { fitRows, frameIndex, framePx, halfBlock, MINI_LIMIT, withMinis, type SkinId, type Theme } from '../sprites.ts'
6
7// seq changes on every request, so the same scene can be asked for twice in a row.
8// rest is the scene a one-shot falls back to (idle, or a time-of-day variant).
9// agents is how many agents run now: each gets a mini robot beside this one.
10// maxRows is how many terminal rows the band has; the robot is cut down to fit.
11// lang is the person's language: an emotion says its line in it, in a bubble.
12// bubbles false keeps the robot silent (no speech bubbles at all).
13type Props = { scene: string; seq: number; rest: string; debug: boolean; source: string; agents: number; maxRows: number; lang: Lang; bubbles?: boolean; theme?: Theme; skin?: SkinId; phrases?: Record<string, string> | null }
14// said is the bubble's line, saidAt when it appeared, shown what of it is on screen now.
15// clock names the clock code that runs; a reload with a different one restarts it.
16type State = { scene: string; seq: number; t0: number; fi: number; blink: number; said?: string; saidAt: number; shown?: string; clock?: number; props: Props; stop?: () => void }
17
18const TICK_MS = 40
19// Bump when the clock's tick changes: a hot reload keeps the Client's state, and with it
20// the old clock, which would go on running the old tick.
21const CLOCK_VERSION = 3
22// How long a mini's antenna stays lit or dim.
23const BLINK_MS = 450
24const OVERFLOW_COLOR = '#8a93a3'
25
26const animOf = (scene: string) => ANIMS[scene] ?? ANIMS.idle!
27const durationOf = (scene: string) => animOf(scene).frames.reduce((sum, fr) => sum + fr.ms, 0) * (animOf(scene).repeat ?? 1)
28
29// The line a scene says, if any. Props from an older hooks module (during a reload) may lack
30// lang, agents or maxRows.
31// A chosen language's phrasebook (phrases) wins over the line in the prompt's language.
32const sayingOf = (scene: string, props: Props) => {
33  const known = props.bubbles === false ? undefined : (SAYINGS[scene] ?? (WORK_SPEAKS ? WORK_SAYINGS[scene] : undefined))
34
35  return known && (props.phrases?.[scene] ?? known[props.lang ?? 'en'])
36}
37
38// The Client keeps its own clock, so frames never travel through the hooks module.
39function startClock(surface: ClientSurface<State>, st: State) {
40  st.clock = CLOCK_VERSION
41  st.stop = surface.every(TICK_MS, () => {
42    const cur = surface.state
43
44    if (!cur) {
45      return
46    }
47
48    const now = Date.now()
49    let { scene, seq, t0 } = cur
50
51    if (cur.props.seq !== cur.seq) {
52      scene = cur.props.scene
53      seq = cur.props.seq
54      t0 = now
55    } else if (animOf(scene).once && now - t0 >= durationOf(scene)) {
56      scene = cur.props.rest
57      t0 = now
58    }
59
60    const fi = frameIndex(animOf(scene).frames, now - t0)
61    const blink = (cur.props.agents ?? 0) > 0 ? Math.floor(now / BLINK_MS) % 2 : 0
62    const said = sayingOf(scene, cur.props)
63    const saidAt = said === cur.said ? cur.saidAt : now
64    const shown = said === undefined ? undefined : shownText(said, now - saidAt)
65
66    if (scene !== cur.scene || seq !== cur.seq || fi !== cur.fi || blink !== cur.blink || said !== cur.said || shown !== cur.shown) {
67      surface.setState({ ...cur, scene, seq, t0, fi, blink, said, saidAt, shown })
68    }
69  })
70}
71
72export default function MascotClient(props: Props, surface: ClientSurface<State>) {
73  const { Box, Text } = surface.elements
74  let st = surface.state
75
76  if (st === undefined) {
77    st = { scene: props.scene, seq: props.seq, t0: Date.now(), fi: 0, blink: 0, said: sayingOf(props.scene, props), saidAt: Date.now(), props }
78    surface.setState(st)
79    startClock(surface, st)
80  } else {
81    st.props = props
82
83    if (st.clock !== CLOCK_VERSION) {
84      st.stop?.()
85      st.said = sayingOf(st.scene, props)
86      st.saidAt = Date.now()
87      startClock(surface, st)
88    }
89  }
90
91
92  const frames = animOf(st.scene).frames
93  const skin = props.skin ?? 'robot'
94  const full = halfBlock(withMinis(framePx(frames[Math.min(st.fi, frames.length - 1)]!, skin), props.agents ?? 0, st.blink, skin), props.theme ?? 'dark', skin)
95  const overflow = (props.agents ?? 0) - MINI_LIMIT
96
97  // the agents beyond the drawn minis, as plain text next to the last one
98  if (overflow > 0) {
99    full[full.length - 2]!.push({ text: ` +${overflow}`, color: OVERFLOW_COLOR })
100  }
101
102  const maxRows = props.maxRows ?? full.length
103  const fitted = fitRows(full, maxRows)
104  const saying = sayingOf(st.scene, props)
105  // an emotion says how it feels, work says what it does, in a bubble beside the picture;
106  // it types itself out letter by letter
107  const rows = saying ? withBubble(fitted, saying, saying === st.said ? Date.now() - st.saidAt : 0) : fitted
108  const hasLabel = props.debug && maxRows > rows.length
109
110  return (
111    <Box flexDirection="column">
112      {rows.map((spans, i) => (
113        <Box key={`r${i}`} flexDirection="row">
114          {spans.map((s, j) => (
115            <Text key={`s${j}`} color={s.color} backgroundColor={s.bg} wrap="truncate">
116              {s.text}
117            </Text>
118          ))}
119        </Box>
120      ))}
121      {hasLabel && <Text dimColor>{st.scene}{props.source && st.scene === props.scene ? `·${props.source}` : ''}</Text>}
122    </Box>
123  )
124}
125
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    pixpal: { scene: string; seq: number; rest: string; demo: boolean; debug: boolean; smart: boolean; source: string; agents: number; workflowAgents: Record<string, number>; lang: 'pl' | 'en' | 'de' | 'es' | 'fr' | 'it' | 'pt' | 'uk' | 'ru' | 'ja' | 'zh' | 'ko'; bubbles: boolean; visible: boolean; theme: 'auto' | 'dark' | 'light'; systemTheme: 'dark' | 'light'; skin: 'robot' | 'cat' | 'claude' | 'owl' | 'ghost'; language: string; phrasebook: Record<string, string> | null; picker: { setting: string; options: Array<{ value: string; label: string }>; current: string; other?: string } | null; pickerTop: number; pickerFocus: string | null; pending: { user: string; answer: string; isAborted: boolean; durationMs: number; at: number } | null }
4  }
5}
6