SLOPSHOPPER

claudle

Programming mini-games to play while Claude works: Spot the Bug, What Does It Print?, Two Truths and a Lie, Real or Hallucinated?, Code Review Court, Big-O…

newpanecommandtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudle
│ ┃ Claudle ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀ ▀▀ ▀ ▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▀▄ ▄▄▀ 1: Spot the Bug ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ▄▀▀▀ ▀▄ one planted bug, explain it │ ┃ │ ▄▀▀ ▀▄ new · give it a go ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ › /claudle │ ┃ [ Surprise me ] t: Topics p: Progress ⎿ claudle: Claudle opened. │ ┃ │ ┃ No rounds yet. Pick a card! │ ┃ │ ┃ Click to play, or ctrl+x tab to give this │ ┃ pane the keyboard (1-6 play a game · s │ ┃ surprise · t topics · p progress) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claudle
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀ ▀ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ╭──────────────────────────────────────────────────────╮╭─── │ ▀▄ ▄▄▀ 1: Spot the Bug ││ ▀ │ ▄▀▀▀ ▀▄ one planted bug, explain it ││ │ ▄▀▀ ▀▄ new · give it a go ││ ▀ ╰──────────────────────────────────────────────────────╯╰─── [ Surprise me ] t: Topics p: Progress No rounds yet. Pick a card! Click to play, or ctrl+x tab to give this pane the keyboard (1-6 play a game · s surprise · t topics · p progress)
README

catras-claude-code-collection

A Claude Code plugin marketplace with my mods and plugins.

Install

/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection
/plugin install claudle@catras-claude-code-collection

The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.

Plugins

agent-comic

A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.

  • Home hub: each session opens at Claude's home: a hall with keepsakes from earlier sessions, a garden and a den. While nothing is happening he keeps himself busy at random, like a screensaver: he waters the flowers, watches a film on the couch, lies down in bed for a rest, or strolls past his keepsakes. On any new turn he drops everything and sprints through the door (there is one in every room) into the session's world; after a long idle he comes back home. Claude can add a keepsake (a trophy, a gem, a plant...) after a real milestone, with the mod's own tool (hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them.
  • Pet him: the ♥ beside the band (click it in fullscreen mode, or press ctrl+x tab, then p), or /comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.
  • One world per session: the session's first turn sets up a place (a setting, a hat and four props) and every later turn plays in it, so the story can call back to earlier turns. The director owns it and changes it rarely, as part of a scene, when the work really shifts: he lifts a prop overhead and, with a puff, it becomes something new (up to 6 per session); a puff over his head brings a new hat (up to 4); or he summons a door and walks through it into new scenery (up to 2). Canned scenes never change it, and nothing is ever squashed away. After a long idle he goes home, and the next turn takes him back into the same world.
  • Needs you: when a permission prompt or a question is waiting, he stops, turns to you and waves.
  • Progress: the agent's task list shows as a trail along the ground, with a flag at the end.
  • Git: a commit plants a little flag, a push lets a balloon go.
  • Sky: the local hour (moon at night, low sun at dusk) and the session's weather (clouds after failures, rain after several), kept in the background.
  • Director: hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.
  • Places: other plugins can add places to Claude's world through $.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place.
  • Desktop app and VS Code: there the band is drawn as SVG with the same pixels. Once a second the comic is played a second ahead and sent as one SVG that runs its 20 frames by itself, so it shows up to a second later than in the terminal. /comic svg image (default) draws it as a picture, /comic svg frame in a sandboxed frame, in case a surface does not animate the picture.

Commands: /comic (on/off, or /comic svg image|frame), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.

cache-keepalive

Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.

  • Countdown: the prompt footer shows how long the cache stays warm, e.g. cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.
  • Keep-alive: shortly before the cache expires (20s by default), and only while Claude is idle, it sends one message asking Claude for a minimal acknowledgement. That request re-reads the cache and restarts its lifetime. After 90 minutes without a prompt from you it lets the cache lapse.
  • Lifetime: auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then.
  • Gauge: /keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).

Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.

Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.

claudle

