SLOPSHOPPER

token-face

A face above the prompt that gets more uncanny as the context window fills.

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-face
› 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 (•_•) Crowded 49% · 97.4k / 200k ▄ ▄ ▲ +0 last turn ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(•_•) Crowded 49% · 97.4k / 200k ▄ ▄ ▲ +0 last turn
README

token-face

A Claude Code mod that puts a face above the prompt. The fuller your context window gets, the worse the face looks.

(•_•) Crowded  61% · 610.1k / 1M  ▁ ▂ ▃ ▄ ▅  ▲ +6.4k last turn

What it shows

  • A face and a caption for how full the context window is:
Context usedCaptionPicture
under 25%Fresh<img src="faces/1.png" width="64" alt="Stage 1 picture">
25–39%Fine<img src="faces/1.png" width="64" alt="Stage 1 picture">
40–64%Crowded<img src="faces/2.png" width="64" alt="Stage 2 picture">
65–89%Too much<img src="faces/3.png" width="64" alt="Stage 3 picture">
90% and upCOMPACT. NOW.<img src="faces/4.png" width="64" alt="Stage 4 picture">
  • The percentage and tokens used, like 134.4k / 200k.
  • A chart of the last 12 turns, each bar in the colour of the stage that turn was in.
  • How much the last turn added, or how much a compaction removed.

It updates after every turn, and straight after a compaction.

In the desktop app the face is a picture. In a terminal it is a text face such as (•_•).

Install

/plugin marketplace add MohamedEmbarak/token-face
/plugin install token-face@token-face

The mod is built on Claude Code's function hooks. If nothing shows above the prompt after installing, add this to the env block of ~/.claude/settings.json and restart Claude Code:

"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"

Use your own pictures

The mod ships with four pictures, one per stage, in the plugin's faces/ folder. To use your own, replace them:

FileShown when context is
1.pngunder 40%
2.png40–64%
3.png65–89%
4.png90% and up

png, jpg, jpeg, gif or webp, each under about 85 KB (128×128 is plenty). A stage without a picture keeps its drawn face. Only use pictures you made or have the right to use.

Develop

From this folder:

claude plugin validate .
claude plugin test .

Licence

MIT

