A pixel hamster above the prompt in the Claude desktop app: it lives on your context window and tells you when to compact or clear. 110 tips on saving tokens…

A pixel hamster that lives above the prompt in the Claude desktop app (Code tab). It eats your context window, and it tells you what to do about it.
Русская версия ниже: читать по-русски.
/clear and /compact give a lot of XP and raise joy. Ten levels, each with its own gear (seed, backpack, red bandana with speed lines, miner's helmet, crown, halo, and so on).$.session.usage) and spends no tokens of your chat.language setting, or the system locale. The flag in the top right corner opens a language menu.You need a Claude Code build that loads function-hook plugins (the desktop app does).
claude plugin marketplace add nickpotet/claude-hamster
claude plugin install hamster@claude-hamster
Then restart the app. The band appears above the prompt in the Code tab. In a terminal, or when there is little room, it shows one plain line instead.
To make it speak your language regardless of the system locale, set "language": "russian" (or "english") in ~/.claude/settings.json, or pick it with the flag button.
| Piece | File |
|---|---|
| The pet: XP, joy, levels, what counts as a clean-up | hamster/hooks/pet.ts |
| Pixel art and animations | hamster/hooks/art.ts, gear.ts, anim.ts, palette.ts |
| When to warn and what to say | hamster/hooks/advice.ts |
| The 110 tips | hamster/hooks/tips.ts |
| Texts in both languages | hamster/hooks/text.ts |
| The band, buttons, timers | hamster/hooks/register.tsx |
Notes:
/compact or /clear there is no reply yet, so the bar shows the app's local estimate and only the next real count is compared with the last real one.Tests run with the plugin tooling of the Claude Code build:
claude plugin validate hamster
claude plugin test hamster
Add tips in hamster/hooks/tips.ts (a Russian and an English text each, a link without https://, or OFFICIAL for the official catalog) and regenerate docs/TIPS.md.
Questions, ideas, bugs: open an issue, or write to nick.potet@gmail.com or Telegram @nick4p.
Пиксельный хомяк, который живёт над строкой ввода в десктопном приложении Claude (вкладка Code). Он «ест» ваш контекст и подсказывает, что с ним делать.
/clear и /compact дают много опыта и поднимают радость. Десять уровней, у каждого своё снаряжение.language в Claude или из языка системы. Флаг в правом верхнем углу открывает меню выбора.claude plugin marketplace add nickpotet/claude-hamster
claude plugin install hamster@claude-hamster
После этого перезапустите приложение. Хомяк появится над строкой ввода на вкладке Code.
Чтобы всегда был русский, добавьте "language": "russian" в ~/.claude/settings.json или выберите язык флажком.
Вопросы, идеи, баги: issues, почта nick.potet@gmail.com или Telegram @nick4p.
hooks/register.tsx 584 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pet, Tip } from '../types'
5
6import { TICK_MS } from './anim'
7import { advise } from './advice'
8import { TIP_MS } from './tips'
9import { hamsterSvg } from './art'
10import { BAR_HEIGHT, GOLD, JOY, barSvg, barWidth, contextColor } from './bars'
11import { levelOf, moodOf, newPet, observe, progressOf, rebase, titleOf } from './pet'
12import type { Change } from './pet'
13import { LANGS, UI, flagOf, isPref, langOf } from './text'
14import type { Lang, Pref } from './text'
15
16// The scale the hamster is drawn at in the band: whole pixels, 3 to 1.
17const BAND = { width: 144, height: 102 }
18// The bars: one length for all three, after a label column this wide.
19const SEGMENTS = 20
20const LABEL = 10
21// The tip card's label column, wide enough for the longest tag.
22const TAG = 17
23
24// How a tip looks at each level: the colour of its label, its frame and its
25// background. The band itself has no plate: only the tip card is framed.
26const LOOK = {
27 info: { color: GOLD, frame: '#88888855', fill: '#88888814' },
28 warn: { color: '#f2a33a', frame: '#f2a33a', fill: '#f2a33a22' },
29 urgent: { color: '#e5584d', frame: '#e5584d', fill: '#e5584d2a' },
30} as const
31const KEY = 'pet'
32const pet = atom({ plugin: 'hamster', key: 'pet' } as const, newPet(0))
33const frame = atom({ plugin: 'hamster', key: 'frame' } as const, 0) // the tick
34const menu = atom({ plugin: 'hamster', key: 'menu' } as const, false) // the language menu
35const tip = atom({ plugin: 'hamster', key: 'tip' } as const, {
36 text: '',
37 tag: '',
38 level: 'info',
39} as Tip)
40
41// Kept for the life of the session; a hot reload starts them over.
42const toasted = new Set<string>()
43let lastTurnAt = 0
44let lastStatus = ''
45// Claude's language, looked up again every minute.
46let lang: Lang = 'en'
47let langAt = -Infinity
48// The person's own pick, kept in the store; 'auto' follows Claude.
49let pref: Pref = 'auto'
50// Warnings the person has waved off: they stay quiet until their cause clears.
51const dismissed = new Set<string>()
52
53// The tip slot the back button holds on screen, and until when; null follows the clock.
54const HOLD_MS = 60000
55let held: number | null = null
56let heldAt = 0
57
58// Follows Claude's language: its `language` setting, else the system locale.
59async function speak($: EngineInterface, now: number) {
60 if (now - langAt < 60000) return
61 langAt = now
62
63 if (pref !== 'auto') {
64 lang = pref
65
66 return
67 }
68
69 try {
70 const settings = await $.settings.read()
71 const locale = await $.env.get('LANG')
72 lang = langOf(settings.language, locale)
73 } catch {
74 // Keeps the language it had.
75 }
76}
77
78async function loadPref($: EngineInterface) {
79 try {
80 const saved = await $.store.get('lang')
81 if (isPref(saved)) pref = saved
82 } catch {
83 // No saved pick: follow Claude.
84 }
85}
86
87// The person picked a language from the menu: keep it, and redraw in it.
88async function choose($: EngineInterface, picked: Pref) {
89 pref = picked
90 langAt = -Infinity
91 try {
92 await $.store.set('lang', picked)
93 } catch {
94 // The pick lasts for the session at least.
95 }
96 await speak($, await $.clock.now())
97 await update($, menu, () => false)
98 await sync($)
99}
100
101const asTip = (a: ReturnType<typeof advise>): Tip => ({
102 text: a.tip,
103 tag: a.tag,
104 level: a.level,
105 link: a.link,
106 href: a.href,
107 id: a.id,
108})
109
110// The band redraws twice a second, and a handle the host holds for a drawing
111// dies with it. So the buttons carry no behaviour of their own: they all share
112// this empty handler and every press is answered by the `ui.press` hook below,
113// which does not depend on which drawing the pointer was over.
114const noop = () => {}
115
116// Is a tip held on the card by the back and forward buttons?
117const browsing = (now: number) => held !== null && now - heldAt < HOLD_MS
118
119// "Forward": the tip after the held one; reaching the current one lets the
120// rotation carry on by itself.
121async function goForward($: EngineInterface) {
122 const now = await $.clock.now()
123 if (!browsing(now)) return
124 const next = held! + 1
125 held = next >= Math.floor(now / TIP_MS) ? null : next
126 heldAt = now
127 await showHeld($, now)
128}
129
130const LIMIT_TOAST_GAP_MS = 60 * 60 * 1000
131
132// True when no limit notice has been shown for an hour; records this one.
133async function limitToastDue($: EngineInterface, now: number): Promise<boolean> {
134 try {
135 const last = Number(await $.store.get('limitToastAt')) || 0
136 if (now - last < LIMIT_TOAST_GAP_MS) return false
137 await $.store.set('limitToastAt', now)
138 } catch {
139 // Without a store the notice still comes at most once per session and kind.
140 }
141
142 return true
143}
144
145// "Back": show the tip before the one on the card and keep it there for a
146// minute, so it can be read; then the rotation carries on.
147async function goBack($: EngineInterface) {
148 const now = await $.clock.now()
149 const live = browsing(now)
150 held = (live ? held! : Math.floor(now / TIP_MS)) - 1
151 heldAt = now
152
153 await showHeld($, now)
154}
155
156// Show the held (or, once released, the current) tip at once, without waiting
157// for the next reading of the context.
158async function showHeld($: EngineInterface, now: number) {
159 const p = await read($, pet)
160 const calm = advise(
161 { percent: p.food, fun: 100, ageMs: 0, idleMs: 0, limits: [], now, slot: held ?? undefined },
162 lang,
163 )
164 await update($, tip, () => asTip(calm))
165 await sync($)
166}
167
168// "Got it": the warning on the card gives way to the ordinary tips.
169async function dismiss($: EngineInterface, id: string) {
170 dismissed.add(id)
171 await sync($)
172
173 // If the context could not be read just now, still take the warning down;
174 // the band then shows an ordinary tip until the next reading.
175 const shown = await read($, tip)
176 if (shown.id === id) {
177 await update($, tip, () => ({ text: '', tag: '', level: 'info', link: null, href: null, id: null }))
178 }
179}
180
181// A warning comes back once what caused it has cleared, so that a later
182// one is not missed.
183function rearm(percent: number, fun: number, limits: { percentUsed: number }[]) {
184 if (percent < 50) {
185 dismissed.delete('ctx60')
186 dismissed.delete('ctx80')
187 }
188 if (fun >= 40) dismissed.delete('bored')
189 if (!limits.some(l => l.percentUsed >= 70)) {
190 for (const id of [...dismissed]) {
191 if (id.startsWith('limit')) dismissed.delete(id)
192 }
193 }
194}
195
196async function save($: EngineInterface, p: Pet) {
197 try {
198 await $.store.set(KEY, p)
199 } catch {
200 // A pet that cannot be saved just lives on in memory.
201 }
202}
203
204async function load($: EngineInterface): Promise<Pet | undefined> {
205 try {
206 return ((await $.store.get(KEY)) as Pet | undefined) ?? undefined
207 } catch {
208 return undefined
209 }
210}
211
212type Look = {
213 tokens: number
214 percent: number
215 // True when the figures are the local estimate, not a model reply's.
216 estimated: boolean
217 startedAt: number
218 limits: { kind: string; percentUsed: number }[]
219}
220
221// The session's context and limits, or null when the engine will not say
222// (so that nothing else in the mod depends on this call working).
223async function look($: EngineInterface): Promise<Look | null> {
224 try {
225 const u = await $.session.usage()
226 let { tokens, percent } = u.context
227 let estimated = false
228
229 // The figures come from the last model reply, so there are none right
230 // after /compact or /clear; the local estimate (free, no model call)
231 // is what the app itself shows in that gap.
232 if (percent === undefined) {
233 try {
234 const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
235 if (b) {
236 tokens = b.totalTokens
237 percent = Math.min(100, b.percentage)
238 estimated = true
239 }
240 } catch {
241 // keep the missing figures: they read as 0 below
242 }
243 }
244
245 return {
246 tokens: tokens ?? 0,
247 percent: percent ?? 0,
248 estimated,
249 startedAt: u.startedAt,
250 limits: u.rateLimits,
251 }
252 } catch {
253 return null
254 }
255}
256
257// Reads the session's context fill and lets the hamster react to it.
258async function sync($: EngineInterface) {
259 const now = await $.clock.now()
260 await speak($, now)
261 const seen0 = await look($)
262 if (!seen0) return
263 const { tokens, percent } = seen0
264 const before = await read($, pet)
265 let change: Change = null
266
267 await update($, pet, p => {
268 // The estimate differs from what a reply reports, so it only moves the
269 // fullness bar: read as a drop in tokens it would count as a clean-up and
270 // feed the joy. The real count, once a reply gives it, is compared with
271 // the last real one.
272 if (seen0.estimated) return { ...p, food: Math.max(0, Math.min(100, percent)) }
273 const seen = observe(p, tokens, percent, now)
274 change = seen.change
275
276 return seen.pet
277 })
278
279 const after = await read($, pet)
280 await save($, after)
281
282 rearm(percent, after.fun, seen0.limits)
283 const advice = advise(
284 {
285 percent,
286 fun: after.fun,
287 ageMs: now - seen0.startedAt,
288 idleMs: lastTurnAt === 0 ? 0 : now - lastTurnAt,
289 limits: seen0.limits,
290 now,
291 slot: browsing(now) ? held! : undefined,
292 },
293 lang,
294 dismissed,
295 )
296 const shown: Tip = {
297 text: advice.tip,
298 tag: advice.tag,
299 level: advice.level,
300 link: advice.link,
301 href: advice.href,
302 id: advice.id,
303 }
304 const shownBefore = await read($, tip)
305 if (JSON.stringify(shownBefore) !== JSON.stringify(shown)) {
306 await update($, tip, () => shown)
307 }
308
309 // A warning is also put on the status line, so it still shows when the
310 // band is folded away; a calm tip clears it.
311 const status = advice.level === 'info' ? '' : `${UI[lang].hamster}: ${advice.tag}. ${advice.tip}`
312 if (status !== lastStatus) {
313 lastStatus = status
314 $.ui.status(status === '' ? undefined : status)
315 }
316
317 // Context levels may warn again once the context has been cleared.
318 if (percent < 50) {
319 toasted.delete('ctx60')
320 toasted.delete('ctx80')
321 }
322 if (advice.toast && !toasted.has(advice.toast.key)) {
323 if (advice.toast.key.startsWith('limit')) {
324 // The limit notice is the one that nags: at most once an hour, across
325 // sessions and restarts too (the card keeps showing it meanwhile).
326 if (await limitToastDue($, now)) $.ui.toast(advice.toast.text)
327 } else {
328 toasted.add(advice.toast.key)
329 $.ui.toast(advice.toast.text)
330 }
331 }
332
333 if (change === 'cleaned') {
334 $.ui.toast(UI[lang].cleared)
335 }
336 if (levelOf(after.xp) > levelOf(before.xp)) {
337 $.ui.toast(UI[lang].levelUp(levelOf(after.xp), titleOf(levelOf(after.xp), lang)))
338 }
339}
340
341export const register: Register = on => {
342 on('session.start', async ($, e, next) => {
343 const now = await $.clock.now()
344 lastTurnAt = now
345 await loadPref($)
346 await speak($, now)
347
348 // The animation clock: a tick every half second, the same for any
349 // viewer, so what is on screen follows from the time alone.
350 // A redraw rebuilds the buttons under the pointer (the hover blinks and
351 // a press can fall between two drawings), so the clock only redraws the
352 // band when the hamster itself would look different.
353 let drawn = ''
354 $.clock.every(TICK_MS, async () => {
355 const at = Math.floor((await $.clock.now()) / TICK_MS)
356 const [p, t] = [await read($, pet), await read($, tip)]
357 const picture = hamsterSvg(p, at, undefined, t.level === 'info' ? null : t.level)
358 if (picture === drawn) return
359 drawn = picture
360 await update($, frame, () => at)
361 })
362 // The context is read every 3 s, and after each turn below.
363 $.clock.every(3000, async () => {
364 await sync($)
365 })
366
367 // Start counting from the context as it is now, awarding nothing.
368 const saved = await load($)
369 const seen = await look($)
370 const base = { ...newPet(now), ...saved }
371 await update($, pet, () =>
372 rebase(base, seen && !seen.estimated ? seen.tokens : base.ctx, seen?.percent ?? 0, now),
373 )
374
375 return next(e)
376 })
377
378 // Work in progress is not idleness: every prompt and tool call restarts it.
379 on('prompt.submit', async ($, e, next) => {
380 lastTurnAt = await $.clock.now()
381 toasted.delete('idle')
382 toasted.delete('cold')
383 dismissed.delete('idle')
384 dismissed.delete('cold')
385
386 return next(e)
387 })
388
389 on('tool.call', async ($, e, next) => {
390 lastTurnAt = await $.clock.now()
391
392 return next(e)
393 })
394
395 on('turn.complete', async ($, e, next) => {
396 // A turn ends the idle period, so the end-of-work reminders may fire again.
397 lastTurnAt = await $.clock.now()
398 toasted.delete('idle')
399 toasted.delete('cold')
400 dismissed.delete('idle')
401 dismissed.delete('cold')
402 await sync($)
403
404 return next(e)
405 })
406
407 on('ui.press', async ($, e, next) => {
408 if (e.plugin !== 'hamster') return next(e)
409
410 if (e.element === 'lang') {
411 await update($, menu, v => !v)
412 } else if (e.element === 'back') {
413 await goBack($)
414 } else if (e.element === 'forward') {
415 await goForward($)
416 } else if (e.element === 'dismiss') {
417 const shown = await read($, tip)
418 if (shown.id) await dismiss($, shown.id)
419 } else if (e.element.startsWith('lang-')) {
420 const pick = e.element.slice(5)
421 if (isPref(pick)) await choose($, pick)
422 } else {
423 return next(e)
424 }
425
426 return { element: e.element }
427 })
428
429 // The hamster lives in a band above the prompt: the hamster on the left,
430 // its numbers in a quiet column on the right, and the tip below in a
431 // framed card, the only plate in the band.
432 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
433 if (e.props.hasSurvey) return next(e)
434
435 const { Box, Text, Svg, Link, Button } = $.ui.resolve(e)
436 const open = await read($, menu)
437 const p = await read($, pet)
438 const tick = await read($, frame)
439 const stored = await read($, tip)
440 const clockNow = await $.clock.now()
441 // Until the first reading, the calm tip stands in so the card is never empty.
442 const advice =
443 stored.text !== ''
444 ? stored
445 : asTip(
446 advise(
447 { percent: p.food, fun: 100, ageMs: 0, idleMs: 0, limits: [], now: await $.clock.now() },
448 lang,
449 dismissed,
450 ),
451 )
452 const tone = LOOK[advice.level]
453 const { level, next: nextAt, share } = progressOf(p.xp)
454 const mood = moodOf(p)
455 const percent = Math.round(p.food)
456 const ui = UI[lang]
457 const title = titleOf(level, lang)
458
459 // Without the room or the vector element, one plain line.
460 if (e.surface !== 'desktop' || e.props.maxRows < 8) {
461 return <Text dimColor>{ui.compact(title, level, percent, Math.round(p.fun))}</Text>
462 }
463
464 const xpText =
465 nextAt === null ? `${Math.floor(p.xp)}` : `${Math.floor(p.xp)} / ${nextAt}`
466
467 // One label column, so the three bars start and end on the same line.
468 const row = (label: string, bar: string, alt: string, value: string) => (
469 <Box columnGap={2} alignItems="center">
470 <Box width={LABEL}>
471 <Text dimColor>{label}</Text>
472 </Box>
473 <Svg source={bar} alt={alt} width={barWidth(SEGMENTS)} height={BAR_HEIGHT} />
474 <Text dimColor>{value}</Text>
475 </Box>
476 )
477
478 // No plate of its own: the hamster and its numbers sit straight on the
479 // window. The same margin all round; one blank row between the blocks.
480 return (
481 <Box flexDirection="column" rowGap={1} paddingX={2} paddingY={1} position="relative">
482 <Box position="absolute" top={1} right={2} flexDirection="column" alignItems="flex-end">
483 <Button key="lang" plain onPress={noop}>
484 {flagOf(lang)}
485 </Button>
486 {open && (
487 <Box borderStyle="round" borderColor={tone.frame} flexDirection="column" paddingX={1}>
488 {LANGS.map(l => (
489 <Button key={`lang-${l.code}`} plain onPress={noop}>
490 {`${pref === l.code ? '✓' : ' '} ${l.flag} ${l.name}`}
491 </Button>
492 ))}
493 <Button key="lang-auto" plain onPress={noop}>
494 {`${pref === 'auto' ? '✓' : ' '} 🌐 ${ui.auto}`}
495 </Button>
496 </Box>
497 )}
498 </Box>
499 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={4}>
500 <Svg
501 source={hamsterSvg(p, tick, undefined, advice.level === 'info' ? null : advice.level)}
502 alt={`${ui.hamster}, ${ui.moods[mood]}`}
503 width={BAND.width}
504 height={BAND.height}
505 />
506 <Box flexDirection="column" flexGrow={1} minWidth={34} rowGap={1}>
507 <Box flexDirection="column">
508 <Box columnGap={1}>
509 <Text dimColor>{`${ui.level} ${level} / 10`}</Text>
510 <Text dimColor>·</Text>
511 <Text dimColor>{ui.moods[mood]}</Text>
512 {e.props.isWorking && <Text color={GOLD}>{ui.working}</Text>}
513 </Box>
514 <Text bold>{title}</Text>
515 </Box>
516 <Box flexDirection="column">
517 {row(
518 ui.xp,
519 barSvg(share, GOLD, SEGMENTS),
520 ui.xpAlt(Math.round(share)),
521 xpText,
522 )}
523 {row(
524 ui.fed,
525 barSvg(p.food, contextColor(p.food), SEGMENTS),
526 ui.fedAlt(percent),
527 `${ui.context} ${percent}%`,
528 )}
529 {row(
530 ui.joy,
531 barSvg(p.fun, JOY, SEGMENTS),
532 ui.joyAlt(Math.round(p.fun)),
533 `${Math.round(p.fun)}% ${ui.joyNote}`,
534 )}
535 </Box>
536 </Box>
537 </Box>
538 {advice.text !== '' && (
539 <Box
540 borderStyle="round"
541 borderColor={tone.frame}
542 backgroundColor={tone.fill}
543 paddingX={2}
544 columnGap={1}
545 alignItems="flex-start"
546 >
547 <Box width={TAG}>
548 <Text bold color={tone.color}>{advice.tag}</Text>
549 </Box>
550 <Box flexGrow={1} flexShrink={1} flexDirection="column">
551 <Text bold={advice.level !== 'info'} wrap="wrap">{advice.text}</Text>
552 {advice.href ? (
553 <Text wrap="wrap">
554 {'→ '}
555 <Link href={advice.href}>{advice.link ?? advice.href}</Link>
556 </Text>
557 ) : (
558 advice.link && <Text dimColor wrap="wrap">{`→ ${advice.link}`}</Text>
559 )}
560 </Box>
561 <Box columnGap={2} alignItems="flex-start">
562 {advice.level === 'info' && !advice.id && (
563 <Button key="back" plain onPress={noop}>
564 ←
565 </Button>
566 )}
567 {advice.level === 'info' && !advice.id && held !== null && browsing(clockNow) && (
568 <Button key="forward" plain onPress={noop}>
569 →
570 </Button>
571 )}
572 {advice.id && (
573 <Button key="dismiss" plain onPress={noop}>
574 {ui.gotIt}
575 </Button>
576 )}
577 </Box>
578 </Box>
579 )}
580 </Box>
581 )
582 })
583}
584hooks/anim.ts 89 lines1// When the hamster does something besides sit there. The time is cut into
2// slots of 20 s; most slots hold one small show, picked by weight, so a
3// sniff comes often and a shooting star almost never. Everything follows
4// from the tick alone, so a picture can be redrawn for any moment.
5
6export const TICK_MS = 500
7const SLOT = 40
8const CHANCE = 0.6
9
10export type AnimName =
11 | 'sniff'
12 | 'earflick'
13 | 'groom'
14 | 'munch'
15 | 'yawn'
16 | 'sleep'
17 | 'butterfly'
18 | 'dig'
19 | 'roll'
20 | 'star'
21 | 'coin'
22
23export type Tier = 'common' | 'uncommon' | 'rare' | 'legendary'
24
25export type Anim = { name: AnimName; step: number }
26
27type Spec = {
28 // How many ticks it lasts.
29 length: number
30 // Its share among the shows open at the level.
31 weight: number
32 tier: Tier
33 // The level it opens at, so there is always something new to catch.
34 level: number
35}
36
37export const ANIMS: Record<AnimName, Spec> = {
38 sniff: { length: 6, weight: 10, tier: 'common', level: 1 },
39 earflick: { length: 8, weight: 10, tier: 'common', level: 1 },
40 munch: { length: 12, weight: 8, tier: 'common', level: 1 },
41 groom: { length: 12, weight: 4, tier: 'uncommon', level: 1 },
42 yawn: { length: 10, weight: 4, tier: 'uncommon', level: 2 },
43 sleep: { length: 24, weight: 1.5, tier: 'rare', level: 3 },
44 butterfly: { length: 28, weight: 1.5, tier: 'rare', level: 4 },
45 dig: { length: 14, weight: 1.5, tier: 'rare', level: 5 },
46 roll: { length: 20, weight: 1.2, tier: 'rare', level: 6 },
47 star: { length: 16, weight: 0.4, tier: 'legendary', level: 8 },
48 coin: { length: 20, weight: 0.4, tier: 'legendary', level: 9 },
49}
50
51const NAMES = Object.keys(ANIMS) as AnimName[]
52
53// A fixed scramble of two numbers into one, the same on every run.
54const hash = (a: number, b: number) => {
55 let h = Math.imul(a ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(b + 0x7f4a7c15, 0xc2b2ae35)
56 h ^= h >>> 15
57 h = Math.imul(h, 0x2c1b3c6d)
58 h ^= h >>> 12
59 h = Math.imul(h, 0x297a2d39)
60 h ^= h >>> 15
61
62 return h >>> 0
63}
64
65export const animationAt = (tick: number, level: number, seed = 7): Anim | null => {
66 const slot = Math.floor(tick / SLOT)
67 const offset = tick - slot * SLOT
68
69 if ((hash(slot, seed) % 1000) / 1000 >= CHANCE) return null
70
71 const open = NAMES.filter(n => ANIMS[n].level <= level)
72 const total = open.reduce((n, name) => n + ANIMS[name].weight, 0)
73 let roll = ((hash(slot, seed + 1) % 100000) / 100000) * total
74 let name = open[0]!
75 for (const n of open) {
76 roll -= ANIMS[n].weight
77 if (roll < 0) {
78 name = n
79 break
80 }
81 }
82
83 const { length } = ANIMS[name]
84 const start = hash(slot, seed + 2) % (SLOT - length - 1)
85 if (offset < start || offset >= start + length) return null
86
87 return { name, step: offset - start }
88}
89hooks/advice.ts 147 lines1import { OFFICIAL, TIP_MS, tipAt } from './tips'
2import { UI } from './text'
3import type { Lang } from './text'
4
5const MIN = 60000
6const HOUR = 60 * MIN
7
8export type Look = {
9 percent: number
10 fun: number
11 ageMs: number
12 idleMs: number
13 limits: { kind: string; percentUsed: number }[]
14 now: number
15 // The calm tip's slot when one is held (the back button); else follows the clock.
16 slot?: number
17}
18
19// How loudly to say it: a quiet hint, a warning, or a plea.
20export type Level = 'info' | 'warn' | 'urgent'
21
22export type Advice = {
23 // Which warning this is, so it can be dismissed; null for a calm tip.
24 id: string | null
25 // Shown in the band, always: a few words for the label, then the text.
26 tag: string
27 tip: string
28 // Where to read more, for a recommendation; null for everything else.
29 link: string | null
30 // The link as an address a person can open (https://...), when there is one.
31 href: string | null
32 level: Level
33 // Shown once as a toast, when set; `key` keeps it from repeating.
34 toast: { key: string; text: string } | null
35}
36
37
38const hours = (ms: number) => Math.floor(ms / HOUR)
39
40const say = (
41 level: Level,
42 tag: string,
43 tip: string,
44 key: string | null,
45 link: string | null = null,
46 href: string | null = null,
47 id: string | null = key,
48): Advice => ({
49 id,
50 level,
51 tag,
52 tip,
53 link,
54 href,
55 toast: key ? { key, text: tip } : null,
56})
57
58// The warnings, worded for each language. Each takes the numbers it quotes.
59const SAY = {
60 ru: {
61 cold: ['КЭШ ОСТЫЛ', (h: number, p: number) => `Пауза ${h} ч, контекст ${p}%: компакт сейчас обойдётся дорого. Лучше /clear и новая сессия.`],
62 idle: ['КОНЕЦ РАБОТЫ?', (p: number) => `Сожми контекст (${p}%) сейчас: кэш ещё тёплый, завтра это будет в разы дороже.`],
63 old: ['СТАРАЯ СЕССИЯ', (h: number, p: number) => `Сессии ${h} ч, контекст ${p}%: компакт старого чата дорог. Лучше /clear и новая сессия.`],
64 ctx80: ['СЖАТЬ СЕЙЧАС', (p: number) => `Контекст ${p}%. Автокомпакт сработает вслепую и может потерять детали.`],
65 ctx60: ['ПОРА СЖИМАТЬ', (p: number) => `Контекст ${p}%: пока это дёшево.`],
66 limit: ['ЛИМИТ', (p: number, name: string) => `Использовано ${p}% ${name}: переключись на Sonnet и не запускай много агентов сразу.`],
67 bored: ['СКУЧАЕТ', () => 'Хомяк скучает: сожми или очисти контекст, когда будет удобно.'],
68 windows: { five_hour: '5-часового окна', seven_day: 'недельного окна', other: (k: string) => `лимита ${k}` },
69 },
70 en: {
71 cold: ['CACHE COLD', (h: number, p: number) => `Idle for ${h} h, context ${p}%: compacting now would be costly. Better /clear and start a new session.`],
72 idle: ['END OF WORK?', (p: number) => `Compact the context (${p}%) now: the cache is still warm, tomorrow it will cost many times more.`],
73 old: ['OLD SESSION', (h: number, p: number) => `Session is ${h} h old, context ${p}%: compacting an old chat is costly. Better /clear and start a new session.`],
74 ctx80: ['COMPACT NOW', (p: number) => `Context ${p}%. Auto-compact will run blind and may lose details.`],
75 ctx60: ['TIME TO COMPACT', (p: number) => `Context ${p}%: it's still cheap.`],
76 limit: ['LIMIT', (p: number, name: string) => `${p}% of the ${name} used: switch to Sonnet and don't launch many agents at once.`],
77 bored: ['BORED', () => 'The hamster is bored: compact or clear the context when convenient.'],
78 windows: { five_hour: '5-hour window', seven_day: 'weekly window', other: (k: string) => `${k} limit` },
79 },
80} as const
81
82// What to tell the person, most urgent first. A pure function of what the
83// session looks like right now. A warning the person has dismissed stays out
84// of the way (the next rule speaks instead) until the hamster re-arms it.
85export const advise = (
86 look: Look,
87 lang: Lang,
88 dismissed: ReadonlySet<string> = new Set(),
89): Advice => {
90 const w = SAY[lang]
91 const pct = Math.round(look.percent)
92 const worst = look.limits.reduce((a, l) => (l.percentUsed > a.percentUsed ? l : a), {
93 kind: '',
94 percentUsed: 0,
95 })
96 // A louder warning that was dismissed also silences the quieter ones of its kind.
97 const off = (...ids: string[]) => ids.some(id => dismissed.has(id))
98
99 if (pct >= 30 && look.idleMs >= HOUR && !off('cold')) {
100 return say('warn', w.cold[0], w.cold[1](hours(look.idleMs), pct), 'cold', null, null, 'cold')
101 }
102 if (pct >= 30 && look.idleMs >= 3 * MIN && !off('idle', 'cold')) {
103 return say('warn', w.idle[0], w.idle[1](pct), 'idle', null, null, 'idle')
104 }
105 if (pct >= 20 && look.ageMs >= 12 * HOUR && !off('old')) {
106 return say('warn', w.old[0], w.old[1](hours(look.ageMs), pct), null, null, null, 'old')
107 }
108 if (pct >= 80 && !off('ctx80')) {
109 return say('urgent', w.ctx80[0], w.ctx80[1](pct), 'ctx80', null, null, 'ctx80')
110 }
111 if (pct >= 60 && !off('ctx60', 'ctx80')) {
112 return say('warn', w.ctx60[0], w.ctx60[1](pct), 'ctx60', null, null, 'ctx60')
113 }
114 if (worst.percentUsed >= 70) {
115 const urgent = worst.percentUsed >= 90
116 const id = urgent ? `limit90-${worst.kind}` : `limit-${worst.kind}`
117 const name =
118 worst.kind === 'five_hour'
119 ? w.windows.five_hour
120 : worst.kind === 'seven_day'
121 ? w.windows.seven_day
122 : w.windows.other(worst.kind)
123
124 if (!off(id, `limit90-${worst.kind}`)) {
125 return say(
126 urgent ? 'urgent' : 'warn',
127 w.limit[0],
128 w.limit[1](Math.round(worst.percentUsed), name),
129 `limit-${worst.kind}`,
130 null,
131 null,
132 id,
133 )
134 }
135 }
136 if (look.fun < 25 && !off('bored')) {
137 return say('info', w.bored[0], w.bored[1](), null, null, null, 'bored')
138 }
139
140 const calm = tipAt(look.slot ?? Math.floor(look.now / TIP_MS))
141 const official = calm.link === OFFICIAL
142 const link = official ? UI[lang].official : calm.link
143 const href = calm.link === null || official ? null : `https://${calm.link}`
144
145 return say('info', UI[lang].tags[calm.kind], calm[lang], null, link, href, null)
146}
147hooks/tips.ts 164 lines1// The calm advice the hamster rotates through when nothing is urgent:
2// saving tokens, skills, plugins, MCP servers and ways of working.
3export type Kind = 'tokens' | 'skill' | 'plugin' | 'mcp' | 'collection' | 'trick'
4export type Tip = { kind: Kind; ru: string; en: string; link: string | null }
5
6// Stands in for a link: shown as a note that the plugin is in the official catalog.
7export const OFFICIAL = 'official'
8
9const t = (kind: Kind, ru: string, en: string, link: string | null = null): Tip => ({
10 kind,
11 ru,
12 en,
13 link,
14})
15const tokens = (ru: string, en: string, link: string | null = null) => t('tokens', ru, en, link)
16const skill = (ru: string, en: string, link: string | null = null) => t('skill', ru, en, link)
17const plugin = (ru: string, en: string, link: string | null = null) => t('plugin', ru, en, link)
18const mcp = (ru: string, en: string, link: string | null = null) => t('mcp', ru, en, link)
19
20export const TIPS: Tip[] = [
21 // Saving tokens.
22 tokens('Перед /compact добавь подсказку, что сохранить: /compact Сохрани цель, решения с причинами, файлы и следующие шаги.', 'Add a hint before /compact saying what to keep: /compact Keep the goal, decisions with reasons, files and next steps.'),
23 tokens('Простое отдавай Sonnet, Opus оставь для сложного: расход лимита заметно ниже.', 'Give routine work to Sonnet and keep Opus for the hard parts: it uses noticeably less of your limit.'),
24 tokens('/clear бесплатен: используй его между несвязанными задачами.', '/clear is free: use it between unrelated tasks.'),
25 tokens('Не продолжай вчерашние чаты: новая сессия с короткой справкой дешевле, чем старый контекст.', "Don't continue yesterday's chats: a new session with a short brief is cheaper than an old context."),
26 tokens('Просишь вывод команд? Проси кратко: длинные результаты быстрее всего раздувают контекст.', 'Asking for command output? Ask for it short: long results bloat the context fastest.'),
27 tokens('Лишние плагины и скиллы входят в каждый ход: отключай то, чем не пользуешься.', "Unused plugins and skills ride along on every turn: switch off what you don't use."),
28 tokens('Перед /clear попроси сохранить цель, решения и следующие шаги в файл, и продолжай с него в новой сессии.', 'Before /clear, ask Claude to save the goal, decisions and next steps to a file, and continue from it in a new session.'),
29 tokens('Кэш живёт недолго: сжимать контекст дешевле сразу после ответа, чем после паузы.', 'The cache expires quickly: compacting right after a reply is cheaper than after a break.'),
30 tokens('Как остывает кэш: пока он тёплый, Claude берёт за перечитывание чата всего 10% цены. Кэш живёт около 5 минут после последнего ответа (в некоторых режимах до часа) и продлевается с каждым новым сообщением.', 'How the cache cools: while it is warm, rereading the chat costs only 10% of the price. The cache lives about 5 minutes after the last reply (up to an hour in some modes) and is extended with every new message.', 'platform.claude.com/docs/en/build-with-claude/prompt-caching'),
31 tokens('Остывший кэш: после паузы весь чат записывается заново, это примерно в 12 раз дороже, чем читать из тёплого (1,25× против 0,1×). Одно сообщение или /compact в большом чате после перерыва стоит как несколько обычных ходов. Лучше /clear.', 'A cold cache: after a pause the whole chat is written again, roughly 12 times pricier than reading from a warm one (1.25× vs 0.1×). One message or /compact in a big chat after a break costs as much as several normal turns. Better /clear.'),
32 tokens('/context показывает, что съедает окно: MCP, скиллы, CLAUDE.md, история.', '/context shows what fills the window: MCP, skills, CLAUDE.md, history.'),
33 tokens('session-report: HTML-отчёт по сессиям с самыми дорогими промптами, кэшем и субагентами.', 'session-report: an HTML report of your sessions with the most expensive prompts, cache use and subagents.', OFFICIAL),
34 tokens('ccusage: расход по дням и моделям. Попроси: «запусти npx ccusage daily».', 'ccusage: spend by day and model. Ask: "run npx ccusage daily".', 'github.com/ccusage/ccusage'),
35 tokens('Serena: Claude находит нужные функции и классы, не читая файлы целиком. Большая экономия на крупных проектах.', 'Serena: Claude finds the right functions and classes without reading whole files. Big savings on large projects.', 'github.com/oraios/serena'),
36 tokens('Context7: документация нужной версии библиотеки вместо десяти попыток угадать API.', 'Context7: docs for the exact library version instead of ten attempts at guessing the API.', 'github.com/upstash/context7'),
37 tokens('claude-mem: сжимает прошлые сессии в заметки и подгружает только нужное.', "claude-mem: compresses past sessions into notes and loads only what's relevant.", 'github.com/thedotmack/claude-mem'),
38 tokens('claude-md-management: аудит CLAUDE.md, вычищает лишнее, которое грузится каждый ход.', 'claude-md-management: audits CLAUDE.md and trims what gets loaded on every turn.', OFFICIAL),
39 tokens('Каждый ход Claude перечитывает весь чат, поэтому 30-е сообщение стоит в десятки раз дороже первого.', 'Every turn Claude rereads the whole chat, so the 30th message costs many times more than the first.'),
40 tokens('Каждый MCP-сервер платит за описания своих инструментов в каждом ходе: держи включёнными только нужные.', 'Every MCP server pays for its tool descriptions on every turn: keep only the ones you need enabled.'),
41 tokens('@путь/файл вместо «найди, где там оплата»: не будет чтения полпроекта.', '@path/file instead of "find where the payment is": no reading half the project.'),
42 tokens('Поиск по большому коду отдай агенту Explore: тебе вернётся вывод, а не 50 файлов.', 'Hand searches over big code to the Explore agent: you get an answer back, not 50 files.'),
43 tokens('Своим агентам-поисковикам ставь model: haiku в .claude/agents/*.md.', 'Set model: haiku on your search agents in .claude/agents/*.md.'),
44 tokens('Effort high и max нужен только для сложного, на рутине хватит low или medium.', 'High and max effort are for the hard stuff; low or medium is enough for routine work.'),
45 tokens('Из лога давай последние 50 строк или саму ошибку. Скриншоты обрезай.', 'From a log, give the last 50 lines or just the error. Crop screenshots.'),
46 tokens('Все уточнения пиши одним сообщением, а не тремя подряд.', 'Put all clarifications in one message, not three in a row.'),
47 tokens('Неудачный ответ откати через /rewind, а не спорь: спор остаётся в контексте.', 'Roll back a bad reply with /rewind instead of arguing: the argument stays in the context.'),
48 tokens('Лимит считается 5-часовыми окнами: перед тяжёлой задачей проверь /usage.', 'The limit runs in 5-hour windows: check /usage before a heavy task.'),
49 tokens('Правило в скилле дешевле, чем в CLAUDE.md: от скилла в каждом ходе висит только описание.', "A rule in a skill is cheaper than one in CLAUDE.md: only a skill's description is carried each turn."),
50 tokens('Запрети через deny-правила чтение node_modules, dist и lock-файлов.', 'Use deny rules to keep Claude out of node_modules, dist and lock files.'),
51 tokens('5 параллельных агентов расходуют 5 контекстов: запускай флот, только если задача делится.', '5 parallel agents burn 5 contexts: run a fleet only when the task really splits.'),
52
53 // Skills.
54 skill('writing-skills (Superpowers): пишет и проверяет скиллы через тесты, как код.', 'writing-skills (Superpowers): writes and tests skills the way you test code.', 'github.com/obra/superpowers'),
55 skill('brainstorming (Superpowers): идея → вопросы → дизайн, и только потом код.', 'brainstorming (Superpowers): idea → questions → design, and only then code.', 'github.com/obra/superpowers'),
56 skill('writing-plans + executing-plans (Superpowers): план из шагов, проверка после каждого.', 'writing-plans + executing-plans (Superpowers): a plan in steps, checked after each one.', 'github.com/obra/superpowers'),
57 skill('systematic-debugging (Superpowers): сначала гипотезы и проверки, потом фикс.', 'systematic-debugging (Superpowers): hypotheses and checks first, the fix after.', 'github.com/obra/superpowers'),
58 skill('test-driven-development (Superpowers): падающий тест → код → тест проходит.', 'test-driven-development (Superpowers): failing test → code → test passes.', 'github.com/obra/superpowers'),
59 skill('subagent-driven-development (Superpowers): каждый шаг плана делает свежий агент, следующий проверяет его работу.', 'subagent-driven-development (Superpowers): a fresh agent does each plan step and the next one reviews its work.', 'github.com/obra/superpowers'),
60 skill('verification-before-completion (Superpowers): «готово» только с доказательством.', 'verification-before-completion (Superpowers): "done" only with proof.', 'github.com/obra/superpowers'),
61 skill('using-git-worktrees + finishing-a-development-branch (Superpowers): изолированная ветка и аккуратный финал.', 'using-git-worktrees + finishing-a-development-branch (Superpowers): an isolated branch and a tidy finish.', 'github.com/obra/superpowers'),
62 skill('pr-review-toolkit: silent-failure-hunter ищет проглоченные ошибки и пустые catch.', 'pr-review-toolkit: silent-failure-hunter finds swallowed errors and empty catch blocks.', OFFICIAL),
63 skill('hookify: «/hookify не давай коммитить в main» делает хук из одной фразы.', 'hookify: "/hookify don\'t let me commit to main" turns one sentence into a hook.', OFFICIAL),
64 skill('skill-creator: создаёт скилл и прогоняет его на тестовых задачах.', 'skill-creator: builds a skill and runs it on test tasks.', OFFICIAL),
65 skill('find-skills: «найди скилл для X» ищет в каталоге skills.sh.', 'find-skills: "find a skill for X" searches the skills.sh catalog.', 'github.com/vercel-labs/skills'),
66 skill('high-end-visual-design: шрифты, отступы и тени дорогого лендинга.', 'high-end-visual-design: the fonts, spacing and shadows of an expensive landing page.', 'github.com/leonxlnx/taste-skill'),
67 skill('better-colors: палитра, токены, проверка контраста.', 'better-colors: palettes, tokens and contrast checks.', 'github.com/jakubkrehel/skills'),
68 skill('pixel-art-sprites: спрайты и анимация, им рисовали этого хомяка.', 'pixel-art-sprites: sprites and animation; it drew this hamster.', 'github.com/omer-metin/skills-for-antigravity'),
69 skill('remotion-best-practices: видео, написанное кодом на React.', 'remotion-best-practices: video written as React code.', 'github.com/remotion-dev/skills'),
70 skill('web-perf: аудит скорости сайта (Core Web Vitals) через Chrome DevTools.', 'web-perf: a site speed audit (Core Web Vitals) through Chrome DevTools.', 'github.com/cloudflare/skills'),
71 skill('agent-reach: Reddit, X, YouTube, GitHub и другие площадки в одном скилле.', 'agent-reach: Reddit, X, YouTube, GitHub and other platforms in one skill.', 'github.com/Panniantong/Agent-Reach'),
72 skill('marketing-psychology: ментальные модели для лендингов и цен.', 'marketing-psychology: mental models for landing pages and pricing.', 'github.com/coreyhaines31/marketingskills'),
73 skill('docx, pdf, xlsx, pptx: настоящие файлы, таблицы с формулами, заполнение PDF-форм.', 'docx, pdf, xlsx, pptx: real files, spreadsheets with formulas, PDF form filling.', 'github.com/anthropics/skills'),
74 skill('deep-research: многошаговое исследование с источниками. Встроен в Claude.', 'deep-research: multi-step research with sources. Built into Claude.'),
75 skill('dataviz: графики и дашборды в едином стиле, со светлой и тёмной темой. Встроен в Claude.', 'dataviz: charts and dashboards in one style, light and dark. Built into Claude.'),
76 skill('/code-review, /simplify, /security-review: баги, чистка и уязвимости в текущем диффе. Встроены в Claude Code.', '/code-review, /simplify, /security-review: bugs, cleanup and vulnerabilities in the current diff. Built into Claude Code.'),
77 skill('/fewer-permission-prompts: добавляет безопасные команды в список разрешённых, вопросов «разрешить?» меньше.', '/fewer-permission-prompts: adds safe commands to the allowlist, so fewer "allow?" questions.'),
78 skill('engineering и product-management: /debug, /system-design, /write-spec (спецификация фичи из одной фразы).', 'engineering and product-management: /debug, /system-design, /write-spec (a feature spec from one sentence).', 'github.com/anthropics/knowledge-work-plugins'),
79 skill('anthropics/skills: официальная коллекция скиллов Anthropic.', "anthropics/skills: Anthropic's official skills collection.", 'github.com/anthropics/skills'),
80 skill('frontend-design: самый устанавливаемый скилл, убирает «ИИ-шный» вид интерфейса.', 'frontend-design: the most installed skill; it removes the "AI look" from interfaces.', OFFICIAL),
81 skill('webapp-testing: Claude пишет Playwright-скрипт и сам проверяет приложение.', 'webapp-testing: Claude writes a Playwright script and tests your app itself.', 'github.com/anthropics/skills'),
82 skill('mcp-builder: свой MCP-сервер по лучшим практикам.', 'mcp-builder: your own MCP server, built by best practice.', 'github.com/anthropics/skills'),
83 skill('doc-coauthoring: совместное написание документа по этапам.', 'doc-coauthoring: write a document together, stage by stage.', 'github.com/anthropics/skills'),
84 skill('algorithmic-art: генеративная графика на p5.js.', 'algorithmic-art: generative graphics in p5.js.', 'github.com/anthropics/skills'),
85 skill('react-best-practices от Vercel: правила производительности React и Next.', 'react-best-practices by Vercel: performance rules for React and Next.', 'github.com/vercel-labs/agent-skills'),
86 skill('web-design-guidelines от Vercel: аудит интерфейса на доступность и UX.', 'web-design-guidelines by Vercel: an interface audit for accessibility and UX.', 'github.com/vercel-labs/agent-skills'),
87 skill('ui-ux-pro-max: стили, палитры и шрифты под тип продукта.', 'ui-ux-pro-max: styles, palettes and fonts matched to the product type.', 'github.com/nextlevelbuilder/ui-ux-pro-max-skill'),
88 skill('trailofbits/skills: аудит безопасности от Trail of Bits.', 'trailofbits/skills: security auditing from Trail of Bits.', 'github.com/trailofbits/skills'),
89 skill('frontend-slides: анимированные презентации в HTML.', 'frontend-slides: animated presentations in HTML.', 'github.com/zarazhangrui/frontend-slides'),
90 skill('dev-browser: быстрый браузер для Claude, помнит состояние между шагами.', 'dev-browser: a fast browser for Claude that keeps state between steps.', 'github.com/SawyerHood/dev-browser'),
91 skill('playwright-skill: браузерные проверки на лету.', 'playwright-skill: browser checks on the fly.', 'github.com/lackeyjb/playwright-skill'),
92 skill('expo/skills: React Native от авторов Expo.', 'expo/skills: React Native from the Expo team.', 'github.com/expo/skills'),
93 skill('cloudflare/skills: Workers, Durable Objects и Agents SDK.', 'cloudflare/skills: Workers, Durable Objects, Agents SDK.', 'github.com/cloudflare/skills'),
94 skill('scientific-agent-skills: научные базы и библиотеки для анализа данных.', 'scientific-agent-skills: scientific databases and libraries for data analysis.', 'github.com/K-Dense-AI/scientific-agent-skills'),
95 skill('elements-of-style: ясный текст по правилам Странка.', "elements-of-style: clear writing by Strunk's rules.", 'github.com/obra/superpowers-marketplace'),
96 skill('episodic-memory: поиск по прошлым разговорам с Claude и Codex.', 'episodic-memory: search across past conversations with Claude and Codex.', 'github.com/obra/episodic-memory'),
97 skill('marketingskills целиком: копирайтинг, SEO, CRO, цены.', 'The whole marketingskills set: copywriting, SEO, CRO, pricing.', 'github.com/coreyhaines31/marketingskills'),
98 skill('skills.sh: каталог скиллов с рейтингом установок, видно, что ставят другие.', 'skills.sh: a skills catalog ranked by installs, so you can see what others use.', 'skills.sh'),
99 skill('plugin-authoring: свои моды, панели и полосы, как этот хомяк. Встроен в Claude Code.', 'plugin-authoring: build your own mods, panels and bands, like this hamster. Built into Claude Code.'),
100
101 // Plugins.
102 plugin('feature-dev: исследование кода → архитектура → реализация → ревью.', 'feature-dev: code exploration → architecture → implementation → review.', OFFICIAL),
103 plugin('code-review: ревью PR несколькими агентами, ложные находки отсекаются по уверенности.', 'code-review: PR review by several agents; false findings are cut by confidence.', OFFICIAL),
104 plugin('security-guidance: предупреждения прямо во время правок плюс ревью диффа.', 'security-guidance: warnings while you edit, plus a diff review.', OFFICIAL),
105 plugin('claude-security: глубокий скан уязвимостей, каждая находка перепроверяется.', 'claude-security: a deep vulnerability scan; every finding is re-checked.', OFFICIAL),
106 plugin('claude-code-setup: смотрит проект и советует хуки, скиллы, MCP и агентов.', 'claude-code-setup: looks at your project and suggests hooks, skills, MCP servers and agents.', OFFICIAL),
107 plugin('commit-commands: коммит, push и PR одной командой.', 'commit-commands: commit, push and open a PR in one command.', OFFICIAL),
108 plugin('code-simplifier: упрощает свежий код, поведение не меняется.', 'code-simplifier: simplifies fresh code, behavior unchanged.', OFFICIAL),
109 plugin('ralph-loop: Claude крутит задачу в цикле, пока не выполнит критерий.', 'ralph-loop: Claude loops on a task until the success criterion is met.', OFFICIAL),
110 plugin('playground: интерактивные HTML-песочницы с ползунками и живым превью.', 'playground: interactive HTML playgrounds with sliders and a live preview.', OFFICIAL),
111 plugin('typescript-lsp, pyright-lsp: Claude видит ошибки типов сразу, как в IDE.', 'typescript-lsp, pyright-lsp: Claude sees type errors right away, like an IDE.', OFFICIAL),
112 plugin('learning-output-style: ключевые куски кода Claude оставляет писать тебе, так ты учишься.', 'learning-output-style: Claude leaves the key pieces of code for you to write, so you learn.', OFFICIAL),
113 plugin('receipts: отчёт о том, что ты сделал с Claude, для самооценки или руководителя.', 'receipts: a report of what you did with Claude, for a self-review or your manager.', OFFICIAL),
114 plugin('project-artifact: живая страница статуса проекта по приватной ссылке.', 'project-artifact: a living project status page behind a private link.', OFFICIAL),
115 plugin('double-shot-latte: убирает вопрос «продолжить?», Claude сам решает, доделывать ли.', 'double-shot-latte: removes the "shall I continue?" question; Claude decides whether to carry on.', 'github.com/obra/superpowers-marketplace'),
116 plugin('coderabbit, greptile: внешний ИИ-ревьюер PR со статическими анализаторами.', 'coderabbit, greptile: an outside AI reviewer for PRs with static analyzers.', OFFICIAL),
117
118 // MCP servers.
119 mcp('Playwright MCP: Claude кликает по твоему приложению.', 'Playwright MCP: Claude clicks through your app.', 'github.com/microsoft/playwright-mcp'),
120 mcp('Chrome DevTools MCP: консоль, сеть, трейсы производительности.', 'Chrome DevTools MCP: console, network, performance traces.', 'github.com/ChromeDevTools/chrome-devtools-mcp'),
121 mcp('GitHub MCP: задачи, PR и Actions.', 'GitHub MCP: issues, PRs, Actions.', 'github.com/github/github-mcp-server'),
122 mcp('Sentry: от стека ошибки сразу к месту в коде.', "Sentry: from an error's stack trace straight to the place in code.", OFFICIAL),
123 mcp('Figma: код по макету вместе с токенами дизайна.', 'Figma: code from the design, design tokens included.', OFFICIAL),
124 mcp('Supabase MCP или Neon: база и миграции, к проду только read-only.', 'Supabase MCP or Neon: database and migrations; read-only for production.', 'github.com/supabase/mcp'),
125 mcp('Firecrawl: любой сайт превращает в чистый markdown.', 'Firecrawl: turns any site into clean markdown.', 'github.com/firecrawl/firecrawl-mcp-server'),
126 mcp('Exa: поиск по смыслу и глубокие исследования.', 'Exa: search by meaning and deep research.', 'github.com/exa-labs/exa-mcp-server'),
127 mcp('Linear, Notion, Atlassian: задача из трекера → код → статус обратно.', 'Linear, Notion, Atlassian: task from the tracker → code → status back.', OFFICIAL),
128 mcp('Vercel, PostHog: деплои и логи, аналитика и фичефлаги прямо в чате.', 'Vercel, PostHog: deploys and logs, analytics and feature flags right in the chat.', OFFICIAL),
129
130 // Collections and ways of working.
131 t('collection', 'awesome-claude-code: главный каталог скиллов, хуков и команд.', 'awesome-claude-code: the main catalog of skills, hooks and commands.', 'github.com/hesreallyhim/awesome-claude-code'),
132 t('collection', 'awesome-claude-skills от Composio.', 'awesome-claude-skills by Composio.', 'github.com/ComposioHQ/awesome-claude-skills'),
133 t('collection', 'wshobson/agents: большая коллекция готовых субагентов и плагинов.', 'wshobson/agents: a big collection of ready-made subagents and plugins.', 'github.com/wshobson/agents'),
134 t('collection', 'awesome-claude-code-subagents: сотни ролей, копируешь нужную в .claude/agents.', 'awesome-claude-code-subagents: hundreds of roles; copy the one you want into .claude/agents.', 'github.com/VoltAgent/awesome-claude-code-subagents'),
135 t('collection', 'claude-code-templates: агенты, команды, хуки и MCP, ставятся одной командой.', 'claude-code-templates: agents, commands, hooks and MCP, installed with one command.', 'github.com/davila7/claude-code-templates'),
136 t('trick', 'Слово «ultracode» включает оркестрацию десятков агентов: для огромных задач, дорого.', 'The word "ultracode" turns on orchestration of dozens of agents: for huge tasks, and it\'s costly.'),
137 t('trick', 'Каждая сессия десктопа может работать в своём worktree: две фичи параллельно без конфликтов.', 'Every desktop session can work in its own worktree: two features in parallel without conflicts.'),
138 t('trick', 'Remote Control: отвечай Claude с телефона, пока он работает.', 'Remote Control: answer Claude from your phone while it works.'),
139 t('trick', '/schedule и /loop: агент по расписанию, например ночные тесты или утренний обзор PR.', '/schedule and /loop: an agent on a timer, like nightly tests or a morning PR digest.'),
140]
141
142// A fixed shuffle, so neighbours in the list never show back to back and
143// the order looks random while staying the same for everyone.
144const order = (n: number): number[] => {
145 const idx = Array.from({ length: n }, (_, i) => i)
146 let seed = 0x9e3779b9
147 for (let i = n - 1; i > 0; i--) {
148 seed = (Math.imul(seed ^ (seed >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0
149 const j = seed % (i + 1)
150 ;[idx[i], idx[j]] = [idx[j]!, idx[i]!]
151 }
152 return idx
153}
154
155const ORDER = order(TIPS.length)
156
157// How long one calm tip stays on the card.
158export const TIP_MS = 20000
159
160// The tip for a given slot of TIP_MS: a new one every 20 seconds, none
161// repeated until all have been shown.
162export const tipAt = (slot: number): Tip =>
163 TIPS[ORDER[((slot % TIPS.length) + TIPS.length) % TIPS.length]!]!
164hooks/art.ts 263 lines1import type { Pet } from '../types'
2
3import { animationAt } from './anim'
4import type { Anim } from './anim'
5import { GEAR, TOMBSTONE } from './gear'
6import type { Scene } from './gear'
7import { COLOR } from './palette'
8import type { Key } from './palette'
9import { levelOf, moodOf } from './pet'
10import { play } from './play'
11
12const W = 48
13const H = 34
14const SCALE = 7
15const GROUND = 31
16
17type Ellipse = readonly [number, number, number, number]
18
19const inside = (x: number, y: number, [cx, cy, rx, ry]: Ellipse) =>
20 ((x + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - cy) / ry) ** 2 <= 1
21
22// The shading contour: below 1 is inside the ellipse.
23const field = (x: number, y: number, [cx, cy, rx, ry]: Ellipse) =>
24 ((x + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - cy) / ry) ** 2
25
26// The outline takes a dark version of the fur it wraps.
27const OUTLINE: Partial<Record<Key, Key>> = { O: 'k', L: 'k', C: 'c', P: 'k', M: 'k' }
28
29const rect = (x: number, y: number, w: number, color: Key) =>
30 `<rect x="${x}" y="${y}" width="${w}" height="1" fill="${COLOR[color]}"/>`
31
32// A side view of a golden hamster, facing right, drawn pixel by pixel.
33// No mouth and no smile. The body follows the food: a bare-ribbed skeleton
34// near zero, a stuffed ball at a hundred, and at zero it lies dead belly up
35// beside a tombstone, with crosses for eyes. Each level has its own gear
36// (see gear.ts), and now and then it does something (see anim.ts). It never
37// bobs: it sits still between shows. `tick` counts every half second, the
38// blink comes every eighteenth. `forced` shows one show regardless of the clock.
39export const hamsterSvg = (
40 pet: Pet,
41 tick = 0,
42 forced?: Anim | null,
43 alert: 'warn' | 'urgent' | null = null,
44) => {
45 const mood = moodOf(pet)
46 const isDead = mood === 'dead'
47 const level = levelOf(pet.xp)
48 const f = Math.max(0, Math.min(1, pet.food / 100))
49 const frame = Math.floor(tick / 3)
50 const anim = isDead ? null : forced !== undefined ? forced : animationAt(tick, level)
51 const name = anim?.name
52 const step = anim?.step ?? 0
53 const isFlat =
54 mood === 'hungry' ||
55 mood === 'bored' ||
56 isDead ||
57 (name === 'earflick' && [1, 2, 5, 6].includes(step)) ||
58 name === 'sleep'
59 const isBlink =
60 !isDead &&
61 (tick % 18 === 17 ||
62 mood === 'bored' ||
63 name === 'sleep' ||
64 name === 'groom' ||
65 name === 'yawn')
66 const sniff = name === 'sniff' && step % 2 === 1
67 // The only vertical motion there is: the slow breath of sleep and the
68 // shaking of a dig. Idle, the hamster does not bob.
69 const dy = name === 'dig' ? step % 2 : name === 'sleep' ? Math.floor(step / 4) % 2 : 0
70
71 const bodyRy = 5 + 7.5 * f
72 const bodyRx = 11 + 6.5 * f
73 const bodyCy = GROUND - bodyRy
74 const headCy = GROUND - 9 - 5 * f
75 const cheek = 3.5 + 4 * f + (name === 'munch' && step % 2 === 1 ? 1 : 0)
76 const hy = Math.round(headCy)
77
78 const grid: (Key | null)[][] = Array.from({ length: H }, () =>
79 Array.from({ length: W }, () => null),
80 )
81 const paint = (shape: Ellipse, color: (x: number, y: number) => Key) => {
82 for (let y = 0; y < H; y++) {
83 for (let x = 0; x < W; x++) {
84 if (inside(x, y - dy, shape)) {
85 grid[y]![x] = color(x, y - dy)
86 }
87 }
88 }
89 }
90 // Paints over what is already drawn and adds no new pixels to the shape.
91 const over = (shape: Ellipse, color: (x: number, y: number) => Key) => {
92 for (let y = 0; y < H; y++) {
93 for (let x = 0; x < W; x++) {
94 if (grid[y]![x] && inside(x, y - dy, shape)) {
95 grid[y]![x] = color(x, y - dy)
96 }
97 }
98 }
99 }
100 const dot = (x: number, y: number, color: Key) => {
101 const row = grid[y + dy]
102 if (row && x >= 0 && x < W) row[x] = color
103 }
104 const scene: Scene = {
105 dot,
106 at: (x, y) => grid[y + dy]?.[x] ?? null,
107 leftmost: y => {
108 const i = (grid[y + dy] ?? []).findIndex(Boolean)
109
110 return i < 0 ? W : i
111 },
112 width: W,
113 ground: GROUND,
114 hy,
115 top: hy - 7,
116 ey: hy - 2,
117 bodyCy,
118 bodyRy,
119 bodyRx,
120 frame,
121 }
122 const gear = isDead ? {} : (GEAR[level] ?? {})
123
124 gear.pre?.(scene)
125
126 // Light comes from the upper left. The back is shaded in a crescent that
127 // follows the body, the belly catches light from below, and every step
128 // blends into the next through a checkerboard, never a straight band.
129 const lift = bodyRy * 0.4
130 paint([20, bodyCy, bodyRx, bodyRy], (x, y) => {
131 const checker = (x + y) % 2 === 0
132 const back = field(x, y, [20, bodyCy + lift, bodyRx, bodyRy])
133 const belly = field(x, y, [20, bodyCy - lift * 1.1, bodyRx, bodyRy])
134
135 if (belly > 1) return 'C'
136 if (belly > 0.9) return checker ? 'C' : 'L'
137 if (belly > 0.78) return 'L'
138 if (back > 1) return 'D'
139 if (back > 0.92) return checker ? 'D' : 'B'
140 if (back > 0.8) return 'B'
141 if (back > 0.72) return checker ? 'B' : 'O'
142
143 return 'O'
144 })
145 // A highlight on the shoulder and rump, where the light lands first.
146 over([12, bodyCy - bodyRy * 0.62, 4, 1.6], () => 'B')
147 over([13, bodyCy - bodyRy * 0.6, 2, 0.8], () => 'O')
148 paint([14, GROUND - 0.5, 4, 1.8], () => 'P')
149 paint([30, GROUND - 0.5, 3.5, 1.8], () => 'P')
150 paint([32, headCy, 8.5, 7], (x, y) => {
151 const checker = (x + y) % 2 === 0
152 const crown = field(x, y, [32, headCy + 2.5, 8.5, 7])
153 const jaw = field(x, y, [32, headCy - 3, 8.5, 7])
154
155 if (jaw > 1) return 'L'
156 if (jaw > 0.9) return checker ? 'L' : 'O'
157 if (crown > 1) return 'B'
158 if (crown > 0.9) return checker ? 'B' : 'O'
159
160 return 'O'
161 })
162 over([29, headCy - 4, 2.6, 1.3], () => 'M')
163 paint([39, headCy + 2, 3.5, 2.6], () => 'L')
164 paint([33.5, headCy + 4, cheek, cheek - 1], () => 'L')
165 paint(
166 isFlat ? [27.5, headCy - 5, 3, 2] : [28, headCy - 7, 3, 3.4],
167 () => 'B',
168 )
169 paint(
170 isFlat ? [27.5, headCy - 4.8, 1.5, 1] : [28, headCy - 6.4, 1.6, 2],
171 () => 'P',
172 )
173
174 gear.mid?.(scene)
175
176 // A one-pixel dark edge around the whole silhouette. The legend gets a
177 // gold one that shimmers.
178 const edge: [number, number, Key][] = []
179 for (let y = 0; y < H; y++) {
180 for (let x = 0; x < W; x++) {
181 if (!grid[y]![x]) continue
182 const open = [grid[y - 1]?.[x], grid[y + 1]?.[x], grid[y]![x - 1], grid[y]![x + 1]]
183 if (open.some(n => !n)) edge.push([x, y, grid[y]![x]!])
184 }
185 }
186 const gold: Key | null = level === 10 && !isDead ? (frame % 2 === 0 ? 'Y' : 'S') : null
187 for (const [x, y, own] of edge) grid[y]![x] = gold ?? OUTLINE[own] ?? 'K'
188
189 // A starving hamster shows its ribs.
190 if (f < 0.35) {
191 for (const x of [11, 14, 17, 20, 23]) {
192 for (let k = 0; k < 3; k++) dot(x + (k > 1 ? 1 : 0), Math.round(bodyCy) - 1 + k, 'D')
193 }
194 }
195
196 const ey = hy - 2
197 if (isDead) {
198 for (const [x, y] of [
199 [35, 0], [37, 0], [36, 1], [35, 2], [37, 2],
200 ] as const) {
201 dot(x, ey + y, 'E')
202 }
203 } else if (isBlink) {
204 dot(36, ey + 1, 'D')
205 dot(37, ey + 1, 'D')
206 } else {
207 dot(36, ey, 'H')
208 dot(37, ey, 'E')
209 dot(36, ey + 1, 'E')
210 dot(37, ey + 1, 'E')
211 }
212 const nx = sniff ? 1 : 0
213 dot(41 + nx, hy, 'P')
214 dot(42 + nx, hy, 'P')
215 dot(41 + nx, hy + 1, 'P')
216
217 gear.post?.(scene)
218 if (anim) play(anim, scene)
219
220 // A warning shows as an exclamation mark beside the head: it blinks for a
221 // warning and stays lit when it is urgent.
222 if (alert && !isDead && (alert === 'urgent' || tick % 2 === 0)) {
223 const mark: Key = alert === 'urgent' ? 'R' : 'Q'
224 for (const [x, y] of [
225 [44, 0], [45, 0], [44, 1], [45, 1], [44, 2], [45, 2], [44, 3], [45, 3], [44, 5], [45, 5],
226 ] as const) {
227 dot(x, scene.top - 6 + y, mark)
228 }
229 }
230
231 const rects: string[] = []
232 for (let y = 0; y < H; y++) {
233 let x = 0
234 while (x < W) {
235 const key = grid[y]![x]
236 if (!key) {
237 x++
238 continue
239 }
240 let end = x
241 while (grid[y]![end + 1] === key) end++
242 rects.push(rect(x, y, end - x + 1, key))
243 x = end + 1
244 }
245 }
246 const body = rects.join('')
247
248 // Dead: turned over inside its own height, so it still lies on the ground.
249 const top = grid.findIndex(row => row.some(Boolean))
250 const art = isDead
251 ? `<g transform="translate(0 ${GROUND + 1 + top}) scale(1 -1)">${body}</g>` +
252 TOMBSTONE.map((row, j) =>
253 [...row]
254 .map((c, i) => (c === '.' ? '' : `<rect x="${i}" y="${GROUND - 6 + j}" width="1" height="1" fill="${COLOR[c as Key]}"/>`))
255 .join(''),
256 ).join('')
257 : body
258
259 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * SCALE}" height="${H * SCALE}" shape-rendering="crispEdges">${art}</svg>`
260}
261
262export const SIZE = { width: W * SCALE, height: H * SCALE }
263hooks/bars.ts 42 lines1// Segmented pixel bars, drawn like the hamster: hard edges, whole pixels.
2// A filled cell has a light top edge; a half cell is dimmed; an empty cell
3// is a faint track that reads on a light and on a dark background alike.
4
5const CELL = 6
6const GAP = 1
7const HEIGHT = 8
8
9export const barWidth = (cells: number) => cells * (CELL + GAP) - GAP
10
11export const barSvg = (value: number, color: string, cells = 12) => {
12 const full = (Math.max(0, Math.min(100, value)) / 100) * cells
13 const parts: string[] = []
14
15 for (let i = 0; i < cells; i++) {
16 const x = i * (CELL + GAP)
17 const share = Math.max(0, Math.min(1, full - i))
18
19 if (share >= 0.99) {
20 parts.push(`<rect x="${x}" y="0" width="${CELL}" height="${HEIGHT}" fill="${color}"/>`)
21 parts.push(`<rect x="${x}" y="0" width="${CELL}" height="2" fill="#fff" fill-opacity="0.32"/>`)
22 } else if (share > 0.45) {
23 parts.push(`<rect x="${x}" y="0" width="${CELL}" height="${HEIGHT}" fill="${color}" fill-opacity="0.5"/>`)
24 } else {
25 parts.push(`<rect x="${x}" y="0" width="${CELL}" height="${HEIGHT}" fill="#888" fill-opacity="0.22"/>`)
26 }
27 }
28
29 const w = barWidth(cells)
30
31 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${HEIGHT}" width="${w}" height="${HEIGHT}" shape-rendering="crispEdges">${parts.join('')}</svg>`
32}
33
34export const BAR_HEIGHT = HEIGHT
35
36// The colours: gold for experience, and for the context the same steps as
37// the advice (calm up to 60%, amber to 80%, then red).
38export const GOLD = '#e0b04a'
39export const JOY = '#e68fb0'
40export const contextColor = (percent: number) =>
41 percent >= 80 ? '#e5584d' : percent >= 60 ? '#f2a33a' : '#58b368'
42hooks/pet.ts 135 lines1import type { Pet } from '../types'
2
3import { UI } from './text'
4import type { Lang } from './text'
5
6// Fun sinks on its own; food is not here: it is the session's context fill.
7const FUN_LOSS_PER_MIN = 0.8
8
9// A drop of at least this many tokens, to under 70% of before, is a clear
10// or a compaction.
11const MIN_FREED = 2000
12
13export type Mood = 'happy' | 'ok' | 'hungry' | 'bored' | 'dead'
14export type Change = 'grew' | 'cleaned' | null
15
16const clamp = (n: number) => Math.max(0, Math.min(100, n))
17
18export const newPet = (now: number): Pet => ({
19 name: 'Хомка',
20 xp: 0,
21 food: 0,
22 fun: 50,
23 at: now,
24 ctx: 0,
25})
26
27export const settle = (pet: Pet, now: number): Pet => {
28 const minutes = Math.max(0, (now - pet.at) / 60000)
29
30 return { ...pet, fun: clamp(pet.fun - minutes * FUN_LOSS_PER_MIN), at: now }
31}
32
33// Starts counting from the context as it is now, awarding nothing: a new
34// session begins small and must not read as a clear.
35export const rebase = (pet: Pet, tokens: number, percent: number, now: number): Pet => ({
36 ...settle(pet, now),
37 ctx: tokens,
38 food: clamp(percent),
39})
40
41// One look at the session's context.
42// grew: more tokens than before; 1 xp per 1000 tokens added.
43// cleaned: far fewer (a /clear or a compaction); xp for the tokens freed
44// and the only thing that raises fun.
45export const observe = (
46 pet: Pet,
47 tokens: number,
48 percent: number,
49 now: number,
50): { pet: Pet; change: Change } => {
51 const p = settle(pet, now)
52 const freed = p.ctx - tokens
53 const base = { ...p, ctx: tokens, food: clamp(percent) }
54
55 if (freed >= MIN_FREED && tokens < p.ctx * 0.7) {
56 return {
57 pet: {
58 ...base,
59 xp: p.xp + Math.max(5, freed / 500),
60 fun: clamp(p.fun + 25 + Math.min(35, freed / 4000)),
61 },
62 change: 'cleaned',
63 }
64 }
65 if (tokens > p.ctx) {
66 return { pet: { ...base, xp: p.xp + (tokens - p.ctx) / 1000 }, change: 'grew' }
67 }
68
69 return { pet: base, change: null }
70}
71
72// The xp each level starts at. Medium work earns about 1000 xp a day (a
73// context that grows by 1000 tokens is 1 xp; clearing earns more), so the
74// tenth level, 60 000 xp, is about 60 days away. The first step is 5 xp.
75export const LEVEL_XP = [0, 5, 100, 550, 1900, 4900, 10500, 20500, 36500, 60000] as const
76
77export const TITLES = UI.ru.titles
78
79// What each level wears or owns; the pictures are in gear.ts.
80export const GEAR_NAMES = [
81 'Семечко: первое имущество',
82 'Вопрос над головой: хочет всё знать',
83 'Мешок запасов и кучка семечек',
84 'Красная повязка и следы скорости',
85 'Каска с фонариком и куча земли',
86 'Горка зерна под охраной',
87 'Треуголка и свиток с картой',
88 'Борода, очки и стопка книг',
89 'Корона, красный плащ и горка золота',
90 'Нимб, тёмные очки, золотой контур и искры',
91] as const
92
93export const MAX_LEVEL = LEVEL_XP.length
94
95export const levelOf = (xp: number) => {
96 let level = 1
97 for (let i = 0; i < LEVEL_XP.length; i++) {
98 if (xp >= LEVEL_XP[i]!) level = i + 1
99 }
100
101 return level
102}
103
104export const gearOf = (level: number) =>
105 GEAR_NAMES[Math.max(1, Math.min(MAX_LEVEL, level)) - 1]!
106
107export const titleOf = (level: number, lang: Lang) =>
108 UI[lang].titles[Math.max(1, Math.min(MAX_LEVEL, level)) - 1]!
109
110// How far through the current level: where it started, where the next one
111// starts and the share done; `next` is null at the top.
112export const progressOf = (xp: number) => {
113 const level = levelOf(xp)
114 const from = LEVEL_XP[level - 1]!
115 const next = level >= MAX_LEVEL ? null : LEVEL_XP[level]!
116 const share = next === null ? 100 : ((xp - from) / (next - from)) * 100
117
118 return { level, from, next, share }
119}
120
121export const moodOf = (pet: Pet): Mood => {
122 if (pet.food < 1) return 'dead'
123 if (pet.food < 25) return 'hungry'
124 if (pet.fun < 25) return 'bored'
125 if (pet.food > 65 && pet.fun > 65) return 'happy'
126
127 return 'ok'
128}
129
130export const bar = (value: number) => {
131 const filled = Math.round(value / 10)
132
133 return '█'.repeat(filled) + '░'.repeat(10 - filled)
134}
135hooks/text.ts 128 lines1// Every word the mod shows, in the two languages it speaks. The language
2// follows Claude's own: the `language` setting, else the system locale.
3export type Lang = 'ru' | 'en'
4
5// Claude's language setting is free text ("russian", "Русский", "ru"): Russian
6// when it says so, any other language falls back to English. With no setting,
7// the system locale decides.
8export const langOf = (setting: unknown, locale: string | undefined): Lang => {
9 const said = typeof setting === 'string' ? setting.trim().toLowerCase() : ''
10
11 if (said !== '') return /^(ru\b|ru[-_]|russian|рус)/.test(said) ? 'ru' : 'en'
12
13 return /^ru/i.test(locale ?? '') ? 'ru' : 'en'
14}
15
16// The languages a person can pick, each with its flag.
17export const LANGS = [
18 { code: 'ru', flag: '🇷🇺', name: 'Русский' },
19 { code: 'en', flag: '🇬🇧', name: 'English' },
20] as const
21
22// What the person chose: one of the languages, or to follow Claude's own.
23export type Pref = 'auto' | Lang
24
25export const isPref = (value: unknown): value is Pref =>
26 value === 'auto' || value === 'ru' || value === 'en'
27
28export const flagOf = (lang: Lang) => LANGS.find(l => l.code === lang)!.flag
29
30export const UI = {
31 ru: {
32 level: 'УР.',
33 xp: 'Опыт',
34 fed: 'Сытость',
35 joy: 'Радость',
36 context: 'контекст',
37 working: '· Claude работает',
38 hamster: 'Хомяк',
39 moods: {
40 happy: 'сыт и активен',
41 ok: 'в норме',
42 hungry: 'голоден: контекст почти пуст',
43 bored: 'скучает: очисти или сожми контекст',
44 dead: 'не подаёт признаков жизни',
45 },
46 xpAlt: (n: number) => `Опыт: ${n}% до следующего уровня`,
47 fedAlt: (n: number) => `Сытость: контекст заполнен на ${n}%`,
48 joyAlt: (n: number) => `Радость ${n}%`,
49 cleared: 'Контекст очищен: хомяк получил опыт и радость.',
50 levelUp: (n: number, title: string) => `Новый уровень ${n}: ${title}!`,
51 compact: (title: string, level: number, ctx: number, joy: number) =>
52 `${title} · ур. ${level} · контекст ${ctx}% · радость ${joy}%`,
53 official: 'плагин из официального каталога',
54 language: 'Язык',
55 auto: 'Как у Claude',
56 gotIt: 'Понятно',
57 joyNote: '(/clear или /compact даст радость)',
58 tags: {
59 tokens: 'ТОКЕНЫ',
60 skill: 'СКИЛЛ',
61 plugin: 'ПЛАГИН',
62 mcp: 'MCP',
63 collection: 'ПОДБОРКА',
64 trick: 'ПРИЁМ',
65 },
66 titles: [
67 'Новорождённый хомячок',
68 'Любопытный хомячок',
69 'Запасливый хомяк',
70 'Ночной бегун',
71 'Мастер нор',
72 'Хранитель закромов',
73 'Хомяк-стратег',
74 'Мудрый хомяк',
75 'Владыка зернохранилища',
76 'Легендарный хомяк',
77 ],
78 },
79 en: {
80 level: 'LV.',
81 xp: 'XP',
82 fed: 'Fullness',
83 joy: 'Joy',
84 context: 'context',
85 working: '· Claude is working',
86 hamster: 'Hamster',
87 moods: {
88 happy: 'full and lively',
89 ok: 'doing fine',
90 hungry: 'hungry: the context is almost empty',
91 bored: 'bored: clear or compact the context',
92 dead: 'showing no signs of life',
93 },
94 xpAlt: (n: number) => `XP: ${n}% to the next level`,
95 fedAlt: (n: number) => `Fullness: the context is ${n}% full`,
96 joyAlt: (n: number) => `Joy ${n}%`,
97 cleared: 'Context cleared: the hamster gained XP and joy.',
98 levelUp: (n: number, title: string) => `Level ${n}: ${title}!`,
99 compact: (title: string, level: number, ctx: number, joy: number) =>
100 `${title} · lv. ${level} · context ${ctx}% · joy ${joy}%`,
101 official: 'official catalog plugin',
102 language: 'Language',
103 auto: 'Same as Claude',
104 gotIt: 'Got it',
105 joyNote: '(/clear or /compact will bring joy)',
106 tags: {
107 tokens: 'TOKENS',
108 skill: 'SKILL',
109 plugin: 'PLUGIN',
110 mcp: 'MCP',
111 collection: 'COLLECTION',
112 trick: 'TRICK',
113 },
114 titles: [
115 'Newborn Hamster',
116 'Curious Hamster',
117 'Hoarding Hamster',
118 'Night Runner',
119 'Burrow Master',
120 'Keeper of the Stash',
121 'Hamster Strategist',
122 'Wise Hamster',
123 'Lord of the Granary',
124 'Legendary Hamster',
125 ],
126 },
127} as const
128hooks/gear.ts 197 lines1import type { Key } from './palette'
2import { COLOR } from './palette'
3
4// What a gear piece needs to know about the hamster it is put on. Every
5// coordinate is in the picture's own pixels, before the idle bob.
6export type Scene = {
7 dot: (x: number, y: number, color: Key) => void
8 at: (x: number, y: number) => Key | null
9 leftmost: (y: number) => number
10 width: number
11 ground: number
12 hy: number
13 top: number
14 ey: number
15 bodyCy: number
16 bodyRy: number
17 bodyRx: number
18 frame: number
19}
20
21type Layers = {
22 // Drawn before the body, which covers it: things behind the hamster.
23 pre?: (s: Scene) => void
24 // Drawn on the body, before the outline: hats, a beard, a pile of loot.
25 // The outline then wraps them with the rest.
26 mid?: (s: Scene) => void
27 // Drawn last, over the outline.
28 post?: (s: Scene) => void
29}
30
31// Draws rows of letters from the palette; `.` leaves a pixel alone.
32const stamp = (s: Scene, rows: readonly string[], x0: number, y0: number) => {
33 rows.forEach((row, j) => {
34 for (let i = 0; i < row.length; i++) {
35 const c = row[i]!
36 if (c !== '.' && c in COLOR) s.dot(x0 + i, y0 + j, c as Key)
37 }
38 })
39}
40
41// Paints over what is already there and leaves empty pixels empty.
42const tint = (s: Scene, x: number, y: number, color: Key) => {
43 if (s.at(x, y)) s.dot(x, y, color)
44}
45
46const SEED = ['.DDDD.', 'DWDWDD', '.DDDD.']
47const SACK = ['..DD..', '.OOOO.', 'OOOOOO', 'OOLLOO', 'OOOOOO']
48const DIRT = ['...DD.....', '..DBBD....', '.DBBBBD...', 'DBBDBBBD..']
49const GRAIN = ['....OL....', '...OLOL...', '..OLOLOL..', '.OLOLOLOLO', 'OLOLOLOLOL']
50const COINS = ['....yY....', '...YyYy...', '..YyYyYY..', '.YyYYyYyYY', 'YYyYyYYyYy']
51const SCROLL = ['.CCCCC.', 'QCCRCCQ', '.CCCCC.']
52const BOOK = ['UUUUUUU', 'UYUUUYU', 'RRRRRRR', 'RYRRRYR']
53const QUESTION = ['.YYY.', 'Y...Y', '....Y', '..YY.', '..Y..', '.....', '..Y..']
54const HARD_HAT = ['....YY....', '..YYYYYY..', '.YYYYYYYY.', 'YYYYYYYYYY', 'QQQQQQQQQQ']
55const BICORNE = ['...NNNNNN...', '.NNNNNNNNNN.', 'NNNNNYYNNNNN', 'NNNNNNNNNNNN']
56const CROWN = ['Y.Y.Y.Y.Y', 'YYYYYYYYY', 'YRYYUYYRY']
57const BEARD = ['.WWWWW.', 'WWTWWTW', '.WWWWW.', '..WTW..', '...W...']
58const GLASSES = ['.GGGG.', 'G....G', 'G....G', '.GGGG.']
59const SUNGLASSES = ['NWNNNN', 'NNNNNN', '.NNNN.']
60const HALO = ['.YYYYYYY.', 'Y.......Y', '.YYYYYYY.']
61
62const SPARKLES = [
63 [5, 6],
64 [43, 7],
65 [8, 20],
66 [44, 22],
67 [25, 3],
68 [15, 11],
69] as const
70
71// Gear for each of the ten levels, by level.
72export const GEAR: Record<number, Layers> = {
73 // A sunflower seed, the first thing it owns.
74 1: {
75 mid: s => stamp(s, SEED, 41, s.ground - 2),
76 },
77
78 // A question mark over its head: it wants to know everything.
79 2: {
80 post: s => stamp(s, QUESTION, 35, s.top - 9),
81 },
82
83 // A sack of supplies behind it and a little heap of seeds in front.
84 3: {
85 pre: s => stamp(s, SACK, 1, s.ground - 4),
86 mid: s => {
87 stamp(s, ['..OLO..', '.OLOLO.', 'OLOLOLO'], 40, s.ground - 2)
88 },
89 },
90
91 // A red headband and speed lines behind: it runs all night.
92 4: {
93 post: s => {
94 for (let y = s.hy - 4; y <= s.hy - 3; y++) {
95 for (let x = 24; x <= 40; x++) tint(s, x, y, 'R')
96 }
97 stamp(s, ['RR', '.R', '..R'], 21, s.hy - 4)
98
99 const row = Math.round(s.bodyCy)
100 const left = s.leftmost(row)
101 if (left >= 5 && left < s.width) {
102 for (const dy of [-2, 1, 4]) {
103 for (let x = Math.max(0, left - 9); x < left - 1; x++) {
104 s.dot(x, row + dy, 'G')
105 }
106 }
107 }
108 },
109 },
110
111 // A hard hat with a lamp, and a heap of dug-up earth behind it.
112 5: {
113 pre: s => stamp(s, DIRT, 0, s.ground - 3),
114 mid: s => stamp(s, HARD_HAT, 27, s.top - 3),
115 post: s => {
116 s.dot(36, s.top - 1, 'S')
117 s.dot(37, s.top - 1, 'S')
118 s.dot(38, s.top, 'S')
119 },
120 },
121
122 // A pile of grain by its paws. It guards the stores.
123 6: {
124 mid: s => stamp(s, GRAIN, 38, s.ground - 4),
125 },
126
127 // A bicorne, and a rolled-up map of campaigns to come.
128 7: {
129 mid: s => {
130 stamp(s, BICORNE, 26, s.top - 2)
131 stamp(s, SCROLL, 41, s.ground - 1)
132 },
133 },
134
135 // A white beard, spectacles on a temple arm, and the book it has read.
136 8: {
137 mid: s => {
138 stamp(s, BEARD, 33, s.hy + 4)
139 stamp(s, BOOK, 40, s.ground - 3)
140 },
141 post: s => {
142 stamp(s, GLASSES, 34, s.ey - 1)
143 stamp(s, ['GGGG'], 30, s.ey)
144 },
145 },
146
147 // A crown, a red cloak flying behind, and a heap of gold.
148 9: {
149 mid: s => {
150 stamp(s, CROWN, 28, s.top - 2)
151 stamp(s, COINS, 38, s.ground - 4)
152
153 const bottom = s.bodyCy + s.bodyRy * 0.4
154 for (let y = Math.floor(s.bodyCy - s.bodyRy); y <= bottom; y++) {
155 const first = s.leftmost(y)
156 for (let x = first; x <= 22; x++) {
157 tint(s, x, y, y >= bottom - 1 ? 'r' : x % 5 === 0 ? 'r' : 'R')
158 }
159 if (y > s.bodyCy - s.bodyRy * 0.6 && first < s.width) {
160 s.dot(first - 1, y, 'R')
161 if (y > s.bodyCy - s.bodyRy * 0.2) s.dot(first - 2, y, 'r')
162 }
163 }
164 },
165 },
166
167 // A halo, dark glasses, a golden outline and sparkles. Cool and legendary.
168 10: {
169 post: s => {
170 stamp(s, HALO, 28, s.top - 6)
171 stamp(s, SUNGLASSES, 34, s.ey - 1)
172 stamp(s, ['NNNNNN'], 28, s.ey - 1)
173
174 SPARKLES.forEach(([x, y], i) => {
175 if ((i + s.frame) % 3 !== 0) return
176 s.dot(x, y, 'W')
177 s.dot(x - 1, y, 'S')
178 s.dot(x + 1, y, 'S')
179 s.dot(x, y - 1, 'S')
180 s.dot(x, y + 1, 'S')
181 })
182 },
183 },
184}
185
186// The tombstone that stands by a hamster who has starved.
187export const TOMBSTONE = [
188 '.GGGG.',
189 'GGKKGG',
190 'GKKKKG',
191 'GGKKGG',
192 'GGKKGG',
193 'GGGGGG',
194 'GGGGGG',
195 'KKKKKK',
196] as const
197hooks/palette.ts 32 lines1export const COLOR = {
2 // The fur ramp shifts hue with the light: shadows lean red-violet,
3 // highlights lean yellow, instead of one brown made darker and lighter.
4 D: '#6a3b2f',
5 B: '#96592f',
6 O: '#c4854a',
7 L: '#e5b476',
8 C: '#f6e4bd',
9 M: '#f9dc9c',
10 P: '#e29a9a',
11 K: '#2c1a14',
12 // Softer outlines for the lighter fur, so a pale belly is not ringed in black.
13 k: '#4a2c1e',
14 c: '#70493a',
15 E: '#0d0a08',
16 H: '#ffffff',
17 T: '#d8c9ae',
18 Y: '#f2c14e',
19 y: '#ffe066',
20 R: '#c8352b',
21 r: '#8f2420',
22 W: '#f4f4f0',
23 G: '#9a9fa6',
24 N: '#222a44',
25 V: '#4f8a3c',
26 U: '#4a86c8',
27 Q: '#e8832a',
28 S: '#fff6b8',
29} as const
30
31export type Key = keyof typeof COLOR
32hooks/play.ts 137 lines1import type { Anim } from './anim'
2import type { Scene } from './gear'
3import type { Key } from './palette'
4
5const put = (s: Scene, rows: readonly string[], x0: number, y0: number, map: Record<string, Key>) => {
6 rows.forEach((row, j) => {
7 for (let i = 0; i < row.length; i++) {
8 const key = map[row[i]!]
9 if (key) s.dot(x0 + i, y0 + j, key)
10 }
11 })
12}
13
14const PAW = [
15 [36, 6],
16 [37, 4],
17 [38, 2],
18 [37, 1],
19 [38, 2],
20 [37, 1],
21 [38, 2],
22 [37, 4],
23 [36, 6],
24] as const
25
26const MOUTH = [0, 1, 2, 3, 4, 4, 3, 2, 1] as const
27
28const Z = ['ZZZ', '.Z.', 'ZZZ']
29
30const COIN = ['YYY', 'YyY', 'YYY']
31
32// What the hamster does on top of the picture, drawn after everything else.
33// The small facial moves (sniff, ear flick, closed eyes) are in art.ts.
34export const play = (a: Anim, s: Scene) => {
35 const { step } = a
36
37 switch (a.name) {
38 // A front paw rubs the face.
39 case 'groom': {
40 const [x, y] = PAW[Math.min(step, PAW.length - 1)]!
41 put(s, ['kPPk', 'PPPP', 'PPPP', '.kk.'], x, s.hy + y, { P: 'P', k: 'k' })
42 break
43 }
44
45 // Crumbs fall from the full cheeks.
46 case 'munch': {
47 for (let k = 0; k < 2; k++) {
48 const t = (step + k * 2) % 4
49 s.dot(42 + k * 2, s.hy + 5 + t, 'C')
50 }
51 break
52 }
53
54 // A wide yawn, a pink tongue at the back.
55 case 'yawn': {
56 const open = MOUTH[Math.min(step, MOUTH.length - 1)]!
57 for (let j = 0; j < open; j++) {
58 const y = s.hy + 2 + j
59 const isTongue = j === open - 1 && open > 2
60 for (let x = 40; x <= 43; x++) s.dot(x, y, isTongue ? 'P' : j === 0 ? 'K' : 'r')
61 }
62 break
63 }
64
65 // Asleep: Zs float up one after another.
66 case 'sleep': {
67 for (let i = 0; i < 3; i++) {
68 const t = step - i * 6
69 if (t < 0 || t >= 12) continue
70 put(s, Z, 38 + i * 3 + Math.floor(t / 3), s.top - 3 - Math.floor(t / 2), { Z: 'U' })
71 }
72 break
73 }
74
75 // Digs at the ground: bits of earth fly in arcs.
76 case 'dig': {
77 for (let j = 0; j < 4; j++) {
78 const t = step - j * 2
79 if (t < 0 || t >= 7) continue
80 const x = 40 + j + Math.round(t * 1.3)
81 const y = s.ground - 1 - Math.round(t * (7 - t) * 0.6)
82 const c: Key = j % 2 === 0 ? 'B' : 'D'
83 s.dot(x, y, c)
84 s.dot(x + 1, y, c)
85 if (t < 4) s.dot(x, y - 1, c)
86 }
87 break
88 }
89
90 // A butterfly flutters across the top.
91 case 'butterfly': {
92 const x = 44 - Math.round(step * 1.5)
93 const y = 6 + Math.round(Math.sin(step * 0.7) * 3)
94 const wings = step % 2 === 0
95 ? ['QQ.QQ', 'QQNQQ', '.QNQ.']
96 : ['.....', '.QNQ.', 'QQNQQ']
97 put(s, wings, x - 2, y - 1, { Q: 'Q', N: 'N' })
98 break
99 }
100
101 // A seed rolls by across the ground.
102 case 'roll': {
103 const x = 46 - Math.round(step * 2.2)
104 put(s, step % 2 === 0 ? ['.DDDD.', 'DWWDWD', '.DDDD.'] : ['.DDDD.', 'DWDWWD', '.DDDD.'], x, s.ground - 2, {
105 D: 'D',
106 W: 'W',
107 })
108 break
109 }
110
111 // A shooting star.
112 case 'star': {
113 const x = 2 + step * 3
114 const y = 2 + Math.round(step * 0.7)
115 s.dot(x, y, 'W')
116 const tail: Key[] = ['S', 'S', 'y', 'y', 'Y', 'Y']
117 tail.forEach((c, i) => s.dot(x - 1 - i, y - Math.round((i + 1) * 0.4), c))
118 break
119 }
120
121 // A gold coin drops, lands and glints.
122 case 'coin': {
123 const y = Math.min(s.ground - 3, Math.round(step * 2.4 - 3))
124 put(s, COIN, 44, y, { Y: 'Y', y: 'y' })
125 if (y === s.ground - 3 && step >= 12) {
126 const on = step % 2 === 0
127 s.dot(43, y - 1, on ? 'W' : 'S')
128 s.dot(47, y + 1, on ? 'S' : 'W')
129 }
130 break
131 }
132
133 default:
134 break
135 }
136}
137types/index.d.ts 24 lines1export type Pet = {
2 name: string
3 xp: number
4 food: number
5 fun: number
6 at: number
7 ctx: number
8}
9
10export type Tip = {
11 text: string
12 tag: string
13 level: 'info' | 'warn' | 'urgent'
14 link?: string | null
15 href?: string | null
16 id?: string | null
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 hamster: { pet: Pet; frame: number; tip: Tip; menu: boolean }
22 }
23}
24