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

<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>
Recorded in a real Claude Code session; the scripts are in docs/*.tape and you can replay them with VHS.
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.
After a minute of quiet, Pixpal falls asleep. Start typing and it peeks over the fence at your prompt.
/pixpal language spanish, then /pixpal emotions ai: the bubbles speak Spanish and the emotion is picked from the exchange.
/pixpal theme light and /pixpal skin claude.
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
| Character | Notes | |
|---|---|---|
| 🤖 | robot | the original: antenna, face screen, wheels |
| 🐱 | cat | ginger fur, whiskers, a tail |
| 🟧 | claude | a square terracotta block on four short legs |
| 🦉 | owl | big eye rings, a beak, ear tufts |
| 👻 | ghost | see-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>.
Pixpal follows a turn in three phases.
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./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.+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.
| Command | What it does | ||||
|---|---|---|---|---|---|
/pixpal init | set 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 hide | Pixpal 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 report | a block with versions and settings to paste into a bug report (never your messages) | ||||
/pixpal help | all 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.
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):
/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./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.
/pixpal hide to give the band to the other mods.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.
MIT.
<sub>Pixpal is a fan-made project, not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.</sub>
hooks/register.tsx 1087 lines1import { 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}
1087hooks/fixtures.ts 28 lines1// 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]
28hooks/classifier.ts 66 lines1// 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}
66hooks/mood.ts 422 lines1// 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}
422hooks/scenes.ts 340 lines1// 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
340hooks/sayings.ts 370 lines1// 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}
370hooks/sprites.ts 705 lines1// 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}
705hooks/client/pixpal.tsx 125 lines1import 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}
125types/index.d.ts 6 lines1declare 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