Source 2 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Forecast } from '../types'
5
6const forecast = atom({ plugin: 'token-face', key: 'forecast' } as const, null)
7
8const TURNS = 12
9const BARS = '▁▂▃▄▅▆▇█'
10
11// Nothing here moves: the face is a still picture, redrawn only when a turn
12// changes the reading. The size it is drawn at, in CSS pixels:
13const REST = 48
14
15// One face, five moods: the fuller the context window, the more it has seen.
16// Each mood is the same head redrawn: colder skin, darker room, emptier eyes.
17type Mood = {
18  from: number
19  caption: string
20  color?: string
21  text: [string, string]
22  room: string
23  skin: string
24  edge: string
25  eyes: string
26  mouth: string
27}
28
29const FRESH: Mood = {
30  from: 0,
31  caption: 'Fresh',
32  color: 'green',
33  text: ['(◕‿◕)', '(◠‿◠)'],
34  room: '#2e7dd1',
35  skin: '#ffd27d',
36  edge: '#f0b04a',
37  eyes: '<path d="M18 31 q5 -7 10 0 M36 31 q5 -7 10 0" fill="none" stroke="#3a2a1a" stroke-width="2.4" stroke-linecap="round"/>',
38  mouth:
39    '<path d="M20 42 q12 13 24 0 z" fill="#fff" stroke="#6b3f1d" stroke-width="1.6" stroke-linejoin="round"/>',
40}
41
42const MOODS: Mood[] = [
43  {
44    from: 90,
45    caption: 'COMPACT. NOW.',
46    color: 'red',
47    text: ['(◉_◉)', '(●_●)'],
48    room: '#000000',
49    skin: '#e6e6e6',
50    edge: '#3a3a3a',
51    eyes: `<ellipse cx="23" cy="30" rx="7.5" ry="9" fill="#000"/><ellipse cx="41" cy="30" rx="7.5" ry="9" fill="#000"/><circle cx="23" cy="31" r="0.9" fill="#fff"></circle><circle cx="41" cy="31" r="0.9" fill="#fff"></circle>`,
52    mouth: '<ellipse cx="32" cy="49" rx="4.5" ry="7.5" fill="#000"/>',
53  },
54  {
55    from: 65,
56    caption: 'Too much',
57    color: 'magenta',
58    text: ['(⊙_⊙)', '(⊙﹏⊙)'],
59    room: '#161616',
60    skin: '#9a9a9a',
61    edge: '#262626',
62    eyes: '<ellipse cx="23" cy="30" rx="6.5" ry="7" fill="#111"/><ellipse cx="41" cy="30" rx="6.5" ry="7" fill="#111"/><circle cx="23" cy="31" r="1.2" fill="#ddd"/><circle cx="41" cy="31" r="1.2" fill="#ddd"/>',
63    mouth: '<path d="M24 48 q8 -4 16 0" fill="none" stroke="#111" stroke-width="2" stroke-linecap="round"/>',
64  },
65  {
66    from: 40,
67    caption: 'Crowded',
68    color: '#ff9f43',
69    text: ['(•_•)', '(•_•;)'],
70    room: '#4b4f55',
71    skin: '#cdb89a',
72    edge: '#8f7f66',
73    eyes: '<ellipse cx="23" cy="30" rx="5" ry="6" fill="#fff"/><ellipse cx="41" cy="30" rx="5" ry="6" fill="#fff"/><circle cx="23" cy="30" r="1.6" fill="#222"/><circle cx="41" cy="30" r="1.6" fill="#222"/><path d="M17 21 h11 M36 21 h11" stroke="#5a4a36" stroke-width="2" stroke-linecap="round"/>',
74    mouth: '<path d="M25 46 h14" stroke="#5a4a36" stroke-width="2" stroke-linecap="round"/>',
75  },
76  {
77    from: 25,
78    caption: 'Fine',
79    color: 'yellow',
80    text: ['(•‿•)', '(-‿•)'],
81    room: '#4a6fa5',
82    skin: '#f2c27a',
83    edge: '#d39a4e',
84    eyes: `<ellipse cx="23" cy="30" rx="4" ry="5" fill="#fff"></ellipse><ellipse cx="41" cy="30" rx="4" ry="5" fill="#fff"></ellipse><circle cx="23.5" cy="30.5" r="2.2" fill="#3a2a1a"/><circle cx="41.5" cy="30.5" r="2.2" fill="#3a2a1a"/>`,
85    mouth: '<path d="M24 45 q8 5 16 0" fill="none" stroke="#6b3f1d" stroke-width="2" stroke-linecap="round"/>',
86  },
87  FRESH,
88]
89
90const portrait = (mood: Mood): string =>
91  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">` +
92  `<defs><radialGradient id="skin" cx="50%" cy="42%" r="62%"><stop offset="0.45" stop-color="${mood.skin}"/><stop offset="1" stop-color="${mood.edge}"/></radialGradient></defs>` +
93  `<rect width="64" height="64" rx="10" fill="${mood.room}"/>` +
94  `<g>` +
95  `<ellipse cx="32" cy="34" rx="21" ry="24" fill="url(#skin)"/>${mood.eyes}${mood.mouth}` +
96  `</g></svg>`
97
98// Your own pictures: faces/1 (freshest) to faces/4 (fullest), in any of these
99// formats. A stage without a picture keeps its drawn face.
100const KINDS = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
101// An Svg's markup is capped at 131072 characters, the picture's base64 included.
102const PICTURE_LIMIT = 120_000
103
104// Read from disk at every draw, never kept: a picture replaced in the folder
105// shows at the next turn, and the band only redraws when a turn moves it.
106async function picture($: EngineInterface, stage: number): Promise<string | null> {
107  for (const [extension, type] of Object.entries(KINDS)) {
108    try {
109      const { base64 } = await $.fs.read(`${$.plugin.root}/faces/${stage}.${extension}`, { as: 'bytes' })
110
111      if (base64.length <= PICTURE_LIMIT) {
112        return `data:${type};base64,${base64}`
113      }
114    } catch {
115      // No picture in this format: try the next.
116    }
117  }
118
119  return null
120}
121
122// The picture takes the drawn head's place.
123const framed = (mood: Mood, href: string): string =>
124  `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64" width="64" height="64">` +
125  `<defs><clipPath id="frame"><rect width="64" height="64" rx="10"/></clipPath></defs>` +
126  `<g clip-path="url(#frame)"><rect width="64" height="64" fill="${mood.room}"/>` +
127  `<g>` +
128  `<image href="${href}" xlink:href="${href}" width="64" height="64" preserveAspectRatio="xMidYMid slice"/>` +
129  `</g></g></svg>`
130
131const short = (n: number): string => {
132  const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : n >= 1000 ? [n / 1000, 'k'] : [n, '']
133
134  return `${value.toFixed(1).replace(/\.0$/, '')}${unit}`
135}
136
137// One bar per turn, each in the colour of the mood that turn was in, so a
138// compaction reads as a run of one colour giving way to another.
139const chart = (readings: number[], window: number): { bar: string; color?: string }[] =>
140  readings.slice(-TURNS).map(tokens => {
141    const percent = Math.round((tokens / window) * 100)
142
143    return {
144      bar: BARS[Math.min(BARS.length - 1, Math.floor((tokens / window) * BARS.length))] ?? ' ',
145      color: (MOODS.find(m => percent >= m.from) ?? FRESH).color,
146    }
147  })
148
149function record($: EngineInterface, window: number, tokens: number) {
150  return update($, forecast, (now): Forecast => ({
151    window,
152    // One more than the chart draws, so the oldest bar still has a delta.
153    readings: [...(now?.readings ?? []), tokens].slice(-(TURNS + 1)),
154  }))
155}
156
157export const register: Register = on => {
158  // A first reading, so the band is up before the first turn of a resumed
159  // session; a reload fires this again and must not add a turn.
160  on('session.start', async ($, e, next) => {
161    const { context } = await $.session.usage()
162
163    if (context.tokens !== undefined && (await read($, forecast)) === null) {
164      await record($, context.window, context.tokens)
165    }
166
167    return next(e)
168  })
169
170  on('session.measure', async ($, e, next) => {
171    if (e.changed.includes('context') && e.context.tokens !== undefined) {
172      await record($, e.context.window, e.context.tokens)
173    }
174
175    return next(e)
176  })
177
178  // A compaction empties the window between turns, and no measurement follows
179  // until the next response: take the drop from the compaction itself.
180  on('session.compact', async ($, e, next) => {
181    const result = await next(e)
182    const isMain = e.agentId === undefined && e.trigger !== 'precompute'
183    const now = await read($, forecast)
184
185    if (isMain && now !== null && result.messages !== undefined && result.tokensAfter !== undefined) {
186      await record($, now.window, result.tokensAfter)
187    }
188
189    return result
190  })
191
192  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193    const now = await read($, forecast)
194
195    if (e.props.hasSurvey) {
196      return next(e)
197    }
198
199    if (now === null) {
200      const { Box, Text } = $.ui.resolve(e)
201
202      return (
203        <Box>
204          <Text dimColor>(◕‿◕) token-face · waiting for the first reading</Text>
205        </Box>
206      )
207    }
208
209    const tokens = now.readings[now.readings.length - 1] ?? 0
210    const before = now.readings[now.readings.length - 2]
211    const percent = Math.round((tokens / now.window) * 100)
212    const mood = MOODS.find(m => percent >= m.from) ?? FRESH
213    const added = before === undefined ? null : tokens - before
214    const bars = chart(now.readings, now.window)
215    const figures = `${percent}% · ${short(tokens)} / ${short(now.window)}`
216    const delta = added === null ? '' : `${added < 0 ? '▼ −' : '▲ +'}${short(Math.abs(added))} last turn`
217
218    if (e.surface === 'desktop') {
219      const { Box, Svg, Text } = $.ui.resolve(e)
220      // Four pictures for five moods, fullest mood first: the two freshest
221      // moods (under 40%) share faces/1.
222      const stage = MOODS.indexOf(mood)
223      const href = await picture($, [4, 3, 2, 1, 1][stage] ?? 1)
224
225      return (
226        <Box alignItems="center">
227          <Box>
228            <Svg
229              source={href === null ? portrait(mood) : framed(mood, href)}
230              alt={`${mood.text[0]} ${mood.caption}`}
231              width={REST}
232              height={REST}
233            />
234          </Box>
235          <Text color={mood.color} bold>
236            {'  '}
237            {mood.caption}
238          </Text>
239          <Text>
240            {'  '}
241            {figures}
242            {'  '}
243          </Text>
244          {bars.map(({ bar, color }, turn) => (
245            <Text color={color}>{turn === 0 ? bar : ` ${bar}`}</Text>
246          ))}
247          <Text dimColor>
248            {'  '}
249            {delta}
250          </Text>
251        </Box>
252      )
253    }
254
255    // Everywhere else the face is text.
256
257    const { Box, Text } = $.ui.resolve(e)
258
259    return (
260      <Box>
261        <Text color={mood.color} bold inverse={percent >= 90}>
262          {mood.text[0]}
263        </Text>
264        <Text color={mood.color} bold>
265          {' '}
266          {mood.caption}
267        </Text>
268        <Text>
269          {'  '}
270          {figures}
271          {'  '}
272        </Text>
273        {bars.map(({ bar, color }, turn) => (
274          <Text color={color}>{turn === 0 ? bar : ` ${bar}`}</Text>
275        ))}
276        <Text dimColor>
277          {'  '}
278          {delta}
279        </Text>
280      </Box>
281    )
282  })
283}
284
types/index.d.ts 9 lines
1/** Context-window readings, oldest first: one per turn, the last 13 kept. */
2export type Forecast = { window: number; readings: number[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'token-face': { forecast: Forecast | null }
7  }
8}
9