SLOPSHOPPER

doro-context

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

newbandtimer
v0.1.0MITupdated 2026-10-07GelzoneCC/doro-context
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doro-context
› 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 ▗▄▟██████▄██▄ doro · munching happily ██▀▀▀▜▛▀▀▀██▀ ███████████████░░░░░░░░░░░░░░░ 49% ██▀▚▚██▚▚▀██ 97k / 200k tokens ▀▀███▚▚███▀▀ ▀▀▛▀▛▀▀▜▀▜▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▗▄▟██████▄██▄ doro · munching happily ██▀▀▀▜▛▀▀▀██▀ ███████████████░░░░░░░░░░░░░░░ 49% ██▀▚▚██▚▚▀██ 97k / 200k tokens ▀▀███▚▚███▀▀ ▀▀▛▀▛▀▀▜▀▜▀▀
README

doro-context

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.

doro at each stage

Context usedDoro
under 10%hungry, waiting for tokens
10–35%snacking on a tangerine
35–60%munching happily ^ω^
60–80%getting stuffed
80% and upabout 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.

Install

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).

Notes

  • Hide or show the band with [-] or ctrl+x ctrl+a.
  • Needs a terminal font with the quadrant block characters (▘▝▖▗▛▜▙▟), the same ones Clawd is drawn with.
  • In the Claude desktop app she is drawn as a still picture.
Source 3 files
hooks/register.tsx 127 lines
1import { 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}
127
hooks/doro.ts 205 lines
1// 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}
205
types/index.d.ts 9 lines
1/** 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