SLOPSHOPPER

hamster

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…

newbandguardtoaststatusprompt
v1.6.4MITupdated 2026-10-06nickpotet/claude-hamster/hamster
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hamster
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Newborn Hamster · lv. 1 · context 49% · joy 50% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Newborn Hamster · lv. 1 · context 49% · joy 50%
README

Claude Hamster

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.

Русская версия ниже: читать по-русски.

  • Its size follows the context fill. An empty context is a tiny hamster, a full one is a round one. The "Fullness" bar is the context percentage.
  • XP and joy come from how you use the context. Every 1000 tokens of growth is 1 XP. /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).
  • Advice is the point. A card under the hamster shows a new tip every 20 seconds: 110 of them, about a fifth on saving tokens, the rest on skills, plugins, MCP servers and ways of working, most with a clickable link. Page through them with the arrow buttons. The full list is in docs/TIPS.md.
  • Warnings when it matters: the context passes 60% and 80%, you stepped away and the cache is going cold, the plan limit is running low, the session is very old. Each has a "Got it" button that brings the ordinary tips back. The limit notice pops up at most once an hour.
  • No model calls. The mod reads only local counters ($.session.usage) and spends no tokens of your chat.
  • Russian and English. It follows Claude's language setting, or the system locale. The flag in the top right corner opens a language menu.

Install

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.

How it works

PieceFile
The pet: XP, joy, levels, what counts as a clean-uphamster/hooks/pet.ts
Pixel art and animationshamster/hooks/art.ts, gear.ts, anim.ts, palette.ts
When to warn and what to sayhamster/hooks/advice.ts
The 110 tipshamster/hooks/tips.ts
Texts in both languageshamster/hooks/text.ts
The band, buttons, timershamster/hooks/register.tsx

Notes:

  • "End of work" means no prompt, tool call or finished turn for 3 minutes with the context at 30% or more. "Cache cold" means an hour of that. These are the mod's own thresholds, not measured from your account: the prompt cache lifetime depends on the plan and mode.
  • XP counts the growth of the real token count from a model reply. Right after /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.

Develop

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.

Contact

Questions, ideas, bugs: open an issue, or write to nick.potet@gmail.com or Telegram @nick4p.

License

MIT


По-русски

Пиксельный хомяк, который живёт над строкой ввода в десктопном приложении Claude (вкладка Code). Он «ест» ваш контекст и подсказывает, что с ним делать.

  • Размер хомяка следует за заполнением контекста. Пустой контекст: крошка, полный: круглый. Полоска «Сытость» это процент контекста.
  • Опыт и радость зависят от того, как вы работаете. Каждые 1000 токенов роста дают 1 XP. /clear и /compact дают много опыта и поднимают радость. Десять уровней, у каждого своё снаряжение.
  • Главное: советы. Карточка под хомяком каждые 20 секунд показывает новый совет. Всего их 110: около пятой части про экономию токенов, остальные про скиллы, плагины, MCP-серверы и приёмы работы, у большинства есть кликабельная ссылка. Листать можно стрелками. Полный список: docs/TIPS.md (на английском).
  • Предупреждения, когда это важно: контекст прошёл 60% и 80%, вы отошли и кэш остывает, лимит плана на исходе, сессия очень старая. У каждого есть кнопка «Понятно», после которой снова идут обычные советы. Уведомление про лимит всплывает не чаще раза в час.
  • Никаких вызовов модели. Мод читает только локальные счётчики и не тратит токены вашего чата.
  • Русский и английский. Язык берётся из настройки 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.

Source 12 files
hooks/register.tsx 584 lines
1import { 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}
584
hooks/anim.ts 89 lines
1// 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}
89
hooks/advice.ts 147 lines
1import { 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}
147
hooks/tips.ts 164 lines
1// 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]!]!
164
hooks/art.ts 263 lines
1import 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 }
263
hooks/bars.ts 42 lines
1// 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'
42
hooks/pet.ts 135 lines
1import 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}
135
hooks/text.ts 128 lines
1// 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
128
hooks/gear.ts 197 lines
1import 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
197
hooks/palette.ts 32 lines
1export 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
32
hooks/play.ts 137 lines
1import 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}
137
types/index.d.ts 24 lines
1export 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