Programming mini-games in a side pane, to play while the agent works. Claude writes every round, pitches it at your level and grades free-text answers.

  • Six games: Spot the Bug (type the line and what is wrong; Claude grades it 0-100), What Does It Print?, Two Truths and a Lie, Real or Hallucinated? (does this API exist?), Code Review Court (rule a reviewer's comment valid, nitpick or wrong) and Big-O Bingo.
  • Adaptive: an Elo-style rating per topic sets the level (1-10) of the next round. The next round is generated while you play the current one, and the model is told your recent rounds so it does not repeat itself.
  • Ask Claude: a tutor in every round. Before you answer it gives hints only, never the answer; afterwards it explains the concept, how to spot it in real code, and ends with a check-your-understanding question. It sees the round, your answer and the conversation, and says so when a generated round was wrong.
  • Quiz show: an animated pixel header in the terminal (Claude as quizmaster in a spotlight: he paces while a round is written, jumps under confetti when you are right, gets a rain cloud when you are not), a pixel icon and color per game, XP, streaks and titles from Fresh Intern to Principal Pedant. In the desktop app and VS Code the same pixel art is drawn as SVG that animates itself.
  • Plays well with the agent: a toast and a line in the pane when Claude finishes its turn; your round waits.
  • Topics and progress in the pane: toggle topics (JavaScript, Python, SQL, Git, Rust, Regex, Docker...) and see a success rate per game and a level per topic.

In the terminal, click a card in fullscreen mode, or press ctrl+x tab and use the keys the pane lists; in the desktop app and VS Code, click, or press the key marked on a button once the pane has focus. Commands: /claudle, /claudle play <game>, /claudle answer <text>, /claudle topics a, b, c, /claudle stats, /claudle reset. Set the quizmaster model (sonnet, opus or haiku) with /config.

Development

claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test plugins/claudle
claude plugin test examples/pond-place

examples/ holds example plugins that are not in the marketplace; copy one as a starting point.

CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.

License

GPL-3.0. Copyright (c) 2026 Ole Stein.

Source 6 files
hooks/register.tsx 1106 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Chat, GameId, Outcome, Profile, Puzzle, View } from '../types'
5import { GAME_COLOR, header, headerRows, hex, ICON_COLUMNS, ICON_ROWS, iconCells, P } from './art'
6import type { Mood, Scene } from './art'
7import { headerPixels, iconSvg, stableHeaderSvg } from './svg'
8import {
9  emptyProfile,
10  gameOf,
11  GAMES,
12  generationPrompt,
13  gradingPrompt,
14  levelFor,
15  LOADING_LINES,
16  parseJson,
17  pointsFor,
18  progressFor,
19  ASK_AFTER,
20  ASK_BEFORE,
21  TUTOR_SYSTEM,
22  tutorPrompt,
23  TOPIC_PRESETS,
24  ratingDelta,
25  START_RATING,
26  SYSTEM,
27  titleFor,
28  toPuzzle,
29} from './games'
30
31const PANE = 'claudle'
32const TITLE = 'Claudle'
33export const DEFAULT_TOPICS = ['JavaScript', 'TypeScript', 'Python', 'SQL', 'Git', 'Shell']
34
35const view = atom({ plugin: 'claudle', key: 'view' } as const, { kind: 'menu' })
36const profile = atom({ plugin: 'claudle', key: 'profile' } as const, emptyProfile())
37const topics = atom({ plugin: 'claudle', key: 'topics' } as const, DEFAULT_TOPICS)
38const agentDone = atom({ plugin: 'claudle', key: 'agentDone' } as const, false)
39const chat = atom({ plugin: 'claudle', key: 'chat' } as const, { round: '', turns: [], isPending: false, isOpen: false })
40
41const HEADER = 'header'
42const ORANGE = 0xd97757
43const { ink: INK, slate: SLATE, moss: MOSS, blood: BLOOD } = P
44
45/** Where the animated header is drawn, while the pane shows it. */
46let mount: { requestId: string; columns: number } | null = null
47let isBlitting = false
48/** What the header shows; the render hook keeps it in step with the view. */
49let scene: Scene = { mood: 'idle', since: 0, title: '', xp: 0, progress: 0, nextAt: undefined, streak: 0 }
50let sceneKey = ''
51
52function setScene(now: View, p: Profile, isTutoring: boolean) {
53  let mood: Mood =
54    now.kind === 'menu' || now.kind === 'stats' || now.kind === 'topics'
55      ? 'idle'
56      : now.kind === 'loading'
57        ? 'thinking'
58        : now.kind === 'question'
59          ? 'watching'
60          : now.kind === 'grading'
61            ? 'grading'
62            : now.kind === 'error'
63              ? 'confused'
64              : now.outcome.score >= 50
65                ? 'correct'
66                : 'wrong'
67  if (isTutoring) mood = 'grading'
68  const key = `${mood}:${'puzzle' in now ? now.puzzle.title : ''}`
69  const since = key === sceneKey ? scene.since : Date.now()
70  sceneKey = key
71  scene = { mood, since, title: titleFor(p.xp), xp: p.xp, streak: p.streak, ...progressFor(p.xp) }
72}
73
74function drawFrame($: EngineInterface) {
75  if (!mount || isBlitting) return
76  const { requestId, columns } = mount
77  isBlitting = true
78  $.ui
79    .blit({ requestId, key: HEADER, cells: header(columns, scene, Date.now()) })
80    .then(r => {
81      if ('deny' in r && r.deny) mount = null
82    })
83    .catch(() => {
84      mount = null
85    })
86    .finally(() => {
87      isBlitting = false
88    })
89}
90
91/** The quizmaster model, from the mod's settings. */
92let model = 'sonnet'
93/** The next round of each game, generated while the current one is played. */
94const prefetched = new Map<GameId, Promise<Puzzle | undefined>>()
95/** Bumped by every new round or trip to the menu, so a late reply is dropped. */
96let roundId = 0
97
98function pick<T>(list: readonly T[]): T {
99  return list[Math.floor(Math.random() * list.length)]!
100}
101
102async function ask($: EngineInterface, prompt: string, maxTokens: number) {
103  const reply = await $.model.complete({ model, system: SYSTEM, prompt, maxTokens, timeoutMs: 90_000 })
104
105  return reply.isAnswered ? parseJson(reply.text) : undefined
106}
107
108async function generate($: EngineInterface, id: GameId): Promise<Puzzle | undefined> {
109  const game = gameOf(id)
110  const { ratings, recent } = await read($, profile)
111  const topic = pick(await read($, topics))
112  const level = levelFor(ratings[topic] ?? START_RATING)
113  const prompt = generationPrompt(game, topic, level, recent[id] ?? [])
114  for (let attempt = 0; attempt < 2; attempt++) {
115    const raw = await ask($, prompt, 1500)
116    const puzzle = raw && toPuzzle(game, topic, level, raw)
117    if (puzzle) return { ...puzzle, id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` }
118  }
119
120  return undefined
121}
122
123/** The next round of a game: the one prefetched or still being written, else a new one. */
124function nextRound($: EngineInterface, id: GameId): Promise<Puzzle | undefined> {
125  let ready = prefetched.get(id)
126  if (!ready) {
127    ready = generate($, id).catch(() => undefined)
128    prefetched.set(id, ready)
129  }
130
131  return ready
132}
133
134/** Forgets `ready` as the next round of `id`, unless something newer took its place. */
135function consume(id: GameId, ready: Promise<Puzzle | undefined>) {
136  if (prefetched.get(id) === ready) prefetched.delete(id)
137}
138
139async function startRound($: EngineInterface, id: GameId) {
140  const mine = ++roundId
141  await update($, view, (): View => ({ kind: 'loading', game: id, line: pick(LOADING_LINES) }))
142  let ready = nextRound($, id)
143  let puzzle = await ready
144  if (!puzzle && mine === roundId) {
145    // the prefetch failed: one fresh try
146    consume(id, ready)
147    ready = nextRound($, id)
148    puzzle = await ready
149  }
150  // left for the menu meanwhile (or pressed twice): the round stays ready for next time
151  if (mine !== roundId) return
152  consume(id, ready)
153  if (!puzzle) {
154    await update($, view, (): View => ({
155      kind: 'error',
156      game: id,
157      message: `The ${model} quizmaster didn't produce a usable round. Try again?`,
158    }))
159
160    return
161  }
162  await update($, view, (): View => ({ kind: 'question', puzzle }))
163  void nextRound($, id)
164}
165
166/** Rounds already answered, given up or sent to the judge. */
167const claimed = new Set<string>()
168
169/**
170 * Claims the round for one answer: two presses before the redraw (a double
171 * click, a then b) would otherwise both score it.
172 */
173function claim(puzzle: Puzzle): boolean {
174  const id = roundOf(puzzle)
175  if (claimed.has(id)) return false
176  claimed.add(id)
177  if (claimed.size > 100) claimed.delete(claimed.values().next().value!)
178
179  return true
180}
181
182async function toMenu($: EngineInterface) {
183  roundId++
184  await update($, view, (): View => ({ kind: 'menu' }))
185}
186
187async function finish($: EngineInterface, puzzle: Puzzle, score: number, verdict: string, extra: Partial<Outcome>) {
188  const before = await read($, profile)
189  const rating = before.ratings[puzzle.topic] ?? START_RATING
190  const delta = ratingDelta(rating, puzzle.level, score)
191  const isWin = score >= 50
192  const streak = isWin ? before.streak + 1 : 0
193  const points = pointsFor(puzzle.level, score, before.streak)
194  const after: Profile = {
195    xp: before.xp + points,
196    played: before.played + 1,
197    correct: before.correct + (isWin ? 1 : 0),
198    streak,
199    bestStreak: Math.max(before.bestStreak, streak),
200    games: {
201      ...before.games,
202      [puzzle.game]: {
203        played: (before.games[puzzle.game]?.played ?? 0) + 1,
204        correct: (before.games[puzzle.game]?.correct ?? 0) + (isWin ? 1 : 0),
205      },
206    },
207    ratings: { ...before.ratings, [puzzle.topic]: rating + delta },
208    recent: { ...before.recent, [puzzle.game]: [...(before.recent[puzzle.game] ?? []), puzzle.title].slice(-15) },
209  }
210  await update($, profile, () => after)
211  await $.store.set('profile', after)
212  if (titleFor(after.xp) !== titleFor(before.xp)) $.ui.toast(`Claudle: promoted to ${titleFor(after.xp)}!`)
213  await update($, view, (): View => ({
214    kind: 'feedback',
215    puzzle,
216    outcome: { score, verdict, points, ratingDelta: delta, ...extra },
217  }))
218}
219
220async function answerChoice($: EngineInterface, picked: number) {
221  const now = await read($, view)
222  if (now.kind !== 'question' || now.puzzle.answer === undefined || !claim(now.puzzle)) return
223  const isRight = picked === now.puzzle.answer
224  await finish($, now.puzzle, isRight ? 100 : 0, isRight ? 'Correct!' : 'Nope.', { picked })
225}
226
227async function answerText($: EngineInterface, typed: string) {
228  const now = await read($, view)
229  if (now.kind !== 'question' || now.puzzle.game !== 'spot-the-bug' || typed.trim() === '' || !claim(now.puzzle)) return
230  const mine = roundId
231  const { puzzle } = now
232  await update($, view, (): View => ({ kind: 'grading', puzzle, typed }))
233  const raw = await ask($, gradingPrompt(puzzle, typed), 400).catch(() => undefined)
234  if (mine !== roundId) return
235  const graded = Number(raw?.score)
236  const score = Number.isFinite(graded) ? Math.min(100, Math.max(0, Math.round(graded))) : fallbackScore(puzzle, typed)
237  const verdict =
238    typeof raw?.verdict === 'string' ? raw.verdict : 'The judge fell asleep; scored on the line number alone.'
239  await finish($, puzzle, score, verdict, { typed })
240}
241
242/** Reveals the answer; the round counts as a miss. */
243async function giveUp($: EngineInterface) {
244  const now = await read($, view)
245  if (now.kind !== 'question' || !claim(now.puzzle)) return
246  roundId++
247  await finish($, now.puzzle, 0, 'You gave up. Here is what was going on.', {})
248}
249
250async function toggleTopic($: EngineInterface, topic: string) {
251  const list = await read($, topics)
252  const next = list.includes(topic) ? list.filter(t => t !== topic) : [...list, topic]
253  if (next.length === 0) {
254    $.ui.toast('Claudle needs at least one topic.')
255
256    return
257  }
258  await update($, topics, () => next)
259  await $.store.set('topics', next)
260  prefetched.clear()
261}
262
263async function show($: EngineInterface, kind: 'stats' | 'topics') {
264  roundId++
265  await update($, view, (): View => ({ kind }))
266}
267
268/** Which round a chat belongs to. */
269function roundOf(puzzle: Puzzle): string {
270  return puzzle.id ?? `${puzzle.game}:${puzzle.topic}:${puzzle.title}`
271}
272
273/** The chat for the round on screen: the stored one, or a fresh one when it was about another round. */
274function chatFor(stored: Chat, puzzle: Puzzle): Chat {
275  return stored.round === roundOf(puzzle) ? stored : { round: roundOf(puzzle), turns: [], isPending: false, isOpen: false }
276}
277
278async function openTutor($: EngineInterface) {
279  const now = await read($, view)
280  if (!('puzzle' in now)) return
281  const { puzzle } = now
282  await update($, chat, c => ({ ...chatFor(c, puzzle), isOpen: true }))
283  await $.ui.focus({ requestId: PANE, key: askKey(chatFor(await read($, chat), puzzle)) }).catch(() => undefined)
284}
285
286/** The ask field's key changes with every question, so it comes back empty. */
287function askKey(c: Chat): string {
288  return `ask:${c.turns.length}`
289}
290
291async function askTutor($: EngineInterface, question: string) {
292  const now = await read($, view)
293  if (question.trim() === '' || !('puzzle' in now)) return
294  const { puzzle } = now
295  const before = chatFor(await read($, chat), puzzle)
296  if (before.isPending) return
297  const outcome = now.kind === 'feedback' ? now.outcome : undefined
298  await update($, chat, (): Chat => ({
299    ...before,
300    isOpen: true,
301    isPending: true,
302    turns: [...before.turns, { who: 'you', text: question.trim() }],
303  }))
304  const reply = await $.model
305    .complete({
306      model,
307      system: TUTOR_SYSTEM,
308      prompt: tutorPrompt(puzzle, outcome, before.turns, question),
309      maxTokens: 900,
310      timeoutMs: 90_000,
311    })
312    .catch(() => undefined)
313  const text = reply?.isAnswered
314    ? reply.text.trim()
315    : `I couldn't reach the tutor just now${reply && !reply.isAnswered ? ` (${reply.reason})` : ''}. Try asking again?`
316  const after = await read($, chat)
317  if (after.round !== roundOf(puzzle)) return
318  await update($, chat, (c): Chat => ({ ...c, isPending: false, turns: [...c.turns, { who: 'claude', text }] }))
319  await $.ui.focus({ requestId: PANE, key: askKey(await read($, chat)) }).catch(() => undefined)
320}
321
322/** When grading fails: the right line counts as a pass, nothing else does. */
323export function fallbackScore(puzzle: Puzzle, typed: string): number {
324  const line = typed.match(/\d+/)?.[0]
325
326  return line !== undefined && Number(line) === puzzle.bugLine ? 60 : 0
327}
328
329const HELP = [
330  '/claudle                 open the game pane',
331  `/claudle play <game>     jump into a game (${GAMES.map(g => g.id).join(', ')})`,
332  '/claudle answer <text>   answer a Spot the Bug round from the prompt',
333  '/claudle topics a, b, c  set the topics rounds are drawn from',
334  '/claudle stats           XP, accuracy, rating per topic',
335  '/claudle reset           start over',
336].join('\n')
337
338export const register: Register = (on, options) => {
339  model = typeof options.model === 'string' ? options.model : 'sonnet'
340
341  on('session.start', async ($, e, next) => {
342    await $.command.register({
343      name: 'claudle',
344      description: 'Programming mini-games to play while Claude works',
345      argumentHint: '[play <game> | answer <text> | topics <a, b, ...> | stats | reset]',
346    })
347    const storedProfile = (await $.store.get('profile')) as Profile | undefined
348    const storedTopics = (await $.store.get('topics')) as string[] | undefined
349    if (storedProfile) await update($, profile, () => ({ ...emptyProfile(), ...storedProfile }))
350    if (storedTopics?.length) await update($, topics, () => storedTopics)
351    $.clock.every(120, () => drawFrame($))
352
353    return next(e)
354  })
355
356  on('prompt.submit', ($, e, next) => {
357    // a write redraws the pane and hands its buttons new handles, so only write a change;
358    // beside the prompt, never in its way
359    void read($, agentDone)
360      .then(isDone => (isDone ? update($, agentDone, () => false) : undefined))
361      .catch(() => undefined)
362
363    return next(e)
364  })
365
366  on('turn.complete', async ($, e, next) => {
367    const now = await read($, view)
368    if (now.kind !== 'menu' && now.kind !== 'stats' && now.kind !== 'topics' && !(await read($, agentDone))) {
369      await update($, agentDone, () => true)
370      $.ui.toast('Claude finished its turn. Your Claudle round will wait.')
371    }
372
373    return next(e)
374  })
375
376  on('command.run', { command: 'claudle' }, async ($, e) => {
377    const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
378    const p = await read($, profile)
379
380    if (verb === '' || verb === 'play') {
381      const wanted = rest.join(' ').toLowerCase()
382      const game = wanted ? GAMES.find(g => g.id.startsWith(wanted) || g.name.toLowerCase().startsWith(wanted)) : undefined
383      if (wanted && !game) return { text: `No such game. Games: ${GAMES.map(g => g.id).join(', ')}` }
384      await $.ui.open({ id: PANE, title: TITLE, focus: true })
385      if (game) void startRound($, game.id)
386
387      return { text: game ? `Claudle: ${game.name}.` : 'Claudle opened.' }
388    }
389
390    if (verb === 'answer') {
391      const now = await read($, view)
392      if (now.kind !== 'question' || now.puzzle.game !== 'spot-the-bug') return { text: 'No Spot the Bug round is waiting.' }
393      void answerText($, rest.join(' '))
394
395      return { text: 'Answer sent to the judge.' }
396    }
397
398    if (verb === 'topics') {
399      const list = rest.join(' ').split(',').map(t => t.trim()).filter(Boolean)
400      if (list.length === 0) return { text: `Topics: ${(await read($, topics)).join(', ')}` }
401      await update($, topics, () => list)
402      await $.store.set('topics', list)
403      prefetched.clear()
404
405      return { text: `Claudle topics: ${list.join(', ')}` }
406    }
407
408    if (verb === 'stats') {
409      const ratings = Object.entries(p.ratings)
410        .sort(([, a], [, b]) => b - a)
411        .map(([t, r]) => `  ${t.padEnd(14)} ${r}  (level ${levelFor(r)})`)
412      const accuracy = p.played ? Math.round((100 * p.correct) / p.played) : 0
413
414      return {
415        text: [
416          `${titleFor(p.xp)} · ${p.xp} XP · ${p.played} played · ${accuracy}% right · best streak ${p.bestStreak}`,
417          ...(ratings.length ? ratings : ['  no rated topics yet']),
418        ].join('\n'),
419      }
420    }
421
422    if (verb === 'reset') {
423      await update($, profile, () => emptyProfile())
424      await $.store.set('profile', emptyProfile())
425
426      return { text: 'Claudle progress reset.' }
427    }
428
429    return { text: HELP }
430  })
431
432  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
433    const elements = $.ui.resolve(e)
434    const { Box, Text, Button, Code } = elements
435    const Input = 'Input' in elements ? elements.Input : undefined
436    // only the terminal paints pixels; other surfaces draw a Raster as an empty box
437    const Raster = e.surface === 'terminal' && 'Raster' in elements ? elements.Raster : undefined
438    const Svg = !Raster && 'Svg' in elements ? elements.Svg : undefined
439    const isTerminal = e.surface === 'terminal'
440    // read together: a redraw hands every button a new handle, and a press that
441    // lands on the old drawing is dropped, so the faster a redraw, the fewer lost
442    const [now, p, isAgentDone, storedChat] = await Promise.all([read($, view), read($, profile), read($, agentDone), read($, chat)])
443    const width = Math.max(24, e.props.bodyColumns)
444    const isFocused = e.props.isFocused
445    const talk = 'puzzle' in now ? chatFor(storedChat, now.puzzle) : undefined
446    setScene(now, p, talk?.isPending ?? false)
447    const Markdown = 'Markdown' in elements ? elements.Markdown : undefined
448
449    /** The Ask Claude box: the conversation, quick questions, and a field to type in. */
450    const tutor = (puzzle: Puzzle, isAnswered: boolean) => {
451      const c = talk ?? chatFor(storedChat, puzzle)
452      const quick = isAnswered
453        ? [...(now.kind === 'feedback' && now.outcome.score < 50 ? ['Why was my answer wrong?'] : []), ...ASK_AFTER]
454        : ASK_BEFORE
455
456      return (
457        <Box borderStyle="round" borderColor={hex(ORANGE)} paddingX={1} flexDirection="column" gap={1}>
458          <Text>
459            <Text bold color={hex(ORANGE)}>
460              Ask Claude
461            </Text>
462            <Text dimColor>{isAnswered ? '  learn the why behind it' : '  hints only until you answer'}</Text>
463          </Text>
464          {c.turns.map((t, i) =>
465            t.who === 'you' ? (
466              <Text key={`you:${i}`} color={hex(P.sand)} wrap="wrap">
467                {`› ${t.text}`}
468              </Text>
469            ) : Markdown ? (
470              <Markdown key={`claude:${i}`} text={t.text} />
471            ) : (
472              <Text key={`claude:${i}`} wrap="wrap">
473                {t.text}
474              </Text>
475            ),
476          )}
477          {c.isPending ? (
478            <Text dimColor italic>
479              Claude is thinking it through…
480            </Text>
481          ) : (
482            <Box columnGap={2} flexWrap="wrap">
483              {quick.map(q => (
484                <Button key={`quick:${q}`} plain dimColor label={`› ${q}`} onPress={() => void askTutor($, q)} />
485              ))}
486            </Box>
487          )}
488          {Input && !c.isPending ? (
489            <Input
490              key={askKey(c)}
491              label="Ask"
492              placeholder={isAnswered ? 'Anything about this round' : 'Where should I look?'}
493              submitLabel="Ask"
494              onSubmit={(value: string) => void askTutor($, value)}
495            />
496          ) : null}
497        </Box>
498      )
499    }
500
501    // ── pieces every view shares ──
502    let headerEl
503    if (Raster) {
504      const columns = Math.min(512, width)
505      mount = { requestId: e.requestId, columns }
506      headerEl = (
507        <Raster key={HEADER} columns={columns} rows={headerRows(columns)} cells={header(columns, scene, Date.now())} />
508      )
509    } else if (Svg) {
510      // the desktop: the same pixel art as an SVG that animates itself
511      mount = null
512      // an SVG scales to the pane: always the wide layout (big logo), shrunk to fit.
513      // Sized explicitly: unsized, the desktop's frame is 300 by 150 and letterboxes it.
514      const columns = 88
515      const own = headerPixels(columns)
516      // a column of the pane is at least 7 px wide, so this never overflows it
517      const pixels = Math.min(own.width, Math.floor(width * 7))
518      headerEl = (
519        <Svg
520          source={stableHeaderSvg(columns, scene, Date.now())}
521          alt={`Claudle: ${titleFor(p.xp)}, ${p.xp} XP${p.streak > 1 ? `, streak ${p.streak}` : ''}`}
522          width={pixels}
523          height={Math.round((pixels * own.height) / own.width)}
524          isInteractive
525        />
526      )
527    } else {
528      mount = null
529      headerEl = (
530        <Box justifyContent="space-between">
531          <Text bold color={hex(ORANGE)}>
532            CLAUDLE <Text dimColor>{titleFor(p.xp)}</Text>
533          </Text>
534          <Text dimColor>
535            {p.xp} XP{p.streak > 1 ? ` · x${p.streak} streak` : ''}
536          </Text>
537        </Box>
538      )
539    }
540    const iconWidth = Raster || Svg ? ICON_COLUMNS : 1
541    const icon = (id: GameId, key: string) =>
542      Raster ? (
543        <Raster key={key} columns={ICON_COLUMNS} rows={ICON_ROWS} cells={iconCells(id)} />
544      ) : Svg ? (
545        <Svg source={iconSvg(id)} alt={gameOf(id).name} width={48} height={36} />
546      ) : (
547        <Text color={hex(GAME_COLOR[id])}>■</Text>
548      )
549    const agentLine = isAgentDone ? (
550      <Box paddingX={1}>
551        <Text color="success">● Claude is done and waiting for you</Text>
552      </Box>
553    ) : null
554    /**
555     * The keys that work here, or how to get them working while the pane lacks the keyboard.
556     * The terminal's alone: the other surfaces mark each button with its key, and ctrl+x tab
557     * is a terminal chord. Not drawn there, the drawing also stays the same when the pane
558     * gains or loses focus.
559     */
560    const hint = (keys: string) =>
561      isTerminal ? (
562        <Box paddingX={1}>
563          <Text dimColor wrap="wrap">
564            {isFocused ? keys : `Click to play, or ctrl+x tab to give this pane the keyboard (${keys})`}
565          </Text>
566        </Box>
567      ) : null
568    /**
569     * Every view the desktop draws starts its focus on a button. The pane counts as focused
570     * only while the focus is inside it, and a new view replaces the button that held it;
571     * with nothing to start on, the focus drops out, and the next click first focuses the
572     * pane again. That redraws it (its isFocused prop changed) and hands out new handles
573     * before the click's press arrives, and the engine drops the press: the click is lost.
574     * The terminal keeps its focus ring where it was and needs none of this.
575     */
576    const keepFocus = isTerminal ? {} : ({ autoFocus: true } as const)
577    const menuButton = (isFocusStart = false) => (
578      <Button key="menu" plain hotkey="m" dimColor label="menu" {...(isFocusStart ? keepFocus : {})} onPress={() => toMenu($)} />
579    )
580
581    // ── menu: a card per game; every text row of a card starts it ──
582    if (now.kind === 'menu') {
583      const isTwoUp = width >= 70
584      const cardWidth = isTwoUp ? Math.floor(width / 2) : width
585      const inner = Math.max(8, cardWidth - 4 - iconWidth - 1)
586      const accuracy = p.played ? Math.round((100 * p.correct) / p.played) : 0
587
588      return (
589        <Box flexDirection="column" gap={1}>
590          {headerEl}
591          {agentLine}
592          <Box flexDirection="row" flexWrap="wrap">
593            {GAMES.map(game => {
594              const color = hex(GAME_COLOR[game.id])
595              const stats = p.games[game.id]
596              const meta = stats
597                ? `${stats.played} played · ${Math.round((100 * stats.correct) / stats.played)}% right`
598                : 'new · give it a go'
599              const play = () => void startRound($, game.id)
600
601              return (
602                <Box
603                  key={`card:${game.id}`}
604                  width={cardWidth}
605                  borderStyle="round"
606                  borderColor={color}
607                  hover={{ borderStyle: 'bold' }}
608                  paddingX={1}
609                  gap={1}
610                >
611                  {icon(game.id, `icon:${game.id}`)}
612                  <Box flexDirection="column">
613                    <Button
614                      key={`game:${game.id}`}
615                      plain
616                      hotkey={game.hotkey}
617                      label={fit(game.name, inner - 3)}
618                      hover={{ color, bold: true }}
619                      onPress={play}
620                    />
621                    <Button key={`game:${game.id}:about`} plain dimColor label={fit(game.blurb, inner)} onPress={play} />
622                    <Button key={`game:${game.id}:meta`} plain dimColor label={fit(meta, inner)} onPress={play} />
623                  </Box>
624                </Box>
625              )
626            })}
627          </Box>
628          <Box paddingX={1} gap={2} flexWrap="wrap">
629            <Button
630              key="surprise"
631              variant="primary"
632              hotkey="s"
633              label="Surprise me"
634              {...keepFocus}
635              onPress={() => void startRound($, pick(GAMES).id)}
636            />
637            <Button key="topics" plain hotkey="t" label="Topics" onPress={() => show($, 'topics')} />
638            <Button key="stats" plain hotkey="p" label="Progress" onPress={() => show($, 'stats')} />
639          </Box>
640          <Box paddingX={1}>
641            <Text dimColor>
642              {p.played === 0
643                ? 'No rounds yet. Pick a card!'
644                : `${p.played} played · ${accuracy}% right · best streak ${p.bestStreak}`}
645            </Text>
646          </Box>
647          {hint('1-6 play a game · s surprise · t topics · p progress')}
648        </Box>
649      )
650    }
651
652    // ── topics: toggles ──
653    if (now.kind === 'topics') {
654      const chosen = await read($, topics)
655      const all = [...TOPIC_PRESETS, ...chosen.filter(t => !TOPIC_PRESETS.includes(t))]
656
657      return (
658        <Box flexDirection="column" gap={1}>
659          {headerEl}
660          <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column" gap={1}>
661            <Text bold>Topics rounds are drawn from</Text>
662            <Box flexWrap="wrap" columnGap={2}>
663              {all.map(topic => {
664                const isOn = chosen.includes(topic)
665
666                return (
667                  <Button
668                    key={`topic:${topic}`}
669                    plain
670                    label={`${isOn ? '◉' : '○'} ${topic}`}
671                    dimColor={!isOn}
672                    onPress={() => toggleTopic($, topic)}
673                  />
674                )
675              })}
676            </Box>
677            <Text dimColor wrap="wrap">
678              Anything else: /claudle topics Elixir, Kubernetes, ...
679            </Text>
680          </Box>
681          <Box paddingX={1}>
682            <Button key="done" variant="primary" hotkey="m" label="Done" autoFocus onPress={() => toMenu($)} />
683          </Box>
684          {hint('Tab moves between topics · Enter toggles · m done')}
685        </Box>
686      )
687    }
688
689    // ── progress: per game and per topic ──
690    if (now.kind === 'stats') {
691      const bar = (fraction: number, cells: number) => {
692        const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells)
693
694        return '█'.repeat(filled) + '░'.repeat(cells - filled)
695      }
696      const ratings = Object.entries(p.ratings).sort(([, a], [, b]) => b - a)
697      const progress = progressFor(p.xp)
698
699      return (
700        <Box flexDirection="column" gap={1}>
701          {headerEl}
702          <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
703            <Text bold>{titleFor(p.xp)}</Text>
704            <Text>
705              <Text color={hex(P.amber)}>{bar(progress.progress, 20)}</Text>
706              <Text dimColor>
707                {progress.nextAt === undefined ? ` ${p.xp} XP, top title` : ` ${p.xp} / ${progress.nextAt} XP to the next title`}
708              </Text>
709            </Text>
710            <Text dimColor>
711              {p.played} played · {p.correct} right · streak {p.streak} · best {p.bestStreak}
712            </Text>
713          </Box>
714          <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
715            <Text bold>Games</Text>
716            {GAMES.map(game => {
717              const stats = p.games[game.id]
718              const rate = stats ? stats.correct / stats.played : 0
719
720              // a column of its own, not padding: spaces only line up in a monospace font
721              return (
722                <Box key={`g:${game.id}`}>
723                  <Box width={22} flexShrink={0}>
724                    <Text color={hex(GAME_COLOR[game.id])} wrap="truncate-end">
725                      {game.name}
726                    </Text>
727                  </Box>
728                  {stats ? (
729                    <Text>
730                      {bar(rate, 10)} <Text dimColor>{`${Math.round(rate * 100)}% of ${stats.played}`}</Text>
731                    </Text>
732                  ) : (
733                    <Text dimColor>not played yet</Text>
734                  )}
735                </Box>
736              )
737            })}
738          </Box>
739          <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
740            <Text bold>Topics</Text>
741            {ratings.length === 0 && <Text dimColor>Play a round to get rated.</Text>}
742            {ratings.map(([topic, rating]) => (
743              <Box key={`t:${topic}`}>
744                <Box width={22} flexShrink={0}>
745                  <Text wrap="truncate-end">{topic}</Text>
746                </Box>
747                <Text>
748                  <Text color={hex(P.leaf)}>{'●'.repeat(levelFor(rating))}</Text>
749                  <Text dimColor>{'○'.repeat(10 - levelFor(rating))}</Text>
750                  <Text dimColor>{`  level ${levelFor(rating)} · rating ${rating}`}</Text>
751                </Text>
752              </Box>
753            ))}
754          </Box>
755          <Box paddingX={1}>
756            <Button key="done" variant="primary" hotkey="m" label="Back" autoFocus onPress={() => toMenu($)} />
757          </Box>
758        </Box>
759      )
760    }
761
762    if (now.kind === 'loading') {
763      const color = hex(GAME_COLOR[now.game])
764
765      return (
766        <Box flexDirection="column" gap={1}>
767          {headerEl}
768          {agentLine}
769          <Box borderStyle="round" borderColor={color} paddingX={1} gap={1}>
770            {icon(now.game, 'icon:round')}
771            <Box flexDirection="column">
772              <Text bold color={color}>
773                {gameOf(now.game).name}
774              </Text>
775              <Text dimColor italic wrap="wrap">
776                {now.line}…
777              </Text>
778              <Text dimColor>Usually 5 to 20 seconds; the next round is prepared while you play.</Text>
779            </Box>
780          </Box>
781          <Box paddingX={1}>{menuButton(true)}</Box>
782        </Box>
783      )
784    }
785
786    if (now.kind === 'error') {
787      const { game } = now
788
789      return (
790        <Box flexDirection="column" gap={1}>
791          {headerEl}
792          <Box borderStyle="round" borderColor="error" paddingX={1}>
793            <Text color="error" wrap="wrap">
794              {now.message}
795            </Text>
796          </Box>
797          <Box paddingX={1} gap={3}>
798            <Button key="retry" variant="primary" hotkey="r" label="Retry" autoFocus onPress={() => void startRound($, game)} />
799            {menuButton()}
800          </Box>
801        </Box>
802      )
803    }
804
805    // ── a round: its card, the code, the answers ──
806    const { puzzle } = now
807    const game = gameOf(puzzle.game)
808    const color = hex(GAME_COLOR[puzzle.game])
809    const isBug = puzzle.game === 'spot-the-bug'
810    const pips = '●'.repeat(puzzle.level) + '○'.repeat(10 - puzzle.level)
811    const card = (
812      <Box borderStyle="round" borderColor={color} paddingX={1} gap={1}>
813        {icon(puzzle.game, 'icon:round')}
814        <Box flexDirection="column" flexShrink={1}>
815          <Text>
816            <Text backgroundColor={color} color={hex(INK)} bold>
817              {` ${game.name.toUpperCase()} `}
818            </Text>
819            <Text dimColor>{`  ${puzzle.topic}`}</Text>
820          </Text>
821          <Text bold wrap="wrap">
822            {puzzle.title}
823          </Text>
824          <Text color={color}>
825            {pips} <Text dimColor>level {puzzle.level}</Text>
826          </Text>
827        </Box>
828      </Box>
829    )
830    const code = puzzle.code ? (
831      <Box borderStyle="single" borderColor={hex(SLATE)} paddingX={1}>
832        <Code
833          source={puzzle.code}
834          {...(puzzle.language ? { language: puzzle.language } : {})}
835          {...(isBug ? { startLine: 1 } : {})}
836          wrap="wrap"
837        />
838      </Box>
839    ) : null
840    const letters = ['a', 'b', 'c', 'd']
841
842    if (now.kind === 'question' || now.kind === 'grading') {
843      let answers
844      let keys = 'q ask for a hint · k skip · g give up · m menu'
845      if (now.kind === 'grading') {
846        answers = (
847          <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
848            <Text dimColor>You said: {now.typed}</Text>
849            <Text dimColor italic>
850              The judge is reading your answer…
851            </Text>
852            <Box marginTop={1}>{menuButton(true)}</Box>
853          </Box>
854        )
855      } else if (isBug) {
856        keys = 'type your answer, Enter submits · Esc leaves the field, then q hint · k skip · g give up'
857        answers = Input ? (
858          <Box borderStyle="round" borderColor={color} paddingX={1} flexDirection="column">
859            <Input
860              key="answer"
861              label="Bug"
862              placeholder="line 7: the loop runs one past the end"
863              submitLabel="Submit"
864              autoFocus
865              onSubmit={(value: string) => void answerText($, value)}
866            />
867          </Box>
868        ) : (
869          <Text dimColor>Answer with /claudle answer &lt;line and what is wrong&gt;</Text>
870        )
871      } else if (game.fixedOptions && !isTerminal) {
872        // native buttons: the whole button answers, not just the word inside a box
873        answers = (
874          <Box gap={1} flexWrap="wrap">
875            {game.fixedOptions.map((option, i) => (
876              <Button key={`opt:${i}`} hotkey={letters[i]!} label={option} onPress={() => answerChoice($, i)} />
877            ))}
878          </Box>
879        )
880      } else if (game.fixedOptions) {
881        const cell = Math.max(...game.fixedOptions.map(o => o.length)) + 2
882        keys = `${game.fixedOptions.map((o, i) => `${letters[i]} ${o}`).join(' · ')} · q hint · k skip · g give up`
883        answers = (
884          <Box gap={1} flexWrap="wrap">
885            {game.fixedOptions.map((option, i) => (
886              <Box key={`box:${i}`} borderStyle="round" borderColor={color} hover={{ borderStyle: 'bold' }} paddingX={1}>
887                <Button
888                  key={`opt:${i}`}
889                  plain
890                  hotkey={letters[i]!}
891                  label={fit(option, cell)}
892                  hover={{ bold: true }}
893                  onPress={() => answerChoice($, i)}
894                />
895              </Box>
896            ))}
897          </Box>
898        )
899      } else {
900        const inner = width - 4
901        // the desktop draws a button's label on one line and cuts the rest, so a long
902        // answer is one button per line, each answering; the first carries the key.
903        // A column is a digit wide and prose runs narrower, so lines this long fit;
904        // less the frame, its padding and the key's mark (about 3 columns)
905        const lineWidth = Math.max(12, width - 7)
906        keys = 'a-d answer · q hint · k skip · g give up · m menu'
907        answers = (
908          <Box flexDirection="column" gap={isTerminal ? 0 : 1}>
909            {(puzzle.options ?? []).map((option, i) => {
910              const isShort = option.length <= inner - 3
911              const press = () => answerChoice($, i)
912              const [first = '', ...rest] = wrapLines(option, lineWidth)
913
914              return (
915                <Box
916                  key={`box:${i}`}
917                  borderStyle="round"
918                  borderColor={hex(SLATE)}
919                  hover={{ borderColor: color }}
920                  paddingX={1}
921                  flexDirection="column"
922                >
923                  {!isTerminal ? (
924                    [
925                      <Button key={`opt:${i}`} plain hotkey={letters[i]!} label={first} hover={{ color }} onPress={press} />,
926                      ...(rest.length > 0
927                        ? [
928                            // unkeyed, so the option's box stays the hover scope of every line
929                            <Box flexDirection="column" paddingLeft={3}>
930                              {rest.map((line, j) => (
931                                <Button key={`opt:${i}:${j + 1}`} plain label={line} hover={{ color }} onPress={press} />
932                              ))}
933                            </Box>,
934                          ]
935                        : []),
936                    ]
937                  ) : isShort ? (
938                    <Button
939                      key={`opt:${i}`}
940                      plain
941                      hotkey={letters[i]!}
942                      label={fit(option, inner - 3)}
943                      hover={{ bold: true }}
944                      onPress={() => answerChoice($, i)}
945                    />
946                  ) : (
947                    <Button key={`opt:${i}`} plain hotkey={letters[i]!} label={option} onPress={() => answerChoice($, i)} />
948                  )}
949                </Box>
950              )
951            })}
952          </Box>
953        )
954      }
955
956      return (
957        <Box flexDirection="column" gap={1}>
958          {headerEl}
959          {agentLine}
960          {card}
961          <Box paddingX={1}>
962            <Text wrap="wrap" bold={puzzle.game === 'review-court'}>
963              {puzzle.prompt}
964            </Text>
965          </Box>
966          {code}
967          {answers}
968          {/* above the chat, which grows: reachable without scrolling to the bottom */}
969          {now.kind === 'question' && (
970            <Box paddingX={1} columnGap={3} flexWrap="wrap">
971              <Button key="hint" plain hotkey="q" label="ask for a hint" {...keepFocus} onPress={() => openTutor($)} />
972              <Button key="skip" plain hotkey="k" dimColor label="skip" onPress={() => void startRound($, puzzle.game)} />
973              <Button key="giveup" plain hotkey="g" dimColor label="give up" onPress={() => giveUp($)} />
974              {menuButton()}
975            </Box>
976          )}
977          {talk && (talk.isOpen || talk.turns.length > 0) ? tutor(puzzle, false) : null}
978          {now.kind === 'question' && hint(keys)}
979        </Box>
980      )
981    }
982
983    // ── feedback ──
984    const { outcome } = now
985    const isWin = outcome.score >= 50
986    let reveal
987    if (isBug) {
988      reveal = (
989        <Box flexDirection="column" paddingX={1}>
990          {outcome.typed !== undefined && <Text dimColor wrap="wrap">You said: {outcome.typed}</Text>}
991          <Text wrap="wrap">{outcome.verdict}</Text>
992          <Text wrap="wrap">
993            <Text bold color={color}>
994              Line {puzzle.bugLine}:
995            </Text>{' '}
996            {puzzle.bug}
997          </Text>
998        </Box>
999      )
1000    } else {
1001      reveal = (
1002        <Box flexDirection="column" paddingX={1}>
1003          {outcome.picked === undefined && <Text wrap="wrap">{outcome.verdict}</Text>}
1004          {(puzzle.options ?? []).map((option, i) =>
1005            i === puzzle.answer ? (
1006              <Text key={`r:${i}`} color="success" bold wrap="wrap">
1007                ✓ {option}
1008              </Text>
1009            ) : i === outcome.picked ? (
1010              <Text key={`r:${i}`} color="error" strikethrough wrap="wrap">
1011                ✗ {option}
1012              </Text>
1013            ) : (
1014              <Text key={`r:${i}`} dimColor wrap="wrap">
1015                {`  ${option}`}
1016              </Text>
1017            ),
1018          )}
1019        </Box>
1020      )
1021    }
1022    const ratingText = `${outcome.ratingDelta >= 0 ? '+' : ''}${outcome.ratingDelta}`
1023
1024    return (
1025      <Box flexDirection="column" gap={1}>
1026        {headerEl}
1027        {agentLine}
1028        <Box backgroundColor={hex(isWin ? MOSS : BLOOD)} paddingX={1} justifyContent="space-between">
1029          <Text bold color="#ffffff">
1030            {isWin ? '✓ CORRECT' : '✗ NOT QUITE'}
1031            {isBug && outcome.typed !== undefined ? `  ${outcome.score}/100` : ''}
1032          </Text>
1033          <Text bold color="#ffffff">
1034            +{outcome.points} XP
1035          </Text>
1036        </Box>
1037        {card}
1038        {code}
1039        {reveal}
1040        <Box borderStyle="round" borderColor={hex(SLATE)} paddingX={1} flexDirection="column">
1041          <Text bold color={color}>
1042            Why
1043          </Text>
1044          <Text wrap="wrap">{puzzle.explanation}</Text>
1045        </Box>
1046        {puzzle.quip !== '' && (
1047          <Box paddingX={1}>
1048            <Text italic color={hex(ORANGE)} wrap="wrap">
1049              “{puzzle.quip}”
1050            </Text>
1051          </Box>
1052        )}
1053        <Box paddingX={1}>
1054          <Text dimColor>
1055            {puzzle.topic} rating {ratingText} · streak {p.streak}
1056          </Text>
1057        </Box>
1058        <Box paddingX={1} columnGap={3} flexWrap="wrap">
1059          <Button
1060            key="next"
1061            variant="primary"
1062            hotkey="n"
1063            label="Next round"
1064            autoFocus
1065            onPress={() => void startRound($, puzzle.game)}
1066          />
1067          <Button key="ask" plain hotkey="q" label="ask Claude" onPress={() => openTutor($)} />
1068          <Button key="other" plain hotkey="o" label="other game" onPress={() => toMenu($)} />
1069        </Box>
1070        {tutor(puzzle, true)}
1071        {hint('Enter or n next round · q ask Claude about it · o pick another game')}
1072      </Box>
1073    )
1074  })
1075}
1076
1077/** `text` broken into lines of at most `width` characters, between words where it can. */
1078export function wrapLines(text: string, width: number): string[] {
1079  const lines: string[] = []
1080  let line: string[] = []
1081  for (const word of text.split(/\s+/).filter(Boolean)) {
1082    const chars = [...word]
1083    if (line.length > 0 && line.length + 1 + chars.length <= width) {
1084      line.push(' ', ...chars)
1085      continue
1086    }
1087    if (line.length > 0) lines.push(line.join(''))
1088    line = chars
1089    // a word longer than a line is cut where the line ends
1090    while (line.length > width) {
1091      lines.push(line.slice(0, width).join(''))
1092      line = line.slice(width)
1093    }
1094  }
1095  if (line.length > 0 || lines.length === 0) lines.push(line.join(''))
1096
1097  return lines
1098}
1099
1100/** `text` cut or padded to exactly `n` columns, so a Button fills its row. */
1101function fit(text: string, n: number): string {
1102  const chars = [...text]
1103
1104  return chars.length > n ? chars.slice(0, Math.max(0, n - 1)).join('') + '…' : text + ' '.repeat(n - chars.length)
1105}
1106
hooks/art.ts 321 lines
1// Pixel art for Claudle: the quiz-show header (logo, quizmaster Claude, XP bar)
2// and the game icons. Same palette and figure as the agent-comic mod.
3
4import type { GameId } from '../types'
5import { Canvas, DEFAULT } from './canvas'
6
7export const P = {
8  ink: 0x181425, night: 0x262b44, slate: 0x3a4466, steel: 0x5a6988, mist: 0x8b9bb4, silver: 0xc0cbdc, white: 0xffffff,
9  wine: 0x3e2731, bark: 0x733e39, wood: 0xb86f50, tan: 0xe4a672, sand: 0xead4aa,
10  blood: 0xa22633, red: 0xe43b44, coral: 0xf6757a, amber: 0xfeae34, yellow: 0xfee761,
11  leaf: 0x63c74d, moss: 0x3e8948, pine: 0x265c42, navy: 0x124e89, sky: 0x0099db, plum: 0x68386c, pink: 0xb55088,
12}
13
14export const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
15
16/** Each game's color, used for its icon, card border and badge. */
17export const GAME_COLOR: Record<GameId, number> = {
18  'spot-the-bug': P.red,
19  'what-prints': P.leaf,
20  'two-truths': P.amber,
21  'real-or-fake': P.sky,
22  'review-court': P.pink,
23  'big-o': P.coral,
24}
25
26// ── Icons: 8 px wide, 6 px tall (8 columns by 3 rows) ─────────────────
27
28type Art = { rows: readonly string[]; palette: Record<string, number> }
29
30export const ICONS: Record<GameId, Art> = {
31  'spot-the-bug': {
32    rows: ['.K....K.', '..K.hK..', '.KhrrdK.', 'K.rKrd.K', '..rKdd..', '.K.dd.K.'],
33    palette: { K: P.ink, h: P.coral, r: P.red, d: P.blood },
34  },
35  'what-prints': {
36    rows: ['ssssssss', 'sNNNNNNs', 'sNgNNNNs', 'sNNgNNNs', 'sNgNyyNs', 'ssssssss'],
37    palette: { s: P.steel, N: P.ink, g: P.leaf, y: P.silver },
38  },
39  'two-truths': {
40    rows: ['hh.hh.cc', 'GG.GG.RR', 'GW.GW.WR', 'WG.WG.RW', 'GG.GG.RR', 'gg.gg.dd'],
41    palette: { h: P.leaf, G: P.moss, g: P.pine, W: P.white, c: P.coral, R: P.red, d: P.blood },
42  },
43  'real-or-fake': {
44    rows: ['..wwww..', '.wwwwwm.', 'wwkwwkwm', 'wwwwwwwm', 'wwwwwwmm', 'w.wm.w.m'],
45    palette: { w: P.white, m: P.silver, k: P.ink },
46  },
47  'review-court': {
48    rows: ['.hhh....', 'BBBBh...', 'BBBB.h..', '.bbb..h.', '.......h', 'sSSSSSSs'],
49    palette: { h: P.tan, B: P.wood, b: P.bark, S: P.pink, s: P.plum },
50  },
51  'big-o': {
52    rows: ['s......r', 's......r', 's.....r.', 's....r..', 's.rrr...', 'ssssssss'],
53    palette: { s: P.steel, r: P.coral },
54  },
55}
56
57export const ICON_COLUMNS = 8
58export const ICON_ROWS = 3
59
60export function iconCells(id: GameId): string {
61  return iconCanvas(id).encode()
62}
63
64export function iconCanvas(id: GameId): Canvas {
65  const c = new Canvas(ICON_COLUMNS, ICON_ROWS)
66  c.sprite(0, 0, ICONS[id].rows, ICONS[id].palette)
67
68  return c
69}
70
71// ── Logo font: 3 by 5, scaled ──────────────────────────────────────────
72
73const FONT: Record<string, string[]> = {
74  C: ['.##', '#..', '#..', '#..', '.##'],
75  L: ['#..', '#..', '#..', '#..', '###'],
76  A: ['.#.', '#.#', '###', '#.#', '#.#'],
77  U: ['#.#', '#.#', '#.#', '#.#', '.#.'],
78  D: ['##.', '#.#', '#.#', '#.#', '##.'],
79  E: ['###', '#..', '##.', '#..', '###'],
80}
81const LOGO = 'CLAUDLE'
82const LOGO_RAMP = [0xf4c09f, 0xeea27d, 0xd97757, 0xd97757, 0xb4533f]
83
84export function logoWidth(scale: number): number {
85  return LOGO.length * 4 * scale - scale
86}
87
88function drawLogo(c: Canvas, x: number, y: number, scale: number, t: number) {
89  // a highlight sweeps across the letters every few seconds
90  const sweep = ((t % 4800) / 4800) * (logoWidth(scale) + 80) - 20
91  ;[...LOGO].forEach((letter, i) => {
92    const glyph = FONT[letter]!
93    for (let r = 0; r < 5; r++) {
94      for (let col = 0; col < 3; col++) {
95        if (glyph[r]![col] !== '#') continue
96        for (let dy = 0; dy < scale; dy++) {
97          for (let dx = 0; dx < scale; dx++) {
98            const px = x + (i * 4 + col) * scale + dx
99            const py = y + r * scale + dy
100            c.set(px + 1, py + 1, P.wine)
101            const shine = Math.abs(px - sweep - (py - y)) < 2
102            c.set(px, py, shine ? 0xffe2cf : LOGO_RAMP[Math.min(4, Math.floor(((r * scale + dy) / (5 * scale)) * 5))]!)
103          }
104        }
105      }
106    }
107  })
108}
109
110// ── Quizmaster Claude ──────────────────────────────────────────────────
111
112type Face = 'ahead' | 'blink' | 'happy' | 'sad' | 'focused' | 'confused' | 'squint'
113type ArmPose = 'down' | 'mid' | 'up'
114const TINT = { h: 0xeea27d, O: 0xd97757, s: 0xb4533f, E: P.ink, w: P.white, b: P.ink, r: P.red }
115
116const FACES: Record<Face, [number, number, 'E' | 's'][]> = {
117  ahead: [[1, 3, 'E'], [2, 3, 'E'], [1, 7, 'E'], [2, 7, 'E']],
118  blink: [[2, 3, 's'], [2, 7, 's']],
119  happy: [[1, 3, 'E'], [2, 2, 'E'], [2, 4, 'E'], [1, 7, 'E'], [2, 6, 'E'], [2, 8, 'E']],
120  sad: [[2, 3, 'E'], [3, 3, 'E'], [2, 7, 'E'], [3, 7, 'E'], [1, 4, 's'], [1, 6, 's']],
121  focused: [[2, 3, 'E'], [2, 4, 'E'], [2, 6, 'E'], [2, 7, 'E']],
122  confused: [[1, 3, 'E'], [2, 3, 'E'], [2, 7, 'E'], [2, 8, 'E'], [0, 8, 's']],
123  squint: [[2, 3, 'E'], [2, 4, 'E'], [2, 7, 'E'], [2, 8, 'E']],
124}
125
126/** Claude, 11 by 7: the comic's figure. */
127function claude(face: Face, back: ArmPose, front: ArmPose, step: number): string[] {
128  const grid = ['..OOOOOOO..', '..OOOOOOO..', '..OOOOOOO..', '..OOOOOOO..', '..sssssss..'].map(r => r.split(''))
129  const arm = (state: ArmPose, [inner, outer]: [number, number]) => {
130    const row = state === 'down' ? 2 : 1
131    grid[row]![inner] = 'O'
132    grid[state === 'up' ? 0 : row]![outer] = 'O'
133  }
134  arm(back, [1, 0])
135  arm(front, [9, 10])
136  for (const [r, col, ch] of FACES[face]) grid[r]![col] = ch
137  for (let col = 0; col <= 7; col++) if (grid[0]![col] === 'O') grid[0]![col] = 'h'
138  for (let r = 1; r <= 2; r++) if (grid[r]![2] === 'O') grid[r]![2] = 'h'
139  for (let r = 1; r <= 3; r++) if (grid[r]![8] === 'O') grid[r]![8] = 's'
140  const legs = step % 2 ? ['..s.s.s.s..', '....s...s..'] : ['..s.s.s.s..', '..s.s.s.s..']
141
142  return [...grid.map(r => r.join('')), ...legs]
143}
144
145function scaled(c: Canvas, x: number, y: number, rows: readonly string[], palette: Record<string, number>, scale: number) {
146  rows.forEach((row, j) =>
147    [...row].forEach((ch, i) => {
148      const color = palette[ch]
149      if (color !== undefined) c.rect(x + i * scale, y + j * scale, scale, scale, color)
150    }),
151  )
152}
153
154// ── The header scene ───────────────────────────────────────────────────
155
156export type Mood = 'idle' | 'thinking' | 'watching' | 'grading' | 'correct' | 'wrong' | 'confused'
157
158export type Scene = {
159  mood: Mood
160  /** When the mood began (ms), so a reaction plays once and settles. */
161  since: number
162  title: string
163  xp: number
164  /** 0..1 toward the next title, and the XP that title needs (undefined at the top). */
165  progress: number
166  nextAt: number | undefined
167  streak: number
168}
169
170const SKY = [0x1b1830, 0x1e1b36, 0x221f3d, 0x262b44]
171
172function hash(n: number): number {
173  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
174
175  return x - Math.floor(x)
176}
177
178export function headerRows(columns: number): number {
179  return columns >= 78 ? 10 : 6
180}
181
182/**
183 * Every looping motion of the header repeats within this many ms, so a
184 * recorded loop of it (the desktop's SVG) has no seam.
185 */
186export const PERIOD = 9600
187/** How long a reaction (the jump and confetti of a right answer) plays before it settles. */
188export const FRESH_MS = 2600
189
190/** One frame of the header at time `t` (ms), as Raster cells. */
191export function header(columns: number, scene: Scene, t: number): string {
192  return headerCanvas(columns, scene, t).encode()
193}
194
195/** One frame of the header at time `t` (ms). */
196export function headerCanvas(columns: number, scene: Scene, t: number): Canvas {
197  const rows = headerRows(columns)
198  const isBig = rows === 10
199  const c = new Canvas(columns, rows)
200  const h = rows * 2
201  const age = t - scene.since
202
203  // night sky, banded, with twinkling stars
204  for (let y = 0; y < h; y++) c.rect(0, y, columns, 1, SKY[Math.min(SKY.length - 1, Math.floor((y / h) * SKY.length))]!)
205  for (let i = 0; i < Math.floor(columns / 5); i++) {
206    const sx = Math.floor(hash(i) * columns)
207    const sy = Math.floor(hash(i + 99) * (h - 4))
208    const tw = Math.sin((2 * Math.PI * t) / 2400 + i * 1.7)
209    if (tw > 0.3) c.set(sx, sy, tw > 0.85 ? P.white : P.steel)
210  }
211
212  // the figure on the right, standing on a little stage
213  const scale = isBig ? 2 : 1
214  const figW = 11 * scale
215  const figH = 7 * scale
216  const floor = h - (isBig ? 2 : 1)
217  const figX = columns - figW - 3
218  // a spotlight from above, widening onto the stage
219  for (let y = 0; y < floor; y++) {
220    const half = Math.round(figW / 2 + 2 + (y / floor) * figW * 0.4)
221    const mid = figX + Math.floor(figW / 2)
222    c.rect(mid - half, y, half * 2, 1, y % 2 ? 0x2c2850 : 0x2a274c)
223  }
224  c.rect(figX - 3, floor, figW + 6, h - floor, P.slate)
225  c.rect(figX - 3, floor, figW + 6, 1, P.steel)
226
227  let face: Face = 'ahead'
228  let back: ArmPose = 'down'
229  let front: ArmPose = 'down'
230  let lift = 0
231  let step = 0
232  const isBlink = t % 3200 < 140
233  const fresh = age < FRESH_MS
234  switch (scene.mood) {
235    case 'idle':
236      face = isBlink ? 'blink' : 'ahead'
237      front = t % PERIOD < 1200 ? 'up' : 'down' // a wave now and then
238      break
239    case 'watching':
240      face = isBlink ? 'blink' : 'focused'
241      back = 'mid'
242      break
243    case 'thinking':
244      face = 'squint'
245      front = 'mid'
246      step = Math.floor(t / 250)
247      break
248    case 'grading':
249      face = 'focused'
250      back = 'mid'
251      front = 'mid'
252      break
253    case 'correct':
254      face = 'happy'
255      back = front = fresh ? 'up' : 'mid'
256      lift = fresh ? Math.round(Math.abs(Math.sin(age / 160)) * 3 * scale) : 0
257      break
258    case 'wrong':
259      face = 'sad'
260      break
261    case 'confused':
262      face = 'confused'
263      front = 'mid'
264      break
265  }
266  const figY = floor - figH - lift
267  scaled(c, figX, figY, claude(face, back, front, step), TINT, scale)
268
269  // what he gives off: thought dots, a question mark, confetti, a rain cloud
270  const bx = figX - 5 * scale
271  const by = Math.max(0, figY - 2)
272  if (scene.mood === 'thinking') {
273    const n = Math.floor(t / 300) % 4
274    for (let i = 0; i < n; i++) c.rect(bx + i * 2 * scale, by + 2 - i, scale, scale, P.silver)
275  } else if (scene.mood === 'grading' || scene.mood === 'confused') {
276    const q = ['.ww.', 'w..w', '..w.', '....', '..w.']
277    if (Math.floor(t / 500) % 2 === 0) scaled(c, bx, by, q, { w: P.yellow }, 1)
278  } else if (scene.mood === 'correct' && fresh) {
279    const colors = [P.red, P.amber, P.yellow, P.leaf, P.sky, P.pink, P.white]
280    for (let i = 0; i < Math.floor(columns / 2); i++) {
281      const x = Math.floor(hash(i * 3) * columns + Math.sin(age / 200 + i) * 2)
282      const y = Math.floor(hash(i * 7) * -h + (age / 1000) * (h + 6) * (0.7 + hash(i) * 0.6))
283      if (y >= 0 && y < h) c.set(x, y, colors[i % colors.length]!)
284    }
285  } else if (scene.mood === 'wrong') {
286    const cloudX = figX + Math.floor(figW / 2) - 4
287    const cloudY = Math.max(0, figY - 5)
288    scaled(c, cloudX, cloudY, ['..mmmm..', '.mmmmmm.', 'mmmmmmmm', '.ssssss.'], { m: P.steel, s: P.slate }, 1)
289    for (let i = 0; i < 4; i++) {
290      const dropY = cloudY + 4 + ((Math.floor(t / 100) + i * 3) % 6)
291      if (dropY < figY + 1) c.set(cloudX + 1 + i * 2, dropY, P.sky)
292    }
293  }
294
295  // the logo, and under it the title, XP bar and streak
296  const textBg = SKY[SKY.length - 1]!
297  const room = figX - 8 * scale
298  const logoRoom = figX - 10
299  const logoScale = isBig && logoWidth(2) + 2 <= logoRoom ? 2 : 1
300  if (logoWidth(logoScale) + 2 <= logoRoom) drawLogo(c, 2, 2, logoScale, t)
301  const textRow = rows - 1
302  const barCells = Math.max(6, Math.min(18, room - 30))
303  const filled = Math.round(scene.progress * barCells)
304  const bar = '█'.repeat(filled) + '░'.repeat(barCells - filled)
305  const xpText = scene.nextAt === undefined ? `${scene.xp} XP` : `${scene.xp}/${scene.nextAt} XP`
306  let col = 1
307  const put = (s: string, fg: number) => {
308    if (col + [...s].length > room) return
309    c.text(col, textRow, s, fg, textBg)
310    col += [...s].length
311  }
312  put(scene.title + '  ', P.sand)
313  put(bar, P.amber)
314  put(' ' + xpText, P.mist)
315  if (scene.streak > 1) put(`  x${scene.streak} streak`, P.coral)
316
317  return c
318}
319
320export { DEFAULT }
321
hooks/svg.ts 165 lines
1// The pixel art as SVG, for surfaces that draw no Raster (the desktop app).
2// The same Canvas the terminal draws: one SVG unit per pixel (a terminal cell is
3// one unit wide and two tall), the text layer on top. The header's animation is
4// recorded as frames that differ from the first and flipped through with SMIL.
5
6import type { GameId } from '../types'
7import { FRESH_MS, headerCanvas, headerRows, iconCanvas, PERIOD } from './art'
8import type { Scene } from './art'
9import { Canvas, DEFAULT } from './canvas'
10
11/** CSS pixels per terminal column at the markup's own size. */
12const UNIT = 8
13/** The Svg element takes at most this much markup; stay clear of it. */
14const BUDGET = 120_000
15
16const color = (c: number) => `#${c.toString(16).padStart(6, '0')}`
17
18const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
19
20/** Rects for every pixel `pick` keeps, merged into horizontal runs of one color. */
21function runs(c: Canvas, pick: (x: number, y: number, color: number) => boolean): string {
22  let out = ''
23  for (let y = 0; y < c.height; y++) {
24    let x = 0
25    while (x < c.columns) {
26      const v = c.px[y * c.columns + x]!
27      if (v === DEFAULT || !pick(x, y, v)) {
28        x++
29        continue
30      }
31      let end = x + 1
32      while (end < c.columns && c.px[y * c.columns + end] === v && pick(end, y, v)) end++
33      out += `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${color(v)}"/>`
34      x = end
35    }
36  }
37
38  return out
39}
40
41/** The text layer: each run of cells with one style as one <text>, its box behind it. */
42function texts(c: Canvas): string {
43  let out = ''
44  for (let r = 0; r < c.rows; r++) {
45    let col = 0
46    while (col < c.columns) {
47      const at = r * c.columns + col
48      if (!c.glyph[at]) {
49        col++
50        continue
51      }
52      const fg = c.fg[at]!
53      const bg = c.bg[at]!
54      let end = col + 1
55      while (end < c.columns && c.glyph[r * c.columns + end] && c.fg[r * c.columns + end] === fg && c.bg[r * c.columns + end] === bg) end++
56      const chars = Array.from(c.glyph.slice(at, r * c.columns + end), cp => String.fromCodePoint(cp)).join('')
57      if (bg !== DEFAULT) out += `<rect x="${col}" y="${r * 2}" width="${end - col}" height="2" fill="${color(bg)}"/>`
58      if (chars.trim() !== '') {
59        out +=
60          `<text x="${col}" y="${r * 2 + 1.55}" textLength="${end - col}" lengthAdjust="spacingAndGlyphs"` +
61          ` fill="${color(fg === DEFAULT ? 0xdddddd : fg)}">${escape(chars)}</text>`
62      }
63      col = end
64    }
65  }
66
67  return out
68}
69
70function wrap(c: Canvas, body: string): string {
71  return (
72    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${c.columns} ${c.height}"` +
73    ` width="${c.columns * UNIT}" height="${c.height * UNIT}" shape-rendering="crispEdges">` +
74    `<style>text{font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;font-size:1.6px;white-space:pre}</style>` +
75    body +
76    '</svg>'
77  )
78}
79
80/** A still canvas (a game icon) as SVG. */
81export function canvasSvg(c: Canvas): string {
82  return wrap(c, runs(c, () => true) + texts(c))
83}
84
85export function iconSvg(id: GameId): string {
86  return canvasSvg(iconCanvas(id))
87}
88
89/** What differs in `frame` from `base`, as rects in the frame's colors. */
90function diff(base: Canvas, frame: Canvas): string {
91  return runs(frame, (x, y, v) => base.px[y * base.columns + x] !== v)
92}
93
94/** A group shown from `begin` for `dur` ms, once. */
95const once = (body: string, begin: number, dur: number) =>
96  `<g visibility="hidden">${body}<set attributeName="visibility" to="visible" begin="${begin}ms" dur="${dur}ms"/></g>`
97
98/** A group shown for the `i`th of `n` slots of a loop of `loop` ms, forever from `begin`. */
99function looped(body: string, i: number, n: number, loop: number, begin: number): string {
100  const values: string[] = []
101  const times: number[] = []
102  const add = (v: string, t: number) => {
103    if (values.at(-1) === v) return
104    values.push(v)
105    times.push(t)
106  }
107  add(i === 0 ? 'visible' : 'hidden', 0)
108  if (i > 0) add('visible', i / n)
109  if (i + 1 < n) add('hidden', (i + 1) / n)
110  const anim =
111    `<animate attributeName="visibility" values="${values.join(';')}" keyTimes="${times.map(t => +t.toFixed(5)).join(';')}"` +
112    ` calcMode="discrete" dur="${loop}ms" begin="${begin}ms" repeatCount="indefinite"/>`
113
114  return `<g visibility="hidden">${body}${anim}</g>`
115}
116
117/**
118 * The animated header as of `now`: a reaction still under way plays its rest
119 * once, then the settled mood loops. The frame rate drops until it fits.
120 */
121export function headerSvg(columns: number, scene: Scene, now: number): string {
122  const age = now - scene.since
123  const introLeft = scene.mood === 'correct' ? Math.max(0, FRESH_MS - age) : 0
124  for (const step of [100, 150, 200, 300, 400, 600, 800]) {
125    const base = headerCanvas(columns, scene, now)
126    let body = runs(base, () => true)
127    for (let t = step; t < introLeft; t += step) body += once(diff(base, headerCanvas(columns, scene, now + t)), t, step)
128    // the loop starts where the reaction ends; its own time keeps the periodic motions in phase
129    const loopStart = now + introLeft
130    const n = Math.round(PERIOD / step)
131    for (let i = 0; i < n; i++) {
132      const frame = headerCanvas(columns, scene, loopStart + i * step)
133      const changed = diff(base, frame)
134      if (changed !== '') body += looped(changed, i, n, PERIOD, introLeft)
135    }
136    const svg = wrap(base, body + texts(base))
137    if (svg.length <= BUDGET) return svg
138  }
139
140  return canvasSvg(headerCanvas(columns, scene, now))
141}
142
143let settledSvg: { key: string; svg: string } | undefined
144
145/**
146 * `headerSvg`, but once no reaction is under way the markup no longer
147 * depends on the clock: every redraw sends the same SVG, so the desktop
148 * keeps its frame (no reload, no restarted animation) and nothing is redrawn
149 * from scratch. The loop's phase is the frame's own, which nobody can see.
150 */
151export function stableHeaderSvg(columns: number, scene: Scene, now: number): string {
152  if (scene.mood === 'correct' && now - scene.since < FRESH_MS) return headerSvg(columns, scene, now)
153  // as of time 0, a reaction that began FRESH_MS earlier is already over
154  const settled = { ...scene, since: -FRESH_MS }
155  const key = JSON.stringify([columns, settled])
156  if (settledSvg?.key !== key) settledSvg = { key, svg: headerSvg(columns, settled, 0) }
157
158  return settledSvg.svg
159}
160
161/** The markup's own size in CSS pixels. */
162export function headerPixels(columns: number): { width: number; height: number } {
163  return { width: columns * UNIT, height: headerRows(columns) * 2 * UNIT }
164}
165
hooks/games.ts 328 lines
1import type { GameId, Profile, Puzzle } from '../types'
2
3export type Game = {
4  id: GameId
5  name: string
6  blurb: string
7  hotkey: string
8  /** Options the pane shows in this fixed order (never shuffled). */
9  fixedOptions?: string[]
10  /** What the model must produce for this game, appended to the shared rules. */
11  brief: (topic: string, level: number) => string
12}
13
14export const GAMES: Game[] = [
15  {
16    id: 'spot-the-bug',
17    name: 'Spot the Bug',
18    blurb: 'one planted bug, explain it',
19    hotkey: '1',
20    brief: (topic, level) => `GAME: Spot the Bug.
21Write a realistic ${topic} snippet of 8 to ${10 + level * 2} lines with exactly ONE planted bug that a careful reviewer could find by reading.
22Level ${level}/10: 1-3 obvious (wrong operator, off-by-one), 4-6 needs thought (mutation, async ordering, edge cases), 7-10 subtle (concurrency, numeric precision, unicode, security).
23The code must look plausible, use descriptive names, and contain NO comments that hint at the bug.
24JSON fields: title, prompt (one line of context: what the code is meant to do), code, language, bugLine (1-based line number of the bug), bug (one sentence: what is wrong and the fix), explanation (2-3 sentences teaching the lesson), quip.`,
25  },
26  {
27    id: 'what-prints',
28    name: 'What Does It Print?',
29    blurb: 'predict the output',
30    hotkey: '2',
31    brief: (topic, level) => `GAME: What Does It Print?
32Write a short ${topic} snippet (3 to ${6 + level} lines) whose output is surprising but fully deterministic and well documented (coercion, scoping, mutability, operator precedence, evaluation order, integer/float behaviour, etc.).
33Level ${level}/10: low levels are classic gotchas, high levels combine several.
34TRACE THE PROGRAM STEP BY STEP before answering; the correct option must be exactly what it prints. If ${topic} is not a programming language (e.g. Git, SQL, Shell), use its command or query output instead.
35JSON fields: title, prompt (e.g. "What does this print?"), code, language, options (exactly 4 distinct outputs, all plausible), answer (0-based index of the correct one), explanation (why, 2-3 sentences), quip.`,
36  },
37  {
38    id: 'two-truths',
39    name: 'Two Truths and a Lie',
40    blurb: 'find the false claim',
41    hotkey: '3',
42    brief: (topic, level) => `GAME: Two Truths and a Lie.
43Write three short factual-sounding claims about ${topic} (behaviour, defaults, history, standard library, tooling). Exactly one is false.
44Level ${level}/10: at high levels the lie should be a common misconception and the truths should sound surprising.
45Only use facts you are certain about.
46JSON fields: title, prompt ("Which one is the lie?"), options (the 3 claims), answer (0-based index of the LIE), explanation (correct the lie and add one surprising detail about a truth), quip.`,
47  },
48  {
49    id: 'real-or-fake',
50    name: 'Real or Hallucinated?',
51    blurb: 'does this API exist?',
52    hotkey: '4',
53    fixedOptions: ['Real', 'Hallucinated'],
54    brief: (topic, level) => `GAME: Real or Hallucinated?
55Name ONE function, method, flag, option or config key in ${topic} or its common ecosystem, shown as a call or usage line, with a one-sentence description of what it supposedly does.
56Flip a coin: half the time it must REALLY exist exactly as written, half the time it must be a convincing fabrication (a name an AI might invent).
57Level ${level}/10: high levels use obscure real APIs and fakes that are one detail off from real ones.
58Only claim "real" for things you are certain exist in current stable versions.
59JSON fields: title, prompt (the description of what it supposedly does), code (the usage line), language, options (["Real", "Hallucinated"]), answer (0 if real, 1 if hallucinated), explanation (what actually exists and where), quip.`,
60  },
61  {
62    id: 'review-court',
63    name: 'Code Review Court',
64    blurb: 'rule on a review comment',
65    hotkey: '5',
66    fixedOptions: ['Valid', 'Nitpick', 'Wrong'],
67    brief: (topic, level) => `GAME: Code Review Court.
68Show a small ${topic} diff or snippet (4 to 14 lines) from a pull request, and one reviewer comment on it.
69The comment is either Valid (points at a real problem worth fixing), a Nitpick (true but style or taste, not worth blocking), or Wrong (the reviewer is mistaken). Pick one of the three roughly at random.
70Level ${level}/10: high levels make the distinction genuinely debatable but still with one best ruling.
71JSON fields: title, prompt (the reviewer comment, quoted, prefixed by a made-up reviewer handle like "@grumpy-dev:"), code, language, options (["Valid", "Nitpick", "Wrong"]), answer (0, 1 or 2), explanation (the ruling, as a judge would deliver it, 2-3 sentences), quip.`,
72  },
73  {
74    id: 'big-o',
75    name: 'Big-O Bingo',
76    blurb: 'guess the complexity',
77    hotkey: '6',
78    brief: (topic, level) => `GAME: Big-O Bingo.
79Write a ${topic} function of 4 to ${8 + level} lines and ask for its worst-case time complexity in terms of the named input size(s).
80Level ${level}/10: high levels hide the cost in library calls (list.remove, string concatenation, includes inside loops, sorting, hash collisions are NOT a trick to use).
81If ${topic} is not a programming language, use SQL query plans or shell pipelines over n lines instead.
82JSON fields: title, prompt (e.g. "Worst-case time complexity? n = len(items)"), code, language, options (exactly 4 distinct complexities like "O(n log n)"), answer (0-based index of the correct one), explanation (where the cost comes from), quip.`,
83  },
84]
85
86export function gameOf(id: GameId): Game {
87  return GAMES.find(g => g.id === id) ?? GAMES[0]!
88}
89
90export const SYSTEM = `You are the quizmaster of Claudle, a set of quick programming games played in a terminal while a coding agent works.
91Personality: a witty, slightly smug but fair senior engineer. Puzzles must be technically correct above all; humour lives in the "title" and "quip" fields only.
92Reply with ONE JSON object and nothing else: no markdown fences, no prose before or after.
93Common JSON fields: "title" (a punny 2-6 word round name), "quip" (one short funny line shown after the answer).
94Code goes in "code" as plain text with real newlines escaped as \\n, at most 70 characters per line.`
95
96export function generationPrompt(game: Game, topic: string, level: number, avoid: string[]): string {
97  const avoidLine = avoid.length > 0 ? `\nDo not reuse these recent rounds or their ideas: ${avoid.join('; ')}.` : ''
98
99  return `${game.brief(topic, level)}${avoidLine}\nReply with the JSON object only.`
100}
101
102export function gradingPrompt(puzzle: Puzzle, typed: string): string {
103  return `GRADE an answer to a Spot the Bug round.
104Code (${puzzle.language ?? puzzle.topic}), lines numbered:
105${numbered(puzzle.code ?? '')}
106The planted bug is on line ${puzzle.bugLine}: ${puzzle.bug}
107The player answered: """${typed.slice(0, 600)}"""
108Score 0-100: 100 = right line and right reason; 60-90 = right reason, line missing or slightly off; 30-50 = right line, vague or wrong reason; 0-20 = wrong. If the player found a DIFFERENT genuine bug, give 70 and say so.
109JSON fields: score (integer), verdict (one or two sentences addressed to the player, in character).`
110}
111
112export function numbered(code: string): string {
113  return code
114    .split('\n')
115    .map((line, i) => `${String(i + 1).padStart(2)}  ${line}`)
116    .join('\n')
117}
118
119/** The first JSON object in a reply, tolerating fences or prose around it. */
120export function parseJson(text: string): Record<string, unknown> | undefined {
121  const start = text.indexOf('{')
122  const end = text.lastIndexOf('}')
123  if (start < 0 || end <= start) return undefined
124  try {
125    const value: unknown = JSON.parse(text.slice(start, end + 1))
126
127    return value !== null && typeof value === 'object' && !Array.isArray(value)
128      ? (value as Record<string, unknown>)
129      : undefined
130  } catch {
131    return undefined
132  }
133}
134
135const str = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
136
137/** Checks a generated round and shapes it for the pane; undefined when it is unusable. */
138export function toPuzzle(
139  game: Game,
140  topic: string,
141  level: number,
142  raw: Record<string, unknown>,
143  random: () => number = Math.random,
144): Puzzle | undefined {
145  const title = str(raw.title)
146  const prompt = str(raw.prompt)
147  const explanation = str(raw.explanation)
148  if (!title || !prompt || !explanation) return undefined
149  const code = str(raw.code)?.replace(/\t/g, '  ')
150  const base: Puzzle = {
151    game: game.id,
152    topic,
153    level,
154    title,
155    prompt,
156    explanation,
157    quip: str(raw.quip) ?? '',
158    ...(code ? { code } : {}),
159    ...(str(raw.language) ? { language: str(raw.language)!.toLowerCase() } : {}),
160  }
161
162  if (game.id === 'spot-the-bug') {
163    const bugLine = Number(raw.bugLine)
164    const bug = str(raw.bug)
165    const lines = code?.split('\n').length ?? 0
166    if (!code || !bug || !Number.isInteger(bugLine) || bugLine < 1 || bugLine > lines) return undefined
167
168    return { ...base, bugLine, bug }
169  }
170
171  const answer = Number(raw.answer)
172  if (game.fixedOptions) {
173    if (!Number.isInteger(answer) || answer < 0 || answer >= game.fixedOptions.length) return undefined
174
175    return { ...base, options: game.fixedOptions, answer }
176  }
177
178  const options = Array.isArray(raw.options) ? raw.options.map(o => String(o).trim()) : []
179  const wanted = game.id === 'two-truths' ? 3 : 4
180  if (options.length !== wanted || new Set(options).size !== wanted) return undefined
181  if (!Number.isInteger(answer) || answer < 0 || answer >= wanted) return undefined
182  if (game.id !== 'two-truths' && !code) return undefined
183
184  const order = options.map((_, i) => i)
185  for (let i = order.length - 1; i > 0; i--) {
186    const j = Math.floor(random() * (i + 1))
187    ;[order[i], order[j]] = [order[j]!, order[i]!]
188  }
189
190  return { ...base, options: order.map(i => options[i]!), answer: order.indexOf(answer) }
191}
192
193// ── Rating and scoring ────────────────────────────────────────────────
194
195export const START_RATING = 1000
196
197export function levelFor(rating: number): number {
198  return Math.min(10, Math.max(1, Math.round((rating - 700) / 100)))
199}
200
201/** Elo against a round of the given level; `score` is 0 to 100. */
202export function ratingDelta(rating: number, level: number, score: number): number {
203  const expected = 1 / (1 + 10 ** ((700 + level * 100 - rating) / 400))
204
205  return Math.round(40 * (score / 100 - expected))
206}
207
208export function pointsFor(level: number, score: number, streak: number): number {
209  if (score < 50) return 0
210  const multiplier = 1 + Math.min(streak, 10) * 0.1
211
212  return Math.round(level * 10 * (score / 100) * multiplier)
213}
214
215export const TITLES: [number, string][] = [
216  [0, 'Fresh Intern'],
217  [100, 'Rubber Duck Whisperer'],
218  [300, 'Off-By-One Survivor'],
219  [700, 'Stack Overflow Regular'],
220  [1500, 'Merge Conflict Mediator'],
221  [3000, 'Senior Stack-Trace Reader'],
222  [6000, '10x Debugger'],
223  [12000, 'Principal Pedant'],
224]
225
226export function titleFor(xp: number): string {
227  return TITLES.filter(([min]) => xp >= min).at(-1)![1]
228}
229
230/** How far `xp` is toward the next title: 0..1, and the XP that title needs. */
231export function progressFor(xp: number): { progress: number; nextAt: number | undefined } {
232  const i = TITLES.findIndex(([min]) => xp < min)
233  if (i < 0) return { progress: 1, nextAt: undefined }
234  const from = TITLES[i - 1]![0]
235  const to = TITLES[i]![0]
236
237  return { progress: (xp - from) / (to - from), nextAt: to }
238}
239
240export function emptyProfile(): Profile {
241  return { xp: 0, played: 0, correct: 0, streak: 0, bestStreak: 0, games: {}, ratings: {}, recent: {} }
242}
243
244export const LOADING_LINES = [
245  'Claude is hiding a bug where you least expect it',
246  'Consulting the ancient scrolls of Stack Overflow',
247  'Sharpening the off-by-one',
248  'Asking the rubber duck for a hard one',
249  'Generating plausible-looking nonsense (some of it true)',
250  'Reticulating splines',
251  'Bribing the type checker to look away',
252  'Writing a commit message that says "small fix"',
253]
254
255/** Topics the pane offers as toggles; any other can be added with /claudle topics. */
256export const TOPIC_PRESETS = [
257  'JavaScript',
258  'TypeScript',
259  'Python',
260  'SQL',
261  'Git',
262  'Shell',
263  'Rust',
264  'Go',
265  'Java',
266  'C#',
267  'C++',
268  'Regex',
269  'HTTP',
270  'Docker',
271  'React',
272  'CSS',
273]
274
275// ── Ask Claude: the tutor ─────────────────────────────────────────────
276
277export const TUTOR_SYSTEM = `You are Claude, a patient and encouraging programming tutor inside Claudle, a quiz game played in a terminal pane.
278Teach, don't just tell: name the underlying concept, give the mental model, and show how to recognise the pattern next time in real code.
279Keep replies short and skimmable: at most about 180 words, plain markdown (short paragraphs, bullets, code blocks of at most 10 lines). No headings.
280Correct misconceptions gently and specifically. A little humour is welcome, but teaching comes first.
281When it helps, end with ONE short check-your-understanding question or tiny exercise.
282The rounds are generated by a model and can be wrong. If the round, its answer or your earlier reply contains a mistake, say so plainly and give the correct version.`
283
284export const HINT_RULES = `THE PLAYER HAS NOT ANSWERED YET. Never reveal, confirm or rule out the answer or any option, and never name the buggy line.
285Give Socratic hints instead: point at what to look at, which rule applies, or a question to ask themselves. Each new hint may be a bit stronger than the last.
286If they ask for the answer outright, decline playfully and give a stronger hint.`
287
288/** Everything the tutor should know about the round, the player's answer, and the chat so far. */
289export function tutorPrompt(
290  puzzle: Puzzle,
291  outcome: { score: number; picked?: number; typed?: string } | undefined,
292  turns: readonly { who: 'you' | 'claude'; text: string }[],
293  question: string,
294): string {
295  const lines = [
296    `ROUND: ${gameOf(puzzle.game).name} · ${puzzle.topic} · level ${puzzle.level}/10 · "${puzzle.title}"`,
297    `Shown to the player: ${puzzle.prompt}`,
298  ]
299  if (puzzle.code) lines.push(`Code (${puzzle.language ?? puzzle.topic}):\n${numbered(puzzle.code)}`)
300  if (puzzle.options) lines.push(`Options:\n${puzzle.options.map((o, i) => `  ${'abcd'[i]}) ${o}`).join('\n')}`)
301  const answer =
302    puzzle.game === 'spot-the-bug'
303      ? `The planted bug is on line ${puzzle.bugLine}: ${puzzle.bug}`
304      : `Correct answer: ${'abcd'[puzzle.answer ?? 0]}) ${puzzle.options?.[puzzle.answer ?? 0]}`
305  lines.push(`${answer}\nExplanation given to the player afterwards: ${puzzle.explanation}`)
306  if (outcome) {
307    const said =
308      outcome.typed !== undefined
309        ? `The player answered: "${outcome.typed}"`
310        : outcome.picked !== undefined
311          ? `The player picked ${'abcd'[outcome.picked]}) ${puzzle.options?.[outcome.picked]}`
312          : 'The player gave up without answering.'
313    lines.push(`${said} (score ${outcome.score}/100).`)
314  } else {
315    lines.push(HINT_RULES)
316  }
317  if (turns.length > 0) {
318    lines.push('CONVERSATION SO FAR:')
319    for (const t of turns.slice(-8)) lines.push(`${t.who === 'you' ? 'Player' : 'You'}: ${t.text}`)
320  }
321  lines.push(`PLAYER'S QUESTION: ${question.slice(0, 1000)}`)
322
323  return lines.join('\n\n')
324}
325
326export const ASK_BEFORE = ['Give me a hint', 'What concept is this testing?']
327export const ASK_AFTER = ['Explain it step by step', 'Show me a similar example', 'How do I spot this in real code?']
328
hooks/canvas.ts 162 lines
1// Copied from the agent-comic mod (~/.claude/mods/agent-comic/hooks/canvas.ts).
2// A pixel canvas two pixels tall per terminal cell (half blocks), with a text
3// layer on top, encoded into a Raster's cells.
4
5export const DEFAULT = 0x01000000
6
7const UPPER = 0x2580
8const LOWER = 0x2584
9
10export class Canvas {
11  readonly px: Uint32Array
12  readonly glyph: Uint32Array
13  readonly fg: Uint32Array
14  readonly bg: Uint32Array
15  readonly height: number
16
17  constructor(readonly columns: number, readonly rows: number) {
18    this.height = rows * 2
19    this.px = new Uint32Array(columns * this.height).fill(DEFAULT)
20    this.glyph = new Uint32Array(columns * rows)
21    this.fg = new Uint32Array(columns * rows)
22    this.bg = new Uint32Array(columns * rows)
23  }
24
25  set(x: number, y: number, color: number) {
26    x = Math.round(x)
27    y = Math.round(y)
28    if (x < 0 || y < 0 || x >= this.columns || y >= this.height) return
29    // painter's order: a pixel drawn later covers any text drawn into its cell before.
30    // It covers only its half, though: the other half keeps the text's background
31    // (a sign's board), or the board would show a gap above a figure walking past it.
32    const cell = (y >> 1) * this.columns + x
33    if (this.glyph[cell]) {
34      if (this.bg[cell] !== DEFAULT) this.px[(y ^ 1) * this.columns + x] = this.bg[cell]!
35      this.glyph[cell] = 0
36    }
37    this.px[y * this.columns + x] = color
38  }
39
40  rect(x: number, y: number, w: number, h: number, color: number) {
41    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, color)
42  }
43
44  /** Draws rows of palette letters, '.' transparent; `flip` mirrors it. */
45  sprite(x: number, y: number, rows: readonly string[], palette: Record<string, number>, flip = false) {
46    for (let j = 0; j < rows.length; j++) {
47      const row = rows[j]!
48      for (let i = 0; i < row.length; i++) {
49        const ch = row[flip ? row.length - 1 - i : i]!
50        const color = palette[ch]
51        if (color !== undefined) this.set(x + i, y + j, color)
52      }
53    }
54  }
55
56  /** Writes text into the cell layer; non-printable or wide characters become '?'. */
57  text(col: number, row: number, s: string, fg: number, bg = DEFAULT) {
58    col = Math.round(col)
59    if (row < 0 || row >= this.rows) return
60    let i = 0
61    for (const ch of s) {
62      const c = col + i++
63      if (c < 0 || c >= this.columns) continue
64      const at = row * this.columns + c
65      this.glyph[at] = safe(ch)
66      this.fg[at] = fg
67      this.bg[at] = bg
68    }
69  }
70
71  /** A rounded box around `lines`, top-left at (col, row). */
72  box(col: number, row: number, lines: readonly string[], fg: number, bg: number, border: number) {
73    if (!lines.length) return
74    const inner = Math.max(...lines.map(l => [...l].length))
75    this.text(col, row, '╭' + '─'.repeat(inner + 2) + '╮', border, bg)
76    lines.forEach((l, i) => {
77      this.text(col, row + 1 + i, '│', border, bg)
78      this.text(col + 1, row + 1 + i, ' ' + l + ' '.repeat(inner - [...l].length + 1), fg, bg)
79      this.text(col + inner + 3, row + 1 + i, '│', border, bg)
80    })
81    this.text(col, row + lines.length + 1, '╰' + '─'.repeat(inner + 2) + '╯', border, bg)
82  }
83
84  /** The Raster `cells`: base64 of [codePoint, fg, bg] u32 triplets, row-major. */
85  encode(): string {
86    const { columns, rows } = this
87    const out = new Uint32Array(columns * rows * 3)
88    for (let r = 0; r < rows; r++) {
89      for (let c = 0; c < columns; c++) {
90        const cell = r * columns + c
91        const o = cell * 3
92        if (this.glyph[cell]) {
93          out[o] = this.glyph[cell]!
94          out[o + 1] = this.fg[cell]!
95          out[o + 2] = this.bg[cell] === DEFAULT ? this.px[(2 * r + 1) * columns + c]! : this.bg[cell]!
96          continue
97        }
98        const top = this.px[2 * r * columns + c]!
99        const bottom = this.px[(2 * r + 1) * columns + c]!
100        if (top === DEFAULT && bottom === DEFAULT) {
101          out[o] = 0x20; out[o + 1] = DEFAULT; out[o + 2] = DEFAULT
102        } else if (top === bottom) {
103          // a solid cell is a coloured space, not a full block: no glyph for a terminal's
104          // minimum-contrast setting (VS Code's is on by default) to brighten against its own colour
105          out[o] = 0x20; out[o + 1] = top; out[o + 2] = top
106        } else if (top === DEFAULT) {
107          out[o] = LOWER; out[o + 1] = bottom; out[o + 2] = DEFAULT
108        } else {
109          out[o] = UPPER; out[o + 1] = top; out[o + 2] = bottom
110        }
111      }
112    }
113    return base64(new Uint8Array(out.buffer))
114  }
115}
116
117function safe(ch: string): number {
118  const cp = ch.codePointAt(0) ?? 0x3f
119  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff) return 0x3f
120  // East Asian wide ranges and emoji draw two cells wide: refuse them here.
121  if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
122      (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
123      (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0xd800 && cp <= 0xdfff)) return 0x3f
124  // emoji (✅ ⚡ ⭐) draw two cells wide, marks and format characters none: the host refuses both
125  if (UNFIT.test(ch)) return 0x3f
126  return cp
127}
128
129const UNFIT = /[\p{Emoji_Presentation}\p{Mn}\p{Me}\p{Cf}]/u
130
131/**
132 * Text as the canvas can draw it, one code point to a cell: accents composed onto
133 * their letters, emoji and invisible marks dropped (anything else unfit becomes '?').
134 */
135export function cellText(s: string): string {
136  return s
137    .normalize('NFC')
138    .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}️|[\p{Mn}\p{Me}\p{Cf}]/gu, '')
139    .replace(/\s+/g, ' ')
140    .trim()
141}
142
143const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
144
145export function base64(bytes: Uint8Array): string {
146  let out = ''
147  let i = 0
148  for (; i + 2 < bytes.length; i += 3) {
149    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
150    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
151  }
152  const rest = bytes.length - i
153  if (rest === 1) {
154    const n = bytes[i]! << 16
155    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + '=='
156  } else if (rest === 2) {
157    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
158    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + '='
159  }
160  return out
161}
162
types/index.d.ts 77 lines
1export type GameId = 'spot-the-bug' | 'what-prints' | 'two-truths' | 'real-or-fake' | 'review-court' | 'big-o'
2
3/** One generated round, as the pane shows it. */
4export type Puzzle = {
5  /** Unique per generated round; the Ask Claude chat is keyed on it. */
6  id?: string
7  game: GameId
8  topic: string
9  level: number
10  title: string
11  prompt: string
12  code?: string
13  language?: string
14  /** Choice games: the options in display order, and which one is right. */
15  options?: string[]
16  answer?: number
17  /** Spot the Bug: the planted bug, kept hidden until graded. */
18  bugLine?: number
19  bug?: string
20  explanation: string
21  quip: string
22}
23
24export type Outcome = {
25  /** 0 to 100: a choice is 0 or 100, a free answer is graded. */
26  score: number
27  verdict: string
28  /** The option picked, for a choice game. */
29  picked?: number
30  /** What the person typed, for a free answer. */
31  typed?: string
32  points: number
33  ratingDelta: number
34}
35
36export type View =
37  | { kind: 'menu' }
38  | { kind: 'loading'; game: GameId; line: string }
39  | { kind: 'question'; puzzle: Puzzle }
40  | { kind: 'grading'; puzzle: Puzzle; typed: string }
41  | { kind: 'feedback'; puzzle: Puzzle; outcome: Outcome }
42  | { kind: 'error'; game: GameId; message: string }
43  | { kind: 'stats' }
44  | { kind: 'topics' }
45
46export type Profile = {
47  xp: number
48  played: number
49  correct: number
50  streak: number
51  bestStreak: number
52  /** Rounds played and won per game. */
53  games: Partial<Record<GameId, { played: number; correct: number }>>
54  /** Elo-style rating per topic; the level of the next round follows it. */
55  ratings: Record<string, number>
56  /** Recent round titles per game, so the model does not repeat itself. */
57  recent: Record<string, string[]>
58}
59
60export type ChatTurn = { who: 'you' | 'claude'; text: string }
61
62/** The Ask Claude conversation about the round on screen. */
63export type Chat = {
64  /** Which round it belongs to; a new round starts a new chat. */
65  round: string
66  turns: ChatTurn[]
67  isPending: boolean
68  /** Shown while a round is still open (it always shows once answered). */
69  isOpen: boolean
70}
71
72declare module 'claude-code' {
73  interface PluginState {
74    claudle: { view: View; profile: Profile; topics: string[]; agentDone: boolean; chat: Chat }
75  }
76}
77