the puppy's food bowl above the prompt: how much context is left

the puppy's food bowl, sitting above the prompt in claude code. the food is your context window: as the conversation grows, the puppy eats it.
◖•ᴥ•◗ ●●●●○○○○○○ 42% left
compact button, so you can tidy the conversation before it runs out.in a claude code session in your terminal:
/plugin install puppy-bowl --marketplace nonbiri1004/baibai-mods
answer y to add the marketplace, then pick a scope.
| command | what it does |
|---|---|
/bowl | steps through the modes: auto (out when half empty) → always → off |
/bowl auto, /bowl always, /bowl off | picks one; it's remembered for every new conversation |
/can | give the puppy a can: a thank-you and a link to ko-fi. unlocks nothing ♡ |
claude plugin validate puppy-bowl
claude plugin test puppy-bowl
MIT
hooks/register.tsx 128 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Bowl, Mode } from '../types'
5
6// the puppy's orange (--phones in shared/theme.css): the one accent, on the headphones
7const PHONES = '#e07b35'
8const CELLS = 10
9// the puppy only comes out once half the bowl is eaten (the status line already has the number)
10const SHOW = 50
11// past this much context used, the puppy mentions /compact
12const HUNGRY = 80
13const KOFI = 'https://ko-fi.com/lofipuppy'
14
15const bowl = atom({ plugin: 'puppy-bowl', key: 'bowl' } as const, { used: null } as Bowl)
16const mode = atom({ plugin: 'puppy-bowl', key: 'mode' } as const, 'auto' as Mode)
17
18// /bowl steps through the modes in this order; /bowl auto|always|off picks one
19const MODES: readonly Mode[] = ['auto', 'always', 'off']
20const SAID: Record<Mode, string> = {
21 auto: 'the bowl comes out when it is half empty.',
22 always: 'the bowl stays out.',
23 off: 'the bowl is put away. /bowl brings it back.',
24}
25
26export function nextMode(was: Mode, arg: string): Mode {
27 const asked = arg.trim().toLowerCase()
28 if ((MODES as readonly string[]).includes(asked)) {
29 return asked as Mode
30 }
31
32 return MODES[(MODES.indexOf(was) + 1) % MODES.length] ?? 'auto'
33}
34
35export function isShown(m: Mode, used: number): boolean {
36 return m === 'always' || (m === 'auto' && used >= SHOW)
37}
38
39// food left in the bowl: ●●●●●●○○○○
40export function food(used: number): string {
41 const left = Math.round(((100 - used) / 100) * CELLS)
42 const full = Math.max(0, Math.min(CELLS, left))
43 return '●'.repeat(full) + '○'.repeat(CELLS - full)
44}
45
46export const register: Register = on => {
47 on('session.start', async ($, e, next) => {
48 await $.command.register({
49 name: 'can',
50 description: 'give the puppy a can (opens ko-fi)',
51 })
52 await $.command.register({
53 name: 'bowl',
54 description: 'the puppy bowl: auto (out when half empty) → always → off',
55 argumentHint: '[auto|always|off]',
56 })
57 // the mode is kept in $.store, so every new conversation remembers it
58 const kept = (await $.store.get('mode')) as Mode | undefined
59 if (kept && MODES.includes(kept)) {
60 await update($, mode, () => kept)
61 }
62 const { context } = await $.session.usage()
63 await update($, bowl, () => ({ used: context.percent ?? null }))
64
65 return next(e)
66 })
67
68 // the engine pushes new figures after each turn; the write redraws the band
69 on('session.measure', async ($, e, next) => {
70 if (e.changed.includes('context')) {
71 await update($, bowl, () => ({ used: e.context.percent ?? null }))
72 }
73
74 return next(e)
75 })
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 // before the first reply there's no reading yet: the bowl starts full
79 const used = (await read($, bowl)).used ?? 0
80 if (e.props.hasSurvey || !isShown(await read($, mode), used)) {
81 return next(e)
82 }
83
84 const { Box, Text, Button } = $.ui.resolve(e)
85 const isHungry = used >= HUNGRY
86
87 return (
88 <Box>
89 <Text>
90 <Text color={PHONES}>◖</Text>
91 <Text dimColor>•ᴥ•</Text>
92 <Text color={PHONES}>◗</Text>
93 <Text dimColor>
94 {' '}
95 {food(used)} {100 - used}% left
96 {isHungry ? ' · the bowl is almost empty ' : ''}
97 </Text>
98 </Text>
99 {isHungry && !e.props.isWorking && (
100 <Button key="compact" label="compact" onPress={() => $.session.compact()} />
101 )}
102 </Box>
103 )
104 })
105
106 on('command.run', { command: 'bowl' }, async ($, e) => {
107 const now = await update($, mode, was => nextMode(was, e.args))
108 await $.store.set('mode', now)
109
110 return { text: SAID[now] }
111 })
112
113 on('command.run', { command: 'can' }, () => ({
114 text: `the puppy wags its tail. thank you for the can ♡ ${KOFI}`,
115 }))
116
117 // draws /can's row with a real link instead of plain text
118 on('ui.render', { component: 'CommandOutput', props: { command: 'can' } }, ($, e) => {
119 const { Text, Link } = $.ui.resolve(e)
120
121 return (
122 <Text dimColor>
123 the puppy wags its tail. thank you for the can ♡ <Link href={KOFI} label="ko-fi.com/lofipuppy" />
124 </Text>
125 )
126 })
127}
128types/index.d.ts 16 lines1// what the bowl remembers for this session
2export type Bowl = {
3 // context used, 0–100; null before the first reading
4 used: number | null
5}
6
7// auto: out once half the bowl is eaten; always: out the whole time; off: put away
8export type Mode = 'auto' | 'always' | 'off'
9
10declare module 'claude-code' {
11 interface PluginState {
12 // mode: copied from $.store at session start so the drawing can read it
13 'puppy-bowl': { bowl: Bowl; mode: Mode }
14 }
15}
16