SLOPSHOPPER

Brain Bar

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

newpanebandguardcommandtoast
v1.1.0no licenseupdated 2026-10-05sameerthapax/know-your-claude/brain-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brain-bar
│ ┃ brain-test ✕ › fix the failing auth test and add ╭──────────────────────────────╮ │ ┃ ╔═══════════════════════════════════════════ │ brain-bar │ │ ┃ ║ ▓▒░ BRAIN TEST ░▒▓ ⏺ Read(src/auth.ts) │ Could not write a brain test │ │ ┃ ║ ══════════════════════════════════════════ ⎿ 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 │ ┃ ║ LOADING. │ ┃ ║ READING THIS SESSION › /brain-test │ ┃ ║ │ ┃ ║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ │ ┃ ║ 💥 ███████████████████████████████████████▓ 10,000 / 10,000 HP -78 Claude created cache.ts writing your brain te ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
💥 ███████████████████████████████████████▓ 10,000 / 10,000 HP -78 Claude created cache.ts writi ⟨Claude Code's own drawing⟩
Pane · brain-test
╔══════════════════════════════════════════════════════╗ ║ ▓▒░ BRAIN TEST ░▒▓ · ║ ║ ════════════════════════════════════════════════════ ║ ║ ║ ║ ▄▄▄▄▄▄ ║ ║ ▀▀▀▀▀▀▀ ▄ ▄▀▀▀▄▀▀▄▀▀▀▄ ║ ║ ▀▀▀▀▀▀▀▀▀▀▀ ▀ ▄ ▀ ▀▀▀▀▄▀▀▄▀▀▀▀ ║ ║ ▀ ▀ ▀ ▀ ▀▀▀▄▀ ║ ║ ║ ║ ║ ║ ║ ║ LOADING. ║ ║ READING THIS SESSION ║ ║ ║ ║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ║ ╚══════════════════════════════════════════════════════╝
README

Brain Bar

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 ]

How health changes

Health starts at 10,000 and is kept per project (per folder you start Claude Code in), across sessions.

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

The brain test

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
  • Five questions: two easy, two medium, one hard, four choices each.
  • ↑ ↓ select, Enter answers, Esc cancels at any stage, loading included.
  • A right answer flashes and counts up its points; a wrong one shakes, is struck through, and shows the right answer with a one-line reason.
  • It opens by itself while Claude is working once health is below 6,000, at most once per reply. Claude Code only opens a panel unasked in a terminal at least 144 columns wide; in a narrower one you get a reminder instead.

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.

Sound and music

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

  • Windows (WSL): a small hidden PowerShell player (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).
  • macOS: effects play through Claude Code's own player; there is no music.
  • Linux (not WSL): no sound; everything else works.

What it reads, runs, and sends

  • Reads: the files Claude edits (to count lines), 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.
  • Sends: that quiz context to a model on your own Claude plan, once per test. Nothing else leaves your machine.
  • Runs: git, and on WSL wslpath and powershell.exe for sound.
  • Writes: health to Claude Code's per-plugin store (~/.claude/plugins/store/), and on WSL the sound files and player to %TEMP%\brain-bar on Windows.

Requirements

Claude Code 2.1.287 or newer, git. Tested with 2.1.289 on Windows (WSL, Ubuntu).

Tests

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.

Source 1 files
hooks/register.js 1022 lines
1// 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