A pixel doro above the prompt who gets rounder as the context window fills up

A Claude Code mod: a pixel doro, drawn in Clawd's quadrant-block style, lives in a band above your prompt and eats your context window. The fuller the window, the rounder she gets.

| Context used | Doro |
|---|---|
| under 10% | hungry, waiting for tokens |
| 10–35% | snacking on a tangerine |
| 35–60% | munching happily ^ω^ |
| 60–80% | getting stuffed |
| 80% and up | about to pop: time to /compact |
She walks back and forth (slower as she fills up), breathes while she rests, and stops to pant when she's nearly full. Beside her: a usage bar and used / window tokens.
At the prompt of a Claude Code terminal session:
/plugin install doro-context --marketplace GelzoneCC/doro-context
Answer y to add the marketplace, then pick a scope (user scope loads her in every session).
[-] or ctrl+x ctrl+a.▘▝▖▗▛▜▙▟), the same ones Clawd is drawn with.hooks/register.tsx 127 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, SessionContextUsage } from 'claude-code'
3
4import type { DoroFill } from '../types'
5import { ROWS, WIDTH, drawDoro, stageOf, startWalk, stepWalk, toCells, toSvg } from './doro'
6
7const fill = atom({ plugin: 'doro-context', key: 'fill' } as const, null)
8
9/** One animation frame; the terminal repaints doro in place this often. */
10const TICK_MS = 150
11
12// Keeps only the figures the band draws, leaving out the ones not reported yet.
13const toFill = (context: SessionContextUsage): DoroFill => ({
14 window: context.window,
15 ...(context.tokens !== undefined && { tokens: context.tokens }),
16 ...(context.percent !== undefined && { percent: context.percent }),
17})
18
19const refresh = async ($: EngineInterface) => {
20 const { context } = await $.session.usage()
21 await update($, fill, () => toFill(context))
22}
23
24const formatTokens = (n: number) =>
25 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
26
27export const register: Register = on => {
28 // The band's picture as last drawn on the terminal, which the timer repaints.
29 let band: { requestId: string; percent: number | undefined } | undefined
30 let walk = startWalk()
31
32 on('session.start', async ($, e, next) => {
33 const started = await next(e)
34 $.clock.every(TICK_MS, () => {
35 if (!band) return
36 walk = stepWalk(walk, band.percent)
37 void $.ui.blit({ requestId: band.requestId, key: 'doro', cells: toCells(drawDoro(band.percent, walk)) })
38 })
39 await refresh($)
40
41 return started
42 })
43
44 on('session.measure', async ($, e, next) => {
45 if (e.changed.includes('context')) {
46 await update($, fill, () => toFill(e.context))
47 }
48
49 return next(e)
50 })
51
52 // A compacted window reports no fill until its next response: doro is hungry again.
53 on('session.compact', async ($, e, next) => {
54 const compacted = await next(e)
55 if (compacted.messages && e.agentId === undefined && e.trigger !== 'precompute') {
56 await refresh($)
57 }
58
59 return compacted
60 }).catch(($, e, next) => next(e))
61
62 on('session.end', async ($, e, next) => {
63 if (e.reason === 'clear') {
64 await update($, fill, last => last && { window: last.window })
65 }
66
67 return next(e)
68 })
69
70 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71 if (e.props.hasSurvey) {
72 return next(e)
73 }
74
75 const usage = await read($, fill)
76 const percent = usage?.percent
77 const stage = stageOf(percent)
78 const sprite = drawDoro(percent, walk)
79 const hasRoomForArt = e.props.bodyColumns >= WIDTH + 30
80 if (e.surface === 'terminal') {
81 band = hasRoomForArt ? { requestId: e.requestId, percent } : undefined
82 }
83
84 let art: RenderNode | null = null
85 if (hasRoomForArt && e.surface === 'terminal') {
86 const { Raster } = $.ui.resolve(e)
87 art = <Raster key="doro" columns={WIDTH} rows={ROWS} cells={toCells(sprite)} />
88 } else if (hasRoomForArt && e.surface === 'desktop') {
89 const { Svg } = $.ui.resolve(e)
90 art = <Svg source={toSvg(sprite)} alt={`doro, ${stage.caption}`} width={WIDTH * 4} height={ROWS * 8} />
91 }
92
93 const { Box, Text } = $.ui.resolve(e)
94 const barWidth = Math.max(10, Math.min(30, e.props.bodyColumns - (art ? WIDTH : 0) - 12))
95 const filled = Math.round((barWidth * Math.min(percent ?? 0, 100)) / 100)
96 const isFull = percent !== undefined && percent >= 80
97 const amount =
98 usage?.tokens !== undefined
99 ? `${formatTokens(usage.tokens)} / ${formatTokens(usage.window)} tokens`
100 : usage
101 ? `0 / ${formatTokens(usage.window)} tokens`
102 : 'no reading yet'
103
104 return (
105 <Box flexDirection="row" alignItems="center">
106 {art}
107 <Box flexDirection="column" paddingLeft={art ? 2 : 0}>
108 <Text>
109 <Text color="claude" bold>
110 doro
111 </Text>
112 <Text color={isFull ? 'warning' : undefined} dimColor={!isFull}>
113 {' '}· {stage.caption}
114 </Text>
115 </Text>
116 <Text>
117 <Text color="claude">{'█'.repeat(filled)}</Text>
118 <Text dimColor>{'░'.repeat(barWidth - filled)}</Text>
119 <Text bold> {percent === undefined ? '--' : `${percent}%`}</Text>
120 </Text>
121 <Text dimColor>{amount}</Text>
122 </Box>
123 </Box>
124 )
125 })
126}
127hooks/doro.ts 205 lines1// Doro, drawn the way Clawd is: flat colors, no outline, built from quadrant
2// blocks (four pixels to a cell, two across and two down). One character of
3// the art is one of those pixels. A cell shows two colors, so every feature
4// sits on whole cells: two columns wide, starting on an even column, and
5// within one of the row pairs 0-1, 2-3, 4-5, 6-7, 8-9.
6
7const COLORS: Record<string, number> = {
8 H: 0xf4a6c4, // hair
9 F: 0xfaf9f5, // face and body (Claude ivory)
10 E: 0x7e4fa0, // eyes
11 B: 0xf7c3d2, // blush
12 M: 0x5a3b46, // mouth
13 R: 0xb48ad8, // ribbon
14 O: 0xd97757, // tangerine (Claude orange)
15 D: 0x8ec5ff, // sweat drop
16}
17
18// Front view, rows 0-6 of the strip; the mouth's second row and the body
19// below are drawn per stage.
20const HEAD = [
21 '.......HHHHHHHHHHHHH..HHHH..',
22 '...HHHHHHHHHHHHHHHHHHHHHHHRR',
23 '..HHHHHHHHHHHHHHHHHHHHHHHHRR',
24 '..HHHHFFFFFFFHHFFFFFFFHHHH..',
25 '..HHHHFFFFFFFFFFFFFFFFHHHH..',
26 '..HHHHBBFFFFFFFFFFFFBBHHHH..',
27 '..HHHHFFFFFFFFFFFFFFFFHHHH..',
28]
29
30const EYES = {
31 open: ['FEEE', 'EEEE'],
32 happy: ['.EE.', 'E..E'],
33 sleepy: ['....', 'EEEE'],
34 strain: ['EE..', '..EE'],
35}
36
37const MOUTHS = {
38 small: ['.MM.', '....'],
39 tangerine: ['.OO.', 'OOOO'],
40 omega: ['M..M', '.MM.'],
41 flat: ['MMMM', '....'],
42 clenched: ['.MM.', 'M..M'],
43}
44
45/** The Raster's size: the strip doro walks along, in cells. */
46export const WIDTH = 34
47export const ROWS = 5
48
49/** The same strip in pixels: two across and two down in every cell. */
50const PIXELS_ACROSS = WIDTH * 2
51const PIXELS_DOWN = ROWS * 2
52
53/** Doro's own width in pixels, room for her fullest belly included. */
54const SPRITE_WIDTH = 40
55/** Where the head sits in it, and the column her middle falls between. */
56const HEAD_X = 6
57const MIDDLE = HEAD_X + 14
58
59export type Stage = {
60 from: number
61 caption: string
62 eyes: keyof typeof EYES
63 mouth: keyof typeof MOUTHS
64 isSweating: boolean
65 /** Ticks per cell walked; 0 stays put. */
66 walkEvery: number
67 /** Ticks per half breath. */
68 breathEvery: number
69}
70
71export const STAGES: Stage[] = [
72 { from: 0, caption: 'hungry, waiting for tokens', eyes: 'open', mouth: 'small', isSweating: false, walkEvery: 2, breathEvery: 6 },
73 { from: 10, caption: 'snacking on tokens', eyes: 'open', mouth: 'tangerine', isSweating: false, walkEvery: 2, breathEvery: 6 },
74 { from: 35, caption: 'munching happily', eyes: 'happy', mouth: 'omega', isSweating: false, walkEvery: 3, breathEvery: 7 },
75 { from: 60, caption: 'getting stuffed', eyes: 'sleepy', mouth: 'flat', isSweating: false, walkEvery: 5, breathEvery: 8 },
76 { from: 80, caption: 'about to pop, time to /compact', eyes: 'strain', mouth: 'clenched', isSweating: true, walkEvery: 0, breathEvery: 2 },
77]
78
79export function stageOf(percent: number | undefined): Stage {
80 const p = percent ?? 0
81 return STAGES.findLast(stage => p >= stage.from) ?? STAGES[0]!
82}
83
84const fillOf = (percent: number | undefined) => Math.min(Math.max(percent ?? 0, 0), 100) / 100
85
86/** Where doro is on her walk, `x` in cells; `tick` counts every frame since she set off. */
87export type Walk = { x: number; dir: -1 | 1; rest: number; tick: number }
88
89const REST_TICKS = 14
90const ROOM = (PIXELS_ACROSS - SPRITE_WIDTH) / 2
91
92export const startWalk = (): Walk => ({ x: 0, dir: 1, rest: REST_TICKS, tick: 0 })
93
94/** One frame on: a cell along every `walkEvery` ticks, turning and resting at each end. */
95export function stepWalk(walk: Walk, percent: number | undefined): Walk {
96 const stage = stageOf(percent)
97 const tick = walk.tick + 1
98 const x = Math.min(walk.x, ROOM)
99
100 if (walk.rest > 0 || stage.walkEvery === 0) {
101 return { ...walk, x, tick, rest: Math.max(0, walk.rest - 1) }
102 }
103 if (tick % stage.walkEvery !== 0) {
104 return { ...walk, x, tick }
105 }
106 const next = x + walk.dir
107 if (next < 0 || next > ROOM) {
108 return { x, dir: walk.dir === 1 ? -1 : 1, rest: REST_TICKS, tick }
109 }
110 return { ...walk, x: next, tick }
111}
112
113/** Rows of 0xRRGGBB colors, `undefined` where the pixel is clear. */
114export type Sprite = (number | undefined)[][]
115
116export function drawDoro(percent: number | undefined, walk: Walk): Sprite {
117 const stage = stageOf(percent)
118 const fill = fillOf(percent)
119 const isWalking = walk.rest === 0 && stage.walkEvery > 0
120 const isInhaling = Math.floor(walk.tick / stage.breathEvery) % 2 === 1
121
122 const canvas = Array.from({ length: PIXELS_DOWN }, () => Array<string>(PIXELS_ACROSS).fill('.'))
123 const left = 2 * walk.x
124 const at = (x: number, y: number) => canvas[y]?.[left + x]
125 const put = (x: number, y: number, ch: string) => {
126 const row = canvas[y]
127 if (row && left + x >= 0 && left + x < PIXELS_ACROSS) row[left + x] = ch
128 }
129 // `under` fills the '.' of a stamp; without it they stay see-through.
130 const stamp = (rows: string[], x: number, y: number, under?: string) =>
131 rows.forEach((row, j) =>
132 [...row].forEach((ch, i) => {
133 if (ch !== '.' || under) put(x + i, y + j, ch === '.' ? under! : ch)
134 }),
135 )
136
137 stamp(HEAD, HEAD_X, 0)
138 // The right eye is the left one mirrored.
139 const eye = EYES[stage.eyes]
140 stamp(eye, HEAD_X + 8, 4, 'F')
141 stamp(eye.map(row => [...row].reverse().join('')), HEAD_X + 16, 4, 'F')
142 stamp(MOUTHS[stage.mouth], HEAD_X + 12, 6, 'F')
143 if (stage.isSweating) stamp(['.D', 'DD'], HEAD_X, 2)
144
145 // The belly behind her: wider and taller the fuller the window, a cell
146 // wider on each breath in. Its edges stay on whole cells; a top corner
147 // rounds off only where nothing sits above it.
148 const half = 8 + 2 * Math.round(5 * fill) + (isInhaling ? 2 : 0)
149 const top = 7 - Math.round(2 * fill) - (isInhaling ? 1 : 0)
150 for (let y = top; y <= 8; y++) {
151 for (let x = MIDDLE - half; x < MIDDLE + half; x++) {
152 const isCorner = y === top && (x === MIDDLE - half || x === MIDDLE + half - 1)
153 if (at(x, y) === '.' && !(isCorner && at(x, y - 1) === '.')) put(x, y, 'F')
154 }
155 }
156 // Clawd's stubby legs under it, shuffling a pixel with every step.
157 const shuffle = isWalking && walk.x % 2 === 1 ? 1 : 0
158 for (const x of [MIDDLE - 8, MIDDLE - 4, MIDDLE + 3, MIDDLE + 7]) put(x + shuffle, 9, 'F')
159
160 return canvas.map(row => row.map(ch => COLORS[ch]))
161}
162
163// The quadrant blocks, indexed by which pixels of the cell take the
164// foreground: 1 top left, 2 top right, 4 bottom left, 8 bottom right.
165const QUADRANTS = [
166 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
167 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
168]
169const DEFAULT_COLOR = 0x01000000
170
171/** One cell from its four pixels (top left, top right, bottom left, bottom right). */
172function toCell(pixels: (number | undefined)[]): number[] {
173 const [fg, bg] = [...new Set(pixels)].sort((a, b) => (a === undefined ? 1 : b === undefined ? -1 : 0))
174 if (fg === undefined) return [QUADRANTS[0]!, DEFAULT_COLOR, DEFAULT_COLOR]
175 const mask = pixels.reduce<number>((bits, p, i) => (p === fg ? bits | (1 << i) : bits), 0)
176 return [QUADRANTS[mask]!, fg, bg ?? DEFAULT_COLOR]
177}
178
179/** The sprite as a terminal Raster's `cells`. */
180export function toCells(sprite: Sprite): string {
181 const words = new Uint32Array(WIDTH * ROWS * 3)
182 for (let r = 0; r < ROWS; r++) {
183 for (let c = 0; c < WIDTH; c++) {
184 const [upper, lower] = [sprite[2 * r], sprite[2 * r + 1]]
185 const pixels = [upper?.[2 * c], upper?.[2 * c + 1], lower?.[2 * c], lower?.[2 * c + 1]]
186 words.set(toCell(pixels), (r * WIDTH + c) * 3)
187 }
188 }
189 let text = ''
190 for (const byte of new Uint8Array(words.buffer)) text += String.fromCharCode(byte)
191 return btoa(text)
192}
193
194/** The sprite as an SVG of half-width pixels, for surfaces that draw Svg. */
195export function toSvg(sprite: Sprite): string {
196 const rects = sprite.flatMap((row, y) =>
197 row.flatMap((color, x) =>
198 color === undefined
199 ? []
200 : [`<rect x="${x}" y="${2 * y}" width="1" height="2" fill="#${color.toString(16).padStart(6, '0')}"/>`],
201 ),
202 )
203 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PIXELS_ACROSS} ${2 * PIXELS_DOWN}" shape-rendering="crispEdges">${rects.join('')}</svg>`
204}
205types/index.d.ts 9 lines1/** The live context window's fill, as `$.session.usage().context` reports it. */
2export type DoroFill = { tokens?: number; window: number; percent?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'doro-context': { fill: DoroFill | null }
7 }
8}
9