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

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
| Context used | Caption | Picture |
|---|---|---|
| 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 up | COMPACT. NOW. | <img src="faces/4.png" width="64" alt="Stage 4 picture"> |
134.4k / 200k.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 (•_•).
/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"
The mod ships with four pictures, one per stage, in the plugin's faces/ folder. To use your own, replace them:
| File | Shown when context is |
|---|---|
1.png | under 40% |
2.png | 40–64% |
3.png | 65–89% |
4.png | 90% 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.
From this folder:
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 284 lines1import { 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}
284types/index.d.ts 9 lines1/** 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