A human-knowledge health bar that drains as Claude edits your code and refills when you pass a quiz about your own project.

A health bar for your knowledge of the code. Every line Claude writes for you is a line you did not write yourself, so the bar drains as Claude works. Win it back by passing a short quiz about your own project.
🧠 ████████████████████████████████░░░░░░░░ 7,936 / 10,000 HP -24 Claude edited architecture.md (architecture ×2) [ Test my brain ]
Health starts at 10,000 and is kept per project (per folder you start Claude Code in), across sessions.
| Event | HP |
|---|---|
| Each line Claude adds | −4 |
| Each line Claude changes | −2 |
| Each line Claude deletes | −5 |
| A new file Claude creates | −50, plus its lines |
| Lines in an architecture file | ×2 |
| Right answer: easy / medium / hard | +200 / +500 / +1,000 |
| Wrong answer | −100 |
Health never goes above 10,000. The bar is green from 6,000, yellow below 6,000, and red below 3,000. Every change plays a short animation: a loss blinks red and drains away, a gain glows green and grows in.
Claude's Edit, Write, and NotebookEdit tools count, and so do files Claude changes through Bash (for example sed or a Python one-liner): the mod reads git status before and after each Bash command and charges for every file whose text changed in between. That works only inside a git repository, and only for files git sees (gitignored files are not counted). A file you edit yourself while a Bash command is running is counted as Claude's.
Architecture files are anything under infra/, terraform/, .github/workflows/, docs/adr/, or a contract/ or contracts/ folder, plus docs/architecture.md and any .tf, .tfvars, or .bicep file. Change the list in ARCHITECTURE at the top of hooks/register.js.
Start it with the Test my brain button on the bar, or:
/brain-test five questions about the project, mostly its architecture
/brain-test animation every question about the topic you type
The questions are written by a separate model call (Sonnet) that this mod makes, so the mod holds the answers and Claude never sees them.
Short effects (start, select, right, wrong, done, alert) and a quiet looping 8-bit track while a test is open. All sounds are generated and included in sounds/.
sounds/player.ps1) mixes the music and the effects. It stops when the test ends or the panel closes, when a newer player starts, and by itself if the mod stops sending a heartbeat for 6 seconds. Volumes are at the top of player.ps1 ($musicVolume = 0.06, $sfxVolume = 0.55).git status and the changed files' text around each Bash command, and for a quiz: this session's recent conversation including the tool calls Claude made, git diff HEAD, git log, git ls-files, and the first part of README.md, CLAUDE.md, and docs/architecture.md.git, and on WSL wslpath and powershell.exe for sound.~/.claude/plugins/store/), and on WSL the sound files and player to %TEMP%\brain-bar on Windows.Claude Code 2.1.287 or newer, git. Tested with 2.1.289 on Windows (WSL, Ubuntu).
claude plugin test
Nine tests cover the quiz panel, right and wrong answers, the final screen, a small panel, the automatic opening below 6,000 HP, and each health cost.
hooks/register.js 1022 lines1// Human knowledge bar: a health bar above the prompt that Claude's edits
2// drain (4 HP per added line, 2 per changed line, 5 per deleted line) and a
3// brain test about the project wins back (easy 200, medium 500, hard 1000).
4// Health is kept per project, capped at 10,000.
5//
6// The quiz is written by a separate model call, so this mod holds the right
7// answers and Claude never sees them. It runs in this mod's own pane, where
8// the arrow keys move between answer cards and Enter picks one; Claude Code's
9// question dialog is never used for it, so Claude's own questions are untouched.
10//
11// Below 6,000 HP the test opens by itself while Claude is working.
12
13const MAX_HP = 10_000
14const LOW_HP = 6_000
15const COST = { add: 4, change: 2, del: 5 }
16const NEW_FILE_COST = 50
17const WRONG_ANSWER_COST = 100
18// Files that shape the whole system cost double: Terraform and other
19// infrastructure, pipelines, the alert contract, and decision records
20const ARCHITECTURE = [
21 /(^|\/)infra\//,
22 /(^|\/)terraform\//,
23 /\.(tf|tfvars|bicep)$/,
24 /(^|\/)\.github\/workflows\//,
25 /(^|\/)docs\/adr\//,
26 /(^|\/)contracts?\//,
27 /(^|\/)docs\/architecture\.md$/,
28]
29const POINTS = { easy: 200, medium: 500, hard: 1000 }
30const DOTS = { easy: 1, medium: 2, hard: 3 }
31const PLAN = ['easy', 'easy', 'medium', 'medium', 'hard']
32const LETTERS = ['A', 'B', 'C', 'D']
33const TICK_MS = 100
34const CONTEXT_CHARS = 40_000
35const CONVERSATION_CHARS = 25_000
36const PANE = 'brain-test'
37const INLINE_ROWS = 24
38
39// One theme color, in three shades; everything else is the terminal's own text color
40const THEME = '#d97757'
41const THEME_LIGHT = '#f2b49b'
42const THEME_DEEP = '#8f4a35'
43const INK = '#1c1c1c'
44
45// Claude Code's own spinner glyphs
46const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢']
47
48// Shared by the hooks below
49let hp = MAX_HP
50let projectKey = 'hp'
51let lastDelta = null
52let quiz = null // { questions, index, score, right, isLoading, isDone, feedback, startHp, doneAt }
53let selected = 0
54let resolveAnswer = null
55let frame = 0
56let timer = null
57let isWorking = false
58let autoOpenedThisTurn = false
59const touched = new Set()
60
61// ---------- health ----------
62
63// Green from 6,000, yellow below 6,000, red below 3,000
64function hpColor(value) {
65 if (value >= 6000) return '#87af87'
66 if (value >= 3000) return '#d7af5f'
67 return '#d75f5f'
68}
69
70// The change animation: { from, to, ms }, stepped by its own timer
71const ANIM_MS = 1500
72const ANIM_STEP = 50
73let anim = null
74let animTimer = null
75
76function startHpAnim($, from, to) {
77 // A second change mid-animation starts from what is on screen now
78 anim = { from: anim ? shownHp() : from, to, ms: 0 }
79 if (animTimer) return
80 animTimer = $.clock.every(ANIM_STEP, () => {
81 if (anim) anim.ms += ANIM_STEP
82 if (!anim || anim.ms >= ANIM_MS) {
83 anim = null
84 if (animTimer) animTimer.cancel()
85 animTimer = null
86 }
87 $.ui.invalidate('ui.render')
88 })
89}
90
91// Eased progress 0..1 of the animation's second phase (after a short hold)
92function animProgress() {
93 if (!anim) return 1
94 const t = Math.max(0, (anim.ms - 350) / (ANIM_MS - 350))
95 return 1 - Math.pow(1 - Math.min(1, t), 3)
96}
97
98// The number on screen: counts from the old value to the new one
99function shownHp() {
100 if (!anim) return hp
101 return Math.round(anim.from + (anim.to - anim.from) * animProgress())
102}
103
104// Lines added, changed, and deleted between two texts, by line content
105function lineDiff(before, after) {
106 const count = (text) => {
107 const m = new Map()
108 for (const l of text === '' ? [] : text.replace(/\n$/, '').split('\n')) m.set(l, (m.get(l) || 0) + 1)
109 return m
110 }
111 const a = count(before)
112 const b = count(after)
113 let added = 0
114 let removed = 0
115 for (const [l, n] of b) added += Math.max(0, n - (a.get(l) || 0))
116 for (const [l, n] of a) removed += Math.max(0, n - (b.get(l) || 0))
117 const change = Math.min(added, removed)
118 return { add: added - change, change, del: removed - change }
119}
120
121async function setHp($, value, note) {
122 const before = hp
123 hp = Math.max(0, Math.min(MAX_HP, Math.round(value)))
124 await $.store.set(projectKey, hp)
125 if (hp !== before) {
126 startHpAnim($, before, hp)
127 lastDelta = { value: hp - before, note }
128 $.clock.after(15_000, () => {
129 lastDelta = null
130 $.ui.invalidate('ui.render')
131 })
132 }
133 $.ui.invalidate('ui.render')
134}
135
136function isArchitecture(file) {
137 return ARCHITECTURE.some((pattern) => pattern.test(String(file || '')))
138}
139
140async function drain($, diff, file, isNewFile) {
141 const isArch = isArchitecture(file)
142 const lines = diff.add * COST.add + diff.change * COST.change + diff.del * COST.del
143 const cost = lines * (isArch ? 2 : 1) + (isNewFile ? NEW_FILE_COST : 0)
144 if (file) touched.add(file)
145 const name = String(file || '').split('/').pop()
146 const why = (isNewFile ? 'Claude created ' : 'Claude edited ') + name + (isArch ? ' (architecture ×2)' : '')
147 if (cost > 0) await setHp($, hp - cost, why)
148 await maybeAutoOpen($)
149}
150
151// Files Claude changes through Bash (sed, a heredoc, a script) never pass
152// through Edit or Write, so each Bash call is bracketed by two looks at git:
153// every file git calls changed or untracked, with its text. A file whose text
154// differs between the two looks was changed by the command, and is measured
155// against its text before (or HEAD's, if it was clean then). Outside a git
156// repository nothing is measured.
157const SNAPSHOT_MAX_FILES = 300
158const SNAPSHOT_MAX_CHARS = 1_000_000
159
160async function gitTop($) {
161 const out = await run($, ['git', '-C', await $.session.root(), 'rev-parse', '--show-toplevel'])
162 return out.trim() || null
163}
164
165async function readText($, path) {
166 try {
167 if (!(await $.fs.exists(path))) return ''
168 const text = await $.fs.read(path)
169 // Binary or huge files are not lines of code
170 return text.length > SNAPSHOT_MAX_CHARS || text.includes('\u0000') ? null : text
171 } catch {
172 return null
173 }
174}
175
176// { path: text } for every changed or untracked file, paths relative to top
177async function snapshot($, top) {
178 const out = await run($, ['git', '-C', top, 'status', '--porcelain=v1', '-z', '--untracked-files=all'])
179 const entries = out.split('\0')
180 const files = new Map()
181 for (let i = 0; i < entries.length && files.size < SNAPSHOT_MAX_FILES; i++) {
182 const entry = entries[i]
183 if (entry.length < 4) continue
184 // A rename is followed by its old path, which is not a file to read
185 if (entry[0] === 'R' || entry[0] === 'C') i++
186 const path = entry.slice(3)
187 files.set(path, await readText($, top + '/' + path))
188 }
189 return files
190}
191
192async function drainShellChanges($, top, before, after) {
193 for (const [path, text] of after) {
194 if (text === null) continue
195 const wasDirty = before.has(path)
196 if (wasDirty && before.get(path) === text) continue
197 const old = wasDirty ? before.get(path) : await run($, ['git', '-C', top, 'show', 'HEAD:' + path])
198 if (old === null) continue
199 await drain($, lineDiff(old, text), top + '/' + path, old === '' && !wasDirty)
200 }
201}
202
203// Below 6,000 HP, while Claude is working, open the test once per turn
204async function maybeAutoOpen($) {
205 if (hp >= LOW_HP || !isWorking || quiz || autoOpenedThisTurn) return
206 autoOpenedThisTurn = true
207 const r = await $.ui.open({ id: PANE, title: 'Brain test', closeOnEscape: true, rows: INLINE_ROWS })
208 if (r && r.isPlaced === false) {
209 await $.ui.close({ id: PANE })
210 $.ui.toast('🧠 Knowledge is low (' + hp.toLocaleString() + ' HP). Type /brain-test to win it back.', { timeoutMs: 8000 })
211 return
212 }
213 sfx($, 'alert')
214 runQuiz($).catch(() => {}) // stopped by a reload; nothing to report
215}
216
217// ---------- sound ----------
218
219// Under WSL a small Windows PowerShell player (sounds/player.ps1) mixes quiet
220// looping music with short effects; this mod sends it numbered commands
221// through a file. Claude Code's own player is macOS only, so elsewhere the
222// effects use $.audio.play and there is no music.
223const SOUND_FILES = ['alert', 'correct', 'done', 'music', 'select', 'start', 'wrong']
224let soundDir = null // the player's folder, as this side sees it
225let soundStarting = null
226let soundSeq = 0
227let soundQueue = []
228
229async function startPlayer($) {
230 const temp = await $.process.run(['powershell.exe', '-NoProfile', '-Command', '[IO.Path]::GetTempPath()'], { timeoutMs: 8000 })
231 if (temp.exitCode !== 0 || !temp.stdout.trim()) return null
232 const unix = await $.process.run(['wslpath', '-u', temp.stdout.trim()], { timeoutMs: 3000 })
233 if (unix.exitCode !== 0) return null
234 const dir = unix.stdout.trim().replace(/\/$/, '') + '/brain-bar'
235 const src = $.plugin.root + '/sounds/'
236 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 3000 })
237 await $.process.run(
238 ['cp', ...SOUND_FILES.map((n) => src + n + '.wav'), src + 'player.ps1', dir + '/'],
239 { timeoutMs: 10000 },
240 )
241 await $.process.run(['rm', '-f', dir + '/commands.txt', dir + '/player.log'], { timeoutMs: 3000 })
242 await $.fs.write(dir + '/alive.txt', String(Date.now()))
243 const win = (await $.process.run(['wslpath', '-w', dir], { timeoutMs: 3000 })).stdout.trim()
244 await $.process.run(
245 [
246 'powershell.exe', '-NoProfile', '-Command',
247 "Start-Process powershell.exe -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','" +
248 win + "\\player.ps1','-Dir','" + win + "'",
249 ],
250 { timeoutMs: 10000 },
251 )
252 // Wait for the player to say it started, so no command is missed
253 for (let i = 0; i < 40; i++) {
254 if (await $.fs.exists(dir + '/player.log')) return dir
255 await $.clock.sleep(100)
256 }
257 return null
258}
259
260// Starts the player once per quiz; resolves to its folder, or null without one
261let soundUnavailable = false
262
263async function player($) {
264 if (soundDir) return soundDir
265 if (soundUnavailable) return null
266 if (!soundStarting) {
267 soundSeq = 0
268 soundQueue = []
269 soundStarting = startPlayer($).catch(() => null)
270 }
271 soundDir = await soundStarting
272 soundStarting = null
273 // No Windows player here (not WSL): stop trying, use $.audio.play
274 if (!soundDir) soundUnavailable = true
275 return soundDir
276}
277
278async function sendSound($, command) {
279 const dir = await player($)
280 if (!dir) return false
281 soundSeq += 1
282 soundQueue = [...soundQueue, soundSeq + ' ' + command].slice(-20)
283 try {
284 await $.fs.write(dir + '/commands.txt', soundQueue.join('\n') + '\n')
285 return true
286 } catch {
287 return false
288 }
289}
290
291async function sfx($, name) {
292 if (await sendSound($, 'sfx ' + name)) return
293 try {
294 await $.audio.play({ asset: 'sounds/' + name + '.wav' })
295 } catch {
296 // No player here; the quiz works without sound
297 }
298}
299
300async function music($, isOn) {
301 await sendSound($, 'music ' + (isOn ? 'start' : 'stop'))
302}
303
304// Tells the player the mod is still here; it stops itself without this
305function heartbeat($) {
306 if (soundDir) $.fs.write(soundDir + '/alive.txt', String(Date.now())).catch(() => {})
307}
308
309// Stops the music and lets the player exit after its last effect
310async function stopPlayer($) {
311 if (!soundDir) return
312 await sendSound($, 'quit')
313 soundDir = null
314}
315
316// ---------- quiz ----------
317
318async function run($, args) {
319 try {
320 const r = await $.process.run(args, { timeoutMs: 15_000 })
321 return r.exitCode === 0 ? r.stdout : ''
322 } catch {
323 return ''
324 }
325}
326
327async function readHead($, path, chars) {
328 try {
329 if (!(await $.fs.exists(path))) return ''
330 return (await $.fs.read(path)).slice(0, chars)
331 } catch {
332 return ''
333 }
334}
335
336// The newest part of this session's conversation, with the code Claude ran
337async function conversation($) {
338 let messages = []
339 try {
340 messages = await $.session.messages()
341 } catch {
342 return ''
343 }
344 const lines = []
345 for (const m of messages.slice(-120)) {
346 if (m.text && m.text.trim()) lines.push(m.role + ': ' + m.text.trim().slice(0, 2000))
347 for (const u of m.toolUses || []) lines.push('[' + u.tool + '] ' + JSON.stringify(u.input || {}).slice(0, 2500))
348 }
349 return lines.join('\n').slice(-CONVERSATION_CHARS)
350}
351
352async function gatherContext($) {
353 const parts = []
354 const add = (title, text) => {
355 if (text && text.trim()) parts.push('## ' + title + '\n' + text.trim())
356 }
357 add('This session so far (newest last)', await conversation($))
358 add('Files Claude changed this session', [...touched].join('\n'))
359 add('Uncommitted changes', (await run($, ['git', 'diff', 'HEAD'])).slice(0, 12_000))
360 add('Recent commits', await run($, ['git', 'log', '--stat', '-8', '--format=--- %s']))
361 add('Project files', (await run($, ['git', 'ls-files'])).split('\n').slice(0, 300).join('\n'))
362 add('README.md', await readHead($, 'README.md', 6_000))
363 add('CLAUDE.md', await readHead($, 'CLAUDE.md', 6_000))
364 add('docs/architecture.md', await readHead($, 'docs/architecture.md', 6_000))
365 return parts.join('\n\n').slice(0, CONTEXT_CHARS)
366}
367
368function parseQuiz(text) {
369 const start = text.indexOf('{')
370 const end = text.lastIndexOf('}')
371 if (start < 0 || end < start) return null
372 const data = JSON.parse(text.slice(start, end + 1))
373 const questions = (data.questions || []).filter(
374 (q) =>
375 POINTS[q.difficulty] &&
376 typeof q.question === 'string' &&
377 Array.isArray(q.choices) &&
378 q.choices.length === 4 &&
379 Number.isInteger(q.answer) &&
380 q.answer >= 0 &&
381 q.answer < 4,
382 )
383 return questions.length ? questions : null
384}
385
386function startTicker($) {
387 if (timer) return
388 timer = $.clock.every(TICK_MS, () => {
389 frame += 1
390 if (frame % 20 === 0) heartbeat($)
391 $.ui.invalidate('ui.render')
392 })
393}
394
395function stopTicker() {
396 if (timer) timer.cancel()
397 timer = null
398}
399
400// Opens the quiz pane; called from what the person did, so it shows at any width
401async function openPane($) {
402 const r = await $.ui.open({ id: PANE, title: 'Brain test', focus: true, closeOnEscape: true, rows: INLINE_ROWS })
403 if (r && r.isPlaced === false) $.ui.toast('The brain test pane is waiting: ' + (r.reason || 'widen the terminal'))
404}
405
406// Resolves with the answer index the person picks, or null when the pane closes
407function waitForAnswer() {
408 return new Promise((resolve) => {
409 resolveAnswer = resolve
410 })
411}
412
413function answer(i) {
414 if (!resolveAnswer || !quiz || quiz.feedback) return
415 const resolve = resolveAnswer
416 resolveAnswer = null
417 resolve(i)
418}
419
420async function startFromButton($) {
421 await openPane($)
422 runQuiz($).catch(() => {}) // stopped by a reload; nothing to report
423}
424
425// Each quiz run gets a number; closing the pane cancels the current one.
426// A run that finds its number out of date stops at its next step.
427let quizRun = 0
428let quizAbort = null
429
430function cancelQuiz($, shouldSay) {
431 if (!quiz) return
432 quizRun += 1
433 if (quizAbort) quizAbort.abort()
434 quizAbort = null
435 if (resolveAnswer) {
436 const resolve = resolveAnswer
437 resolveAnswer = null
438 resolve(null)
439 }
440 quiz = null
441 stopTicker()
442 stopPlayer($).catch(() => {})
443 $.ui.invalidate('ui.render')
444 if (shouldSay) $.ui.toast('Brain test cancelled')
445}
446
447async function runQuiz($, topic) {
448 if (quiz) return
449 quizRun += 1
450 const thisRun = quizRun
451 const isCurrent = () => thisRun === quizRun
452 quizAbort = new AbortController()
453 const signal = quizAbort.signal
454 quiz = { questions: [], index: 0, score: 0, right: 0, isLoading: true, isDone: false, feedback: null, startHp: hp, loadAt: frame }
455 selected = 0
456 startTicker($)
457 sfx($, 'start').then(() => music($, true)).catch(() => {})
458 $.ui.invalidate('ui.render')
459 try {
460 const context = await gatherContext($)
461 if (!isCurrent()) return
462 const r = await $.model.complete({
463 model: 'sonnet',
464 system:
465 'You write short multiple-choice quizzes that check whether a developer understands their own project. ' +
466 'Reply with JSON only, no prose and no code fences.',
467 prompt:
468 'Write ' + PLAN.length + ' questions in this order of difficulty: ' + PLAN.join(', ') + '.\n' +
469 (topic
470 ? 'Every question must be about this topic: ' + topic + '. Use the session conversation and the code Claude ran in it.\n'
471 : '') +
472 'Otherwise focus mostly on the architecture: how the parts fit together, what calls what, where data and secrets live, and why. ' +
473 'Include at least one question about the changes made this session if there are any.\n' +
474 'Easy checks a fact, medium checks how two parts connect, hard checks a design reason or a consequence of a change.\n' +
475 'Each question at most 160 characters. Exactly 4 choices, each at most 70 characters, only one correct, wrong ones plausible.\n' +
476 'Format: {"questions":[{"difficulty":"easy","question":"...","choices":["...","...","...","..."],"answer":0,"why":"one short sentence"}]}\n\n' +
477 context,
478 maxTokens: 3000,
479 timeoutMs: 120_000,
480 }, { signal })
481 if (!isCurrent()) return
482 const questions = r.isAnswered ? parseQuiz(r.text) : null
483 if (!questions) {
484 $.ui.toast('Could not write a brain test' + (r.isAnswered ? '' : ' (' + r.reason + ')'))
485 await $.ui.close({ id: PANE })
486 return
487 }
488 quiz = { ...quiz, questions, isLoading: false }
489
490 for (let i = 0; i < questions.length; i++) {
491 quiz.index = i
492 quiz.feedback = null
493 selected = 0
494 $.ui.invalidate('ui.render')
495 const q = questions[i]
496 const picked = await waitForAnswer()
497 if (picked === null || !isCurrent()) return
498 const points = POINTS[q.difficulty]
499 const isRight = picked === q.answer
500 quiz.feedback = { picked, isRight, at: frame }
501 $.ui.invalidate('ui.render')
502 sfx($, isRight ? 'correct' : 'wrong')
503 if (isRight) {
504 quiz.score += points
505 quiz.right += 1
506 await setHp($, hp + points, 'brain test')
507 } else {
508 quiz.lost = (quiz.lost || 0) + WRONG_ANSWER_COST
509 await setHp($, hp - WRONG_ANSWER_COST, 'wrong answer')
510 }
511 await $.clock.sleep(isRight ? 2000 : 5000)
512 if (!isCurrent()) return
513 }
514 quiz.isDone = true
515 quiz.doneAt = frame
516 quiz.feedback = null
517 $.ui.invalidate('ui.render')
518 sfx($, 'done')
519 $.ui.log('brain test done: ' + quiz.right + ' of ' + quiz.questions.length + ' right, +' + quiz.score + ' HP, now ' + hp.toLocaleString() + ' / 10,000')
520 await $.clock.sleep(6000)
521 if (isCurrent()) await $.ui.close({ id: PANE })
522 } finally {
523 // A cancelled run was already cleaned up by cancelQuiz
524 if (isCurrent()) {
525 stopTicker()
526 stopPlayer($).catch(() => {})
527 quiz = null
528 resolveAnswer = null
529 quizAbort = null
530 $.ui.invalidate('ui.render')
531 }
532 }
533}
534
535// ---------- fitting ----------
536
537// Rows a text takes when wrapped to a width
538function rowsOf(text, width) {
539 return Math.max(1, Math.ceil([...String(text)].length / Math.max(1, width)))
540}
541
542// Text cut to a number of characters, with an ellipsis
543function clip(text, n) {
544 const chars = [...String(text)]
545 return chars.length > n ? chars.slice(0, Math.max(0, n - 1)).join('') + '…' : chars.join('')
546}
547
548// ---------- pixel art ----------
549
550const DEFAULT_COLOR = 0x01000000
551const hex = (c) => parseInt(c.slice(1), 16)
552
553// Claude Code's mascot: X body, o eyes, l legs (two leg poses)
554const CLAWD = [
555 '..XXXXXXX..',
556 '..XoXXXoX..',
557 'XXXXXXXXXXX',
558 '..XXXXXXX..',
559]
560const LEGS = [
561 ['..l.l.l.l..', '..l.l.l.l..'],
562 ['..l.l.l.l..', '.l.l...l.l.'],
563]
564
565const BRAIN = [
566 '...XXXXXX...',
567 '.XXX.XX.XXX.',
568 'XX.XXXXXX.XX',
569 'XXXX.XX.XXXX',
570 'XX.XXXXXX.XX',
571 '.XXX.XX.XXX.',
572 '...XXXXXX...',
573 '.....XX.....',
574]
575
576// The loading picture: Claude sends knowledge across to the brain
577function loadingPixels(t, width, height) {
578 const px = Array.from({ length: height }, () => Array(width).fill(null))
579 const put = (x, y, c) => {
580 if (x >= 0 && x < width && y >= 0 && y < height) px[y][x] = c
581 }
582 const clawdX = Math.floor(width * 0.18) - 5
583 const brainX = Math.floor(width * 0.82) - 6
584 const bob = Math.floor(t / 4) % 2
585 const top = Math.floor((height - 8) / 2)
586 const art = [...CLAWD, ...LEGS[Math.floor(t / 3) % 2]]
587 art.forEach((row, y) =>
588 [...row].forEach((ch, x) => {
589 if (ch === 'X' || ch === 'l') put(clawdX + x, top + 1 + y - bob, THEME)
590 if (ch === 'o') put(clawdX + x, top + 1 + y - bob, INK)
591 }),
592 )
593 const glow = Math.floor(t / 5) % 2 === 0 ? THEME_LIGHT : THEME
594 BRAIN.forEach((row, y) => [...row].forEach((ch, x) => ch === 'X' && put(brainX + x, top + y, glow)))
595 // Sparks travel from Claude to the brain
596 const from = clawdX + 13
597 const to = brainX - 2
598 const span = Math.max(1, to - from)
599 for (let k = 0; k < 4; k++) {
600 const x = from + ((t * 1 + k * Math.ceil(span / 4)) % span)
601 const y = top + 3 + Math.round(Math.sin((x - from) / 3) * 1.2)
602 put(x, y, k % 2 ? THEME : THEME_LIGHT)
603 }
604 return px
605}
606
607// Pixels to a Raster: two pixel rows per cell, drawn with half blocks
608function toCells(px) {
609 const nums = []
610 for (let y = 0; y < px.length; y += 2) {
611 for (let x = 0; x < px[y].length; x++) {
612 const top = px[y][x]
613 const bottom = y + 1 < px.length ? px[y + 1][x] : null
614 if (top && bottom) nums.push('▀'.codePointAt(0), hex(top), hex(bottom))
615 else if (top) nums.push('▀'.codePointAt(0), hex(top), DEFAULT_COLOR)
616 else if (bottom) nums.push('▄'.codePointAt(0), hex(bottom), DEFAULT_COLOR)
617 else nums.push(32, DEFAULT_COLOR, DEFAULT_COLOR)
618 }
619 }
620 return new Uint8Array(Uint32Array.from(nums).buffer).toBase64()
621}
622
623// ---------- hooks ----------
624
625export function register(on) {
626 on('session.start', async ($, e, next) => {
627 projectKey = 'hp:' + (await $.session.root())
628 const saved = await $.store.get(projectKey)
629 hp = typeof saved === 'number' ? saved : MAX_HP
630 try {
631 await $.command.register({
632 name: 'brain-test',
633 description: 'Test your knowledge of this project to win back HP',
634 argumentHint: '[topic]',
635 immediate: true,
636 })
637 } catch {
638 // The name is taken; the button still works
639 }
640 return next(e)
641 })
642
643 // Know when Claude is working, for the low-health auto-open
644 on('turn.start', async ($, e, next) => {
645 isWorking = true
646 autoOpenedThisTurn = false
647 await maybeAutoOpen($)
648 return next(e)
649 })
650
651 on('turn.complete', async ($, e, next) => {
652 isWorking = false
653 return next(e)
654 })
655
656 // Claude's file edits drain health once they succeed
657 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
658 const r = await next(e)
659 if (r && !r.deny && !r.isError) await drain($, lineDiff(e.old_string || '', e.new_string || ''), e.file_path)
660 return r
661 })
662
663 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
664 let before = ''
665 let isNewFile = false
666 try {
667 if (await $.fs.exists(e.file_path)) before = await $.fs.read(e.file_path)
668 else isNewFile = true
669 } catch {
670 before = ''
671 }
672 const r = await next(e)
673 if (r && !r.deny && !r.isError) await drain($, lineDiff(before, e.content || ''), e.file_path, isNewFile)
674 return r
675 })
676
677 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
678 const top = await gitTop($)
679 const before = top ? await snapshot($, top) : null
680 const r = await next(e)
681 // A failing command can still have written files, so isError is not checked
682 if (top && r && !r.deny) await drainShellChanges($, top, before, await snapshot($, top))
683 return r
684 })
685
686 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
687 const r = await next(e)
688 if (r && !r.deny && !r.isError) {
689 const lines = (e.new_source || '').split('\n').length
690 const diff =
691 e.edit_mode === 'insert' ? { add: lines, change: 0, del: 0 }
692 : e.edit_mode === 'delete' ? { add: 0, change: 0, del: 1 }
693 : { add: 0, change: lines, del: 0 }
694 await drain($, diff, e.notebook_path)
695 }
696 return r
697 })
698
699 on('command.run', { command: 'brain-test' }, async ($, e) => {
700 if (quiz) return {}
701 await openPane($)
702 runQuiz($, (e.args || '').trim()).catch(() => {}) // stopped by a reload
703 return {}
704 })
705
706 // The bar, always above the prompt; other mods' band drawing stays under it
707 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
708 if (e.props.hasSurvey) return next(e)
709 const { Box, Text, Button } = $.ui.resolve(e)
710 const width = 40
711 const cells = (v) => Math.round((Math.max(0, v) / MAX_HP) * width)
712 const color = hpColor(hp)
713 const shown = shownHp()
714 const isHit = anim && anim.to < anim.from
715 const isHeal = anim && anim.to > anim.from
716 const blink = anim ? Math.floor(anim.ms / 150) % 2 === 0 : false
717
718 // Bar: the new health, then the piece that changed, then empty
719 let solid = cells(hp)
720 let ghost = 0
721 let ghostColor = color
722 if (isHit) {
723 // The lost piece blinks red, then drains away; at least one cell shows
724 const edge = anim.from + (anim.to - anim.from) * animProgress()
725 ghost = Math.max(anim.ms < ANIM_MS - 100 ? 1 : 0, cells(edge) - solid)
726 ghostColor = blink ? '#ff5f5f' : '#870000'
727 } else if (isHeal) {
728 // The gained piece glows in, growing from the old edge
729 solid = cells(anim.from + (anim.to - anim.from) * animProgress())
730 ghost = Math.max(0, cells(hp) - solid)
731 ghostColor = blink ? '#d7ffd7' : '#87af87'
732 }
733 ghost = Math.min(ghost, width - solid)
734
735 const children = [
736 Text({ children: [isHit && anim.ms < 450 ? '💥 ' : '🧠 '] }),
737 Text({ color, children: ['█'.repeat(solid)] }),
738 Text({ color: ghostColor, children: [(isHit ? '▓' : '█').repeat(ghost)] }),
739 Text({ dimColor: true, children: ['░'.repeat(width - solid - ghost)] }),
740 Text({
741 bold: true,
742 ...(isHit ? { color: '#ff8787' } : isHeal ? { color: '#afffaf' } : {}),
743 children: [' ' + shown.toLocaleString() + ' / 10,000 HP'],
744 }),
745 ]
746 if (lastDelta) {
747 children.push(
748 Text({
749 color: lastDelta.value > 0 ? '#87af87' : '#d78787',
750 children: [' ' + (lastDelta.value > 0 ? '+' : '') + lastDelta.value + ' ' + lastDelta.note],
751 }),
752 )
753 }
754 children.push(Text({ children: [' '] }))
755 if (quiz && quiz.isLoading) {
756 children.push(Text({ dimColor: true, children: ['writing your brain test…'] }))
757 } else if (quiz && !quiz.isDone) {
758 children.push(Text({ dimColor: true, children: ['brain test ' + (quiz.index + 1) + '/' + quiz.questions.length] }))
759 } else if (!quiz) {
760 children.push(Button({ key: 'brain-test', label: 'Test my brain', onPress: () => startFromButton($) }))
761 }
762 const bar = Box({ flexDirection: 'row', children })
763 const theirs = await next(e)
764 return Box({ flexDirection: 'column', children: theirs ? [bar, theirs] : [bar] })
765 })
766
767 // Arrow keys move the focus between answer buttons; follow it to light the card
768 on('ui.focus', async ($, e, next) => {
769 if (e.requestId === PANE && typeof e.element === 'string' && e.element.startsWith('ans-')) {
770 const moved = Number(e.element.slice(4))
771 if (moved !== selected && e.origin && e.origin.kind === 'person') sfx($, 'select').catch(() => {})
772 selected = moved
773 $.ui.invalidate('ui.render')
774 }
775 return next(e)
776 })
777
778 // Esc or ctrl+x x closes the pane: stop the test
779 // Closing the pane cancels the test at any stage, loading included
780 on('ui.close', async ($, e, next) => {
781 if (e.id === PANE) {
782 stopPlayer($).catch(() => {})
783 // Only say so when a test was really cut short, not when it closed itself at the end
784 cancelQuiz($, Boolean(quiz && !quiz.isDone))
785 }
786 return next(e)
787 })
788
789 // Arrows scroll a pane taller than its window instead of moving the focus.
790 // While a question waits, turn those arrow presses into moving the selection.
791 on('ui.scroll', async ($, e, next) => {
792 const isArrow = e.origin && e.origin.kind === 'person' && !e.pointer && Math.abs(e.by) === 1
793 const isAsking = quiz && !quiz.isLoading && !quiz.isDone && !quiz.feedback
794 if (e.requestId !== PANE || !isArrow || !isAsking) return next(e)
795 const moved = Math.max(0, Math.min(3, selected + Math.sign(e.by)))
796 if (moved !== selected) sfx($, 'select').catch(() => {})
797 selected = moved
798 $.ui.invalidate('ui.render')
799 $.ui.focus({ requestId: PANE, key: 'ans-' + selected }).catch(() => {})
800 $.ui.scroll({ in: PANE, to: { key: 'card-' + selected } }).catch(() => {})
801 return { deny: 'the arrow moved the selection' }
802 })
803
804 // The quiz pane: retro, and sized to the pane it is given
805 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
806 if (e.requestId !== PANE) return next(e)
807 const { Box, Text, Button, Raster } = $.ui.resolve(e)
808 const width = Math.max(24, e.props.bodyColumns)
809 const viewRows = e.viewport && e.viewport.rows ? e.viewport.rows : 40
810 const height =
811 e.props.placement === 'dock' ? Math.max(10, e.props.scroll.bodyRows) : Math.max(12, Math.min(INLINE_ROWS, viewRows - 8))
812 const inner = width - 4 // inside the double frame and its padding
813 const isTerminal = e.surface === 'terminal'
814 const spin = SPINNER[frame % SPINNER.length]
815 const cursor = Math.floor(frame / 5) % 2 === 0 ? '█' : ' '
816 const blank = () => Text({ children: [' '] })
817 const frameBox = (children, extra = {}) =>
818 Box({ flexDirection: 'column', width, height, borderStyle: 'double', borderColor: THEME, paddingX: 1, ...extra, children })
819 const title = (right) =>
820 Box({
821 flexDirection: 'row',
822 justifyContent: 'space-between',
823 children: [
824 Text({ bold: true, color: THEME, children: [inner < 40 ? '▓▒░ BRAIN' : '▓▒░ BRAIN TEST ░▒▓'] }),
825 Text({ color: THEME, children: [right] }),
826 ],
827 })
828 const rule = Text({ color: THEME_DEEP, children: ['═'.repeat(inner)] })
829
830 // ----- loading: Claude passing knowledge to the brain, filling the pane -----
831 if (!quiz || quiz.isLoading) {
832 const t = quiz ? frame - quiz.loadAt : frame
833 const phases = ['READING THIS SESSION', 'MAPPING THE ARCHITECTURE', 'PICKING WHAT MATTERS', 'WRITING 5 QUESTIONS']
834 const phase = phases[Math.floor(t / 25) % phases.length]
835 const barWidth = Math.min(inner - 2, 30)
836 const filled = Math.min(barWidth, Math.floor(t / 3) % (barWidth + 1))
837 const meter = '▓'.repeat(filled) + '░'.repeat(barWidth - filled)
838 const roomForArt = height - 9 >= 7 && inner >= 34
839 const artWidth = Math.min(inner, 64)
840 const art =
841 isTerminal && roomForArt
842 ? Raster({ key: 'loading', columns: artWidth, rows: 7, cells: toCells(loadingPixels(t, artWidth, 14)) })
843 : Text({ color: THEME, children: [spin + ' ' + '·•'.repeat(1 + (t % 3)) + ' 🧠'] })
844 return frameBox(
845 [
846 title(spin),
847 rule,
848 Box({
849 flexDirection: 'column',
850 flexGrow: 1,
851 justifyContent: 'center',
852 alignItems: 'center',
853 children: [
854 art,
855 blank(),
856 Text({ bold: true, color: THEME, children: ['LOADING' + '.'.repeat((Math.floor(t / 4) % 3) + 1)] }),
857 Text({ dimColor: true, children: [phase] }),
858 blank(),
859 Text({ color: THEME, children: [meter] }),
860 ],
861 }),
862 ],
863 { key: 'loading-frame' },
864 )
865 }
866
867 // ----- done: arcade score -----
868 if (quiz.isDone) {
869 const t = frame - quiz.doneAt
870 const won = Math.round(quiz.score * Math.min(1, t / 12))
871 const score = String(won).padStart(6, '0')
872 const blink = Math.floor(t / 4) % 2 === 0
873 const jump = Math.floor(t / 3) % 2
874 const px = Array.from({ length: 10 }, () => Array(11).fill(null))
875 ;[...CLAWD, ...LEGS[jump]].forEach((row, y) =>
876 [...row].forEach((ch, x) => {
877 const yy = y + 2 - jump * 2
878 if (ch === 'X' || ch === 'l') px[yy][x] = THEME
879 if (ch === 'o') px[yy][x] = INK
880 }),
881 )
882 return frameBox([
883 title('★'),
884 rule,
885 Box({
886 flexDirection: 'column',
887 flexGrow: 1,
888 justifyContent: 'center',
889 alignItems: 'center',
890 children: [
891 ...(isTerminal && height >= 16 ? [Raster({ key: 'done', columns: 11, rows: 5, cells: toCells(px) }), blank()] : []),
892 Text({ bold: true, color: THEME, children: [blink ? '★ QUIZ COMPLETE ★' : '☆ QUIZ COMPLETE ☆'] }),
893 blank(),
894 Text({ bold: true, children: ['SCORE ' + score] }),
895 Text({ children: [quiz.right + ' / ' + quiz.questions.length + ' CORRECT'] }),
896 ...(quiz.lost ? [Text({ dimColor: true, children: ['−' + quiz.lost + ' HP for wrong answers'] })] : []),
897 Text({ dimColor: true, children: ['HP ' + quiz.startHp.toLocaleString() + ' → ' + hp.toLocaleString()] }),
898 ],
899 }),
900 ])
901 }
902
903 // ----- a question -----
904 const q = quiz.questions[quiz.index]
905 const fb = quiz.feedback
906 const t = fb ? frame - fb.at : 0
907 const progress = quiz.questions.map((_, i) => (i <= quiz.index ? '▰' : '▱')).join('')
908 const dots = '●'.repeat(DOTS[q.difficulty]) + '○'.repeat(3 - DOTS[q.difficulty])
909 const right = inner < 44 ? dots : dots + ' +' + POINTS[q.difficulty] + ' ' + progress
910
911 // Fit to the pane: wrapped answers if they fit, one line each if not
912 const optWidth = inner - 7 // "▶ [A] "
913 const fixed = 2 /* frame */ + 2 /* title, rule */ + 1 /* Q n/5 */ + 1 + 1 + 1 /* blanks */ + (fb ? 3 : 1) /* footer */
914 const questionRows = rowsOf('> ' + q.question + ' ', inner)
915 const wrappedRows = q.choices.reduce((n, c) => n + rowsOf(c, optWidth), 0)
916 const isRoomy = fixed + questionRows + wrappedRows <= height
917 const isTight = fixed + questionRows + 4 > height
918 const choiceText = (c) => (isRoomy ? c : clip(c, optWidth))
919
920 const rows = q.choices.map((choice, i) => {
921 const isSel = i === selected && !fb
922 const isAnswer = i === q.answer
923 const isPicked = fb && i === fb.picked
924 let fill = {}
925 let text = {}
926 let marker = ' '
927 let shift = 0
928
929 if (isSel) {
930 // Selected: an inverse bar with a blinking pointer
931 fill = { backgroundColor: THEME }
932 text = { color: INK, bold: true }
933 marker = Math.floor(frame / 5) % 2 === 0 ? '▶' : '▷'
934 } else if (fb && fb.isRight && isPicked) {
935 // Right: the bar flashes, then stays lit
936 const flash = t < 8 && Math.floor(t / 2) % 2 === 0
937 fill = { backgroundColor: flash ? THEME_LIGHT : THEME }
938 text = { color: INK, bold: true }
939 marker = '✓'
940 } else if (fb && !fb.isRight && isPicked) {
941 // Wrong: the row shakes and is struck through
942 shift = t < 8 ? [0, 2, 0, 2, 1, 0, 1, 0][t] : 0
943 text = { strikethrough: true, dimColor: true }
944 marker = '✗'
945 } else if (fb && !fb.isRight && isAnswer && t >= 6) {
946 // Then the right answer lights up and pulses
947 fill = { backgroundColor: Math.floor(t / 3) % 2 === 0 ? THEME : THEME_LIGHT }
948 text = { color: INK, bold: true }
949 marker = '✓'
950 } else if (fb) {
951 text = { dimColor: true }
952 }
953
954 return Box({
955 key: 'card-' + i,
956 flexDirection: 'row',
957 marginLeft: shift,
958 ...fill,
959 children: [
960 Text({ color: fill.backgroundColor ? INK : THEME, bold: true, children: [marker + ' '] }),
961 fb
962 ? Text({ ...text, children: ['[' + LETTERS[i] + '] '] })
963 : Button({
964 key: 'ans-' + i,
965 label: '[' + LETTERS[i] + ']',
966 plain: true,
967 ...(i === selected ? { autoFocus: true } : {}),
968 onPress: () => answer(i),
969 }),
970 Text({ ...text, wrap: isRoomy ? 'wrap' : 'truncate-end', children: [' ' + choiceText(choice)] }),
971 ],
972 })
973 })
974
975 let footer
976 if (!fb) {
977 footer = Text({ dimColor: true, children: [inner < 44 ? '↑↓ ⏎ esc' : '↑↓ SELECT ⏎ CONFIRM ESC QUIT'] })
978 } else if (fb.isRight) {
979 const won = Math.round(POINTS[q.difficulty] * Math.min(1, t / 8))
980 const stars = Math.floor(t / 2) % 2 === 0 ? '★ ☆ ★' : '☆ ★ ☆'
981 footer = Box({
982 flexDirection: 'column',
983 alignItems: 'center',
984 children: [
985 Text({ color: THEME, children: [stars] }),
986 Text({ bold: true, color: THEME, children: ['CORRECT +' + won + ' HP'] }),
987 blank(),
988 ],
989 })
990 } else {
991 const why = 'ANSWER: ' + LETTERS[q.answer] + '. ' + (q.why || '')
992 const typed = t < 6 ? '' : why.slice(0, (t - 6) * 4)
993 footer = Box({
994 flexDirection: 'column',
995 children: [
996 Text({ bold: true, color: THEME_DEEP, children: ['✗ WRONG −' + WRONG_ANSWER_COST + ' HP'] }),
997 Text({ wrap: isTight ? 'truncate-end' : 'wrap', children: [typed + (typed.length < why.length && t >= 6 ? '█' : '')] }),
998 ],
999 })
1000 }
1001
1002 return frameBox([
1003 title(right),
1004 rule,
1005 Text({ color: THEME, children: ['Q' + (quiz.index + 1) + '/' + quiz.questions.length + ' ' + q.difficulty.toUpperCase()] }),
1006 Text({
1007 bold: true,
1008 wrap: isTight ? 'truncate-end' : 'wrap',
1009 children: [
1010 Text({ color: THEME, children: ['> '] }),
1011 isTight ? clip(q.question, inner - 4) : q.question,
1012 Text({ color: THEME, children: [' ' + (fb ? '' : cursor)] }),
1013 ],
1014 }),
1015 ...(isTight ? [] : [blank()]),
1016 ...rows,
1017 ...(isTight ? [] : [blank()]),
1018 footer,
1019 ])
1020 })
1021}
1022