A 2048 game above the prompt to keep your attention in Claude Code while it works, with a live view of Claude's work and a your-turn card when it stops.

Don't let your attention wander off while waiting for Claude Code's answer.

A small 2048 game sits right above the prompt, so you can stay in Claude Code while it works instead of switching to another tab. Claude's output still appears above it as usual: glance at it or follow along whenever you want, and play a few moves when you feel like it.
2048 was chosen because the attention it gets is entirely up to you: as much or as little as you like, whenever you like. Leave it at any moment and pick it up where you left off. Click the board to play, press Tab to type.
The game also shows what Claude is doing. When Claude stops, the board locks behind a ✓ YOUR TURN card that says what happened and what to do next.
/plugin install twenty-forty-wait --marketplace leonidb/claude-code-mods
Run /fortywait to show the game; run it again to hide it.
Needs Claude Code 2.1.289 or later. Tested with Claude Code 2.1.296.
Only Claude's own words, for the card's summary. After a turn of 10 seconds or longer that ends normally with a reply of 200 characters or more, that reply is sent to Claude Haiku, with fixed instructions, to write the summary: the whole reply if it's 8,000 characters or less, otherwise its first 2,000 and last 6,000 characters. That text can include anything Claude wrote in the reply, such as file paths, quoted output or code. It goes through the same connection as the rest of your Claude Code session (Anthropic, or the provider or gateway your session uses, such as Bedrock or Vertex), and adds a little to your usage.
Nothing else from the session is sent: not your prompts, tool calls or files. Nothing at all is sent before your first /fortywait, or after you run it again. A reply already on its way to Haiku at that moment can't be called back.
The mod keeps two values in Claude Code's plugin storage: whether the game is on, and your best score.
--resume starts a new game.Third-party marketplaces don't auto-update by default. To update:
/plugin marketplace update leonidb /plugin update twenty-forty-wait@leonidb
Or turn on auto-update: /plugin, then Marketplaces, select leonidb, then Enable auto-update.
To uninstall:
/plugin uninstall twenty-forty-wait@leonidb
An independent community mod, not affiliated with or endorsed by Anthropic. Apache License 2.0. Design notes: DESIGN.md.
hooks/register.tsx 549 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Model } from '../types'
5import { bandTree } from './band'
6import {
7 INITIAL_MODEL,
8 MODEL_SHAPE,
9 afterBoardPost,
10 afterDialog,
11 afterFault,
12 afterHandoff,
13 afterPrompt,
14 afterRestore,
15 afterSummary,
16 afterTick,
17 afterToggle,
18 afterToolCall,
19 afterToolDone,
20 afterTurnEnd,
21 afterTurnStart,
22 bandOf,
23 boardPostOf,
24 handoffOf,
25 isDealt,
26 isReadFirst,
27 isTicking,
28 isTimeShown,
29 modelOf,
30 toggleNoteOf,
31 toolSucceeded,
32 turnKeyOf,
33 writeOf,
34} from './logic'
35import type {
36 Effect,
37 SaveEffect,
38 Step,
39 StoreRead,
40 SummarizeEffect,
41 TurnKey,
42} from './logic'
43
44const model = atom(
45 { plugin: 'twenty-forty-wait', key: 'model' } as const,
46 INITIAL_MODEL,
47 { shape: MODEL_SHAPE },
48)
49
50// The time the band shows. Written with each change the band shows and by
51// the ticker each second, so the band redraws as it moves.
52const now = atom({ plugin: 'twenty-forty-wait', key: 'now' } as const, 0)
53
54// One module load's handles. `section` runs every piece of work that reads or
55// writes the model, one after another in the order it came in; `writes`
56// chains the durable writes; `timer` is the ticker while the band needs it.
57// A tick (between faults), a handoff for one board, or a restore the band
58// asked for waits in line at most once, and so do prompt edits that come one
59// after another with nothing between: the edit waiting in line, until it
60// starts or anything else comes in after it.
61type Load = {
62 section: Promise<unknown>
63 writes: Map<string, Promise<unknown>>
64 timer: { cancel: () => void } | undefined
65 isTickWaiting: boolean
66 handoffWaiting: number | undefined
67 isRestoreWaiting: boolean
68 editWaiting: object | undefined
69 isFaulted: boolean
70 isRestored: boolean
71}
72
73function inOrder<T>(load: Load, work: () => Promise<T>): Promise<T> {
74 const run = load.section.then(work)
75 load.section = run.catch(() => undefined)
76 return run
77}
78
79// A handoff the render asked for, done now if its timer never ran (another
80// plugin may refuse timers): the next work in line carries it out first.
81async function handedBack($: EngineInterface, load: Load): Promise<void> {
82 const epoch = load.handoffWaiting
83 if (epoch === undefined) return
84 load.handoffWaiting = undefined
85 await commit($, latest => afterHandoff(latest, epoch))
86}
87
88// Runs observation work in this load's order, with the time the event came
89// in, after restoring a session whose state Claude Code emptied. The event
90// itself is never held: the hook hands it on at once. Work that fails stops
91// the board in its own place in line, before anything after it, in case the
92// band is stuck.
93function observe(
94 $: EngineInterface,
95 load: Load,
96 work: (at: number) => Promise<void>,
97): void {
98 const arrived = $.clock.now()
99 load.editWaiting = undefined
100 void inOrder(load, async () => {
101 try {
102 await rescued($, load)
103 await handedBack($, load)
104 await work(await arrived)
105 } catch {
106 await faulted($, load)
107 }
108 })
109}
110
111// Stops the board: the ticker first, and the band stops drawing it, so both
112// hold even if nothing can be written, then the model. The ticker's waiting
113// tick is forgotten, so a later ticker runs even if that tick failed before
114// its work began; a tick still in line runs, beside the later ticker's.
115async function faulted($: EngineInterface, load: Load): Promise<void> {
116 load.timer?.cancel()
117 load.timer = undefined
118 load.isTickWaiting = false
119 load.isFaulted = true
120 // The band read nothing while it was hidden, so no write redraws it.
121 $.ui.invalidate('ui.render')
122 await commit($, afterFault).catch(() => undefined)
123}
124
125// Restores the session's band from what was kept: on or off for a session
126// with no choice of its own, the best score, and a new game for a session
127// with none. The band shows from here on.
128async function restored($: EngineInterface, load: Load): Promise<void> {
129 const kept = {
130 isOn: await keptOn($),
131 best: await keptValue($, 'best'),
132 }
133 const at = await $.clock.now()
134 const step = await commit($, current =>
135 afterRestore(current, kept, Math.random),
136 )
137 void runEffects($, load, step.effects)
138 load.isRestoreWaiting = false
139 load.isFaulted = false
140 load.isRestored = true
141 $.ui.invalidate('ui.render')
142 await settled($, load, at)
143}
144
145// A `/clear`, or a resume inside Claude Code, ends the session: Claude Code
146// empties the mod's session state and no session start follows. A load that
147// has restored and then finds a board with no tiles restores again, as a new
148// session would.
149async function rescued($: EngineInterface, load: Load): Promise<void> {
150 if (!load.isRestored || isDealt(modelOf(await read($, model)).game)) return
151 await restored($, load)
152}
153
154// Applies a transition to the model. A transition that changes nothing
155// writes nothing; otherwise it is one compare-and-set write, and `update` may
156// run it more than once, so the effects are those of the run written.
157async function commit(
158 $: EngineInterface,
159 transition: (current: Model) => Step,
160): Promise<Step & { isChanged: boolean }> {
161 const kept = modelOf(await read($, model))
162 const first = transition(kept)
163 if (first.model === kept) return { ...first, isChanged: false }
164 let effects = first.effects
165 const written = await update($, model, value => {
166 const step = transition(modelOf(value))
167 effects = step.effects
168 return step.model
169 })
170 return { model: written, effects, isChanged: true }
171}
172
173// After a change at `at`: a lock that came due is committed first, whatever
174// arrived, so the ticker never stops with a lock left uncommitted. Then the
175// band's time moves when the band shows time, and the ticker runs exactly
176// while the band needs the clock.
177async function settled(
178 $: EngineInterface,
179 load: Load,
180 at: number,
181): Promise<void> {
182 const latest = await commit($, current => afterTick(current, at))
183 if (isTimeShown(latest.model)) await update($, now, () => at)
184 const isNeeded = isTicking(latest.model, at)
185 if (isNeeded && load.timer === undefined) {
186 load.timer = $.clock.every(1000, () => {
187 if (load.isTickWaiting) return
188 load.isTickWaiting = true
189 observe($, load, async tickAt => {
190 load.isTickWaiting = false
191 await settled($, load, tickAt)
192 })
193 })
194 } else if (!isNeeded && load.timer !== undefined) {
195 load.timer.cancel()
196 load.timer = undefined
197 }
198}
199
200// Carries out a commit's effects: durable writes in line behind earlier
201// ones, and summary calls on their own, none once a failure stopped the band,
202// even if that couldn't be written. Resolves once the writes have landed.
203function runEffects(
204 $: EngineInterface,
205 load: Load,
206 effects: Effect[],
207): Promise<unknown> {
208 const saves: SaveEffect[] = []
209 for (const effect of effects) {
210 switch (effect.kind) {
211 case 'save':
212 saves.push(effect)
213 break
214 case 'summarize':
215 if (!load.isFaulted) summarize($, load, effect)
216 break
217 }
218 }
219 return queueWrites($, load, saves)
220}
221
222// Asks the model for a turn's summary, outside the line so a slow call holds
223// nothing up, and hands the result back in line, where the turn it belongs
224// to decides whether it is shown. A call that fails says so in its result; a
225// call refused outright is logged, and the card keeps its placeholder.
226function summarize(
227 $: EngineInterface,
228 load: Load,
229 effect: SummarizeEffect,
230): void {
231 $.model.complete(effect.request).then(
232 result => {
233 observe($, load, async at => {
234 await committedAt($, load, at, current =>
235 afterSummary(current, effect.turn, result),
236 )
237 })
238 },
239 (error: unknown) => {
240 $.ui.log(`could not summarize: ${String(error)}`, { to: 'debug' })
241 },
242 )
243}
244
245// Commits an observation together with any lock that came due by then, in
246// one read, so a stopped ticker never leaves a lock waiting; the band's time
247// moves only when something changed.
248async function committedAt(
249 $: EngineInterface,
250 load: Load,
251 at: number,
252 transition: (current: Model) => Step,
253): Promise<void> {
254 const step = await commit($, current => {
255 const observed = transition(current)
256 const due = afterTick(observed.model, at)
257 return { model: due.model, effects: [...observed.effects, ...due.effects] }
258 })
259 if (step.isChanged) await settled($, load, at)
260}
261
262// Queues durable writes, each behind the earlier writes of its own key, and
263// resolves once these have landed; a slow best score never holds up on/off.
264// A best score reads what is stored first, since another session may have
265// kept a higher one meanwhile. A write that fails is logged, and its queue
266// goes on.
267function queueWrites(
268 $: EngineInterface,
269 load: Load,
270 effects: SaveEffect[],
271): Promise<unknown> {
272 const landed: Promise<unknown>[] = []
273 for (const effect of effects) {
274 const queued = (load.writes.get(effect.key) ?? Promise.resolve())
275 .then(async () => {
276 const stored: StoreRead = isReadFirst(effect)
277 ? await storedOf($, effect.key)
278 : { isRead: false }
279 const write = writeOf(effect, stored)
280 if (write !== null) await $.store.set(write.key, write.value)
281 })
282 .catch((error: unknown) => {
283 $.ui.log(`could not save ${effect.key}: ${String(error)}`, {
284 to: 'debug',
285 })
286 })
287 load.writes.set(effect.key, queued)
288 landed.push(queued)
289 }
290 return Promise.all(landed)
291}
292
293// What the store holds under a key now, or that it couldn't be read.
294async function storedOf($: EngineInterface, key: string): Promise<StoreRead> {
295 try {
296 return { isRead: true, value: await $.store.get(key) }
297 } catch (error) {
298 $.ui.log(`could not read ${key}: ${String(error)}`, { to: 'debug' })
299 return { isRead: false }
300 }
301}
302
303// Whether a session with no choice of its own starts with the band on: as
304// `/fortywait` last left it, in any session.
305function keptOn($: EngineInterface): Promise<unknown> {
306 return keptValue($, 'isOn')
307}
308
309// A value kept from an earlier session, or undefined when it can't be read.
310async function keptValue($: EngineInterface, key: string): Promise<unknown> {
311 try {
312 return await $.store.get(key)
313 } catch (error) {
314 $.ui.log(`could not read ${key}: ${String(error)}`, { to: 'debug' })
315 return undefined
316 }
317}
318
319/**
320 * Wires the mod into Claude Code. At each session start or reload it offers
321 * `/fortywait` and restores the kept choice, and restores again when Claude
322 * Code empties the session's state; `/fortywait` switches the band on or off.
323 * Turn, tool and dialog events move the band's model, one after another in
324 * the order they came, while each event goes on unchanged and unheld; the
325 * ticker moves the band's time, and the band above the prompt draws it.
326 */
327export const register: Register = on => {
328 const load: Load = {
329 section: Promise.resolve(),
330 writes: new Map(),
331 timer: undefined,
332 isTickWaiting: false,
333 handoffWaiting: undefined,
334 isRestoreWaiting: false,
335 editWaiting: undefined,
336 isFaulted: false,
337 isRestored: false,
338 }
339
340 on('session.start', ($, e, next) => {
341 // The restore takes its place in line before anything can wait on I/O,
342 // so a `/fortywait` run meanwhile comes after it; the session starts at
343 // once, whatever the restore waits on.
344 void inOrder(load, async () => {
345 try {
346 await $.command.register({
347 name: 'fortywait',
348 description:
349 'Turn Twenty Forty-Wait on or off: a game above the prompt while Claude works',
350 immediate: true,
351 })
352 await restored($, load)
353 } catch {
354 await faulted($, load)
355 }
356 })
357 return next(e)
358 }).catch(($, e, next) => next(e))
359
360 on('command.run', { command: 'fortywait' }, async $ => {
361 const { step, written } = await inOrder(load, async () => {
362 await rescued($, load)
363 const at = await $.clock.now()
364 const step = await commit($, afterToggle)
365 load.isFaulted = false
366 $.ui.invalidate('ui.render')
367 const written = runEffects($, load, step.effects)
368 await settled($, load, at).catch(() => faulted($, load))
369 return { step, written }
370 })
371 await written
372 return { text: toggleNoteOf(step.model) }
373 })
374
375 on('turn.start', ($, e, next) => {
376 observe($, load, async at => {
377 await commit($, current =>
378 afterTurnStart(current, { turnId: e.turnId, at }),
379 )
380 await settled($, load, at)
381 })
382 return next(e)
383 }).catch(($, e, next) => next(e))
384
385 on('tool.call', ($, e, next) => {
386 const call = { tool: String(e.tool), input: e }
387 // The turn this call ran in, so its result counts only there.
388 let turn: TurnKey | null = null
389 observe($, load, async at => {
390 const step = await commit($, current =>
391 afterToolCall(current, { ...call, at, agentId: e.agentId }),
392 )
393 turn = turnKeyOf(step.model)
394 await settled($, load, at)
395 })
396 const result = next(e)
397 void result.then(
398 done => {
399 observe($, load, async at => {
400 await commit($, current =>
401 afterToolDone(current, {
402 ...call,
403 isOk: toolSucceeded(done),
404 turn,
405 }),
406 )
407 await settled($, load, at)
408 })
409 },
410 () => undefined,
411 )
412 return result
413 }).catch(($, e, next) => next(e))
414
415 on('turn.complete', ($, e, next) => {
416 observe($, load, async at => {
417 const step = await commit($, current =>
418 afterTurnEnd(current, {
419 turnId: e.turnId,
420 reason: e.reason,
421 answer: e.answer,
422 durationMs: e.durationMs,
423 at,
424 agentId: e.agentId,
425 }),
426 )
427 void runEffects($, load, step.effects)
428 await settled($, load, at)
429 })
430 return next(e)
431 }).catch(($, e, next) => next(e))
432
433 // A permission prompt or a question, from any loop: the dialog takes the
434 // keys from the board.
435 on('classic.PermissionRequest', ($, e, next) => {
436 observe($, load, async at => {
437 await commit($, afterDialog)
438 await settled($, load, at)
439 })
440 return next(e)
441 }).catch(($, e, next) => next(e))
442
443 // The board posted its clicks and keys. Only queued here: the count the
444 // model acknowledged reaches the board through its props at the next draw.
445 on('ui.message', { component: 'AbovePrompt' }, ($, e, next) => {
446 const post = boardPostOf(e.data)
447 if (post !== null) {
448 observe($, load, async at => {
449 const step = await commit($, current =>
450 afterBoardPost(current, e.element, post, at, Math.random),
451 )
452 void runEffects($, load, step.effects)
453 await settled($, load, at)
454 })
455 }
456 return next(e)
457 }).catch(($, e, next) => next(e))
458
459 // An edit or a send in the prompt: if the game still had the keys, the
460 // person left it in a way Claude Code doesn't report. Observed only: the
461 // edit and the prompt go on unchanged. Edits typed one after another wait
462 // in line as one, since each says the same.
463 on('prompt.edit', ($, e, next) => {
464 if (load.editWaiting === undefined) {
465 const edit = {}
466 observe($, load, async at => {
467 if (load.editWaiting === edit) load.editWaiting = undefined
468 await committedAt($, load, at, current =>
469 afterPrompt(current, { kind: 'edit' }),
470 )
471 })
472 load.editWaiting = edit
473 }
474 return next(e)
475 }).catch(($, e, next) => next(e))
476
477 on('prompt.submit', ($, e, next) => {
478 const origin = String((e.origin as { kind?: unknown } | undefined)?.kind)
479 observe($, load, async at => {
480 await committedAt($, load, at, current =>
481 afterPrompt(current, { kind: 'submit', origin }),
482 )
483 })
484 return next(e)
485 }).catch(($, e, next) => next(e))
486
487 // The board widget failed: it stops being drawn, and the keys go back.
488 on('ui.fault', ($, e, next) => {
489 observe($, load, () => faulted($, load))
490 return next(e)
491 }).catch(($, e, next) => next(e))
492
493 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
494 // Nothing of the mod is drawn until this load has restored the session's
495 // band, nor once a fault stopped it, even if that couldn't be written.
496 if (
497 (e.surface !== 'terminal' && e.surface !== 'desktop') ||
498 !load.isRestored ||
499 load.isFaulted
500 ) {
501 return next(e)
502 }
503 const current = modelOf(await read($, model))
504 // Claude Code emptied the session's state: drawing can't write, so the
505 // restore runs right after, asked for once until one lands, and Claude
506 // Code draws its own band meanwhile.
507 if (!isDealt(current.game)) {
508 if (!load.isRestoreWaiting) {
509 load.isRestoreWaiting = true
510 $.clock.after(0, () => observe($, load, () => Promise.resolve()))
511 }
512 return next(e)
513 }
514 const view = bandOf(current, await read($, now), {
515 columns: e.props.bodyColumns,
516 maxRows: e.props.maxRows,
517 isFullscreen: e.viewport?.isFullscreen,
518 hasSurvey: e.props.hasSurvey,
519 })
520 // Drawing can't write; a board that held the keys but isn't drawn is
521 // handed back right after, once per board.
522 const epoch = handoffOf(current, view)
523 if (epoch !== null && load.handoffWaiting !== epoch) {
524 load.handoffWaiting = epoch
525 $.clock.after(0, () => {
526 observe($, load, async at => {
527 load.handoffWaiting = undefined
528 await commit($, latest => afterHandoff(latest, epoch))
529 await settled($, load, at)
530 })
531 })
532 }
533 return view.kind === 'none'
534 ? next(e)
535 : bandTree($.ui.resolve(e) as Parameters<typeof bandTree>[0], view)
536 }).catch(($, e, next) => {
537 // The band's own drawing failed: Claude Code draws its own, and the board
538 // stops, right after. A failure beneath, once handed on, is not ours, nor
539 // is a re-entry (read as a string: older Claude Code types list the kinds
540 // differently).
541 if (!next.called && String(next.error.kind) !== 're-entry') {
542 $.clock.after(0, () => {
543 observe($, load, () => faulted($, load))
544 })
545 }
546 return next(e)
547 })
548}
549hooks/band.tsx 144 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { BOARD_COLUMNS, SIDE_GAP } from './logic'
4import type {
5 BandView,
6 CardView,
7 MinimizedView,
8 ModeView,
9 StatusView,
10} from './logic'
11
12/** The elements the band draws with, from the surface's table. */
13export type BandElements = Pick<
14 ElementTable<'terminal'>,
15 'Box' | 'Text' | 'Client'
16>
17
18/**
19 * Draws the band's view as it is: the board beside the card or the live
20 * view, or above it when narrow, and the mode row at the bottom, unless it
21 * has nothing to say. Each text is one row of its own, cut at the band's
22 * width, so the band is never taller than its layout. Nothing is decided here.
23 */
24export function bandTree(
25 ui: BandElements,
26 view: Exclude<BandView, { kind: 'none' }>,
27): RenderElement {
28 const { Box, Text } = ui
29 if (view.kind === 'minimized') {
30 return (
31 <Text wrap="truncate-end">
32 {headTree(ui, view.tone, view.head)}
33 <Text dimColor>{view.tail}</Text>
34 </Text>
35 )
36 }
37 const { Client } = ui
38 return (
39 <Box flexDirection="column" flexGrow={1}>
40 <Box flexDirection={view.direction} columnGap={SIDE_GAP}>
41 <Client
42 key={view.boardKey}
43 module="./board.tsx"
44 props={{
45 ...view.board,
46 frame: view.frame,
47 count: view.count,
48 }}
49 width={BOARD_COLUMNS}
50 />
51 <Box flexDirection="column" flexGrow={1}>
52 {view.card !== null
53 ? cardTree(ui, view.card)
54 : view.status !== null
55 ? statusTree(ui, view.status)
56 : null}
57 </Box>
58 </Box>
59 {view.mode.line !== '' ? modeTree(ui, view.mode) : null}
60 </Box>
61 )
62}
63
64// The mode row: highlighted while the game has the keys, dimmed otherwise.
65function modeTree(ui: BandElements, mode: ModeView): RenderElement {
66 const { Text } = ui
67 return mode.isActive ? (
68 <Text color="cyan" bold wrap="truncate-end">
69 {mode.line}
70 </Text>
71 ) : (
72 <Text dimColor wrap="truncate-end">
73 {mode.line}
74 </Text>
75 )
76}
77
78function cardTree(ui: BandElements, card: CardView): RenderElement {
79 const { Box, Text } = ui
80 return (
81 <Box flexDirection="column" flexGrow={1}>
82 <Text color="green" bold wrap="truncate-end">
83 {card.heading}
84 </Text>
85 {card.stats !== null ? (
86 <Text dimColor wrap="truncate-end">
87 {card.stats}
88 </Text>
89 ) : null}
90 {card.rows.map(row => (
91 <Text wrap="truncate-end">{row}</Text>
92 ))}
93 {card.files !== null ? (
94 <Text dimColor wrap="truncate-end">
95 {card.files}
96 </Text>
97 ) : null}
98 </Box>
99 )
100}
101
102// A state's first words in its tone: plain while Claude works, the banner
103// while a turn's end fades (Claude Code's warning colour, bold, is for this
104// warning alone), the card's heading, or dim while idle.
105function headTree(
106 ui: BandElements,
107 tone: MinimizedView['tone'],
108 text: string,
109): RenderElement {
110 const { Text } = ui
111 return tone === 'working' ? (
112 <Text>{text}</Text>
113 ) : tone === 'waiting' ? (
114 <Text color="warning" bold>
115 {text}
116 </Text>
117 ) : tone === 'card' ? (
118 <Text color="green" bold>
119 {text}
120 </Text>
121 ) : (
122 <Text dimColor>{text}</Text>
123 )
124}
125
126function statusTree(ui: BandElements, status: StatusView): RenderElement {
127 const { Box, Text } = ui
128 return (
129 <Box flexDirection="column" flexGrow={1}>
130 <Text wrap="truncate-end">
131 {headTree(ui, status.tone, status.headline)}
132 </Text>
133 {status.current.map(line => (
134 <Text wrap="truncate-end">{line}</Text>
135 ))}
136 {status.before.map(line => (
137 <Text dimColor wrap="truncate-end">
138 {line}
139 </Text>
140 ))}
141 </Box>
142 )
143}
144hooks/logic.ts 1670 lines1import type { Activity, Game, Mode, Model, StopReason, Turn } from '../types'
2import { canMove, newGame, played } from './game'
3import type { Move, Random } from './game'
4
5// Types and constants
6
7/**
8 * The shape tag the model is kept under in `$.state`. A value kept under
9 * another tag reads as absent. Bump it only for a change `modelOf` can't read
10 * (an added field keeps the tag and gets its default), and only in a release.
11 */
12export const MODEL_SHAPE = 'model/1'
13
14/** Cells on the 4×4 board. */
15export const CELLS = 16
16
17// More than any real game can hold, and far below where numbers stop being
18// exact: merges never raise the sum, so play stays exact from any board kept.
19const MAX_BOARD_SUM = 2 ** 30
20
21/** A game with no tiles, before the first ones are dealt. */
22export const EMPTY_GAME: Game = frozen({
23 cells: new Array<number>(CELLS).fill(0),
24 score: 0,
25 best: 0,
26 isOver: false,
27 hasWon: false,
28 moves: 0,
29})
30
31/** No turn seen yet. */
32export const IDLE_TURN: Turn = frozen({
33 turnId: '',
34 phase: 'idle',
35 startedAt: 0,
36 endedAt: 0,
37 tools: 0,
38 current: null,
39 recent: [],
40 files: [],
41 reason: '',
42 hasAnswer: false,
43 summary: '',
44 next: '',
45 lockAt: 0,
46 isShown: false,
47})
48
49/** The model of a fresh install: off, idle, keys with the prompt. */
50export const INITIAL_MODEL: Model = frozen({
51 isOn: false,
52 isSeeded: false,
53 turn: IDLE_TURN,
54 mode: 'input',
55 game: EMPTY_GAME,
56 board: { epoch: 0, count: 0 },
57 hasStrayKey: false,
58 isFaulted: false,
59})
60
61/** A value to keep across sessions, written after the model is committed. */
62export type SaveEffect =
63 | { kind: 'save'; key: 'isOn'; value: boolean }
64 | { kind: 'save'; key: 'best'; value: number }
65
66/**
67 * A call to the model for a handed-back turn's summary, exactly as it is to
68 * be sent; its result comes back into `afterSummary` for that turn.
69 */
70export type SummarizeEffect = {
71 kind: 'summarize'
72 turn: SummaryKey
73 request: {
74 model: string
75 system: string
76 prompt: string
77 maxTokens: number
78 timeoutMs: number
79 }
80}
81
82/** Work a transition asks the shell to do once the model is committed. */
83export type Effect = SaveEffect | SummarizeEffect
84
85/** What a transition returns: the next model, and the effects to carry out. */
86export type Step = { model: Model; effects: Effect[] }
87
88const PHASES: readonly Turn['phase'][] = ['idle', 'running', 'done']
89const REASONS: readonly StopReason[] = [
90 'answer',
91 'aborted',
92 'refusal',
93 'error',
94 '',
95]
96const MODES: readonly Mode[] = ['game', 'input']
97
98/** A turn this long or longer fades before it locks; a shorter one locks at once. */
99export const LONG_TURN_MS = 10_000
100
101/** How long a long turn's board stays playable while it fades. */
102export const FADE_MS = 5_000
103
104// The current call and the two before it.
105const RECENT = 3
106// Long enough to read, short enough for one line of the band.
107const TARGET_LENGTH = 72
108// The most characters in each line of a summary. Haiku is asked for under
109// 120, and a line somewhat over that still shows: the card wraps it and gives
110// way for it, and cuts the end only where the panel has no room left.
111const SUMMARY_PART = 200
112// Room for two lines at the cap even in dense text (paths, names, numbers,
113// about 3 characters a token), so an answer cut off by the budget is always
114// longer than two such lines, and refused rather than shown cut.
115const SUMMARY_TOKENS = 250
116// The reply the summary is made from: whole up to this many characters, and
117// past it its beginning and its end, where it says what was done, what was
118// found and what is asked.
119const SUMMARY_WHOLE = 8000
120const SUMMARY_HEAD = 2000
121// A shorter reply is as quick to read as its summary would be, and a summary
122// of it could only restate it or add to it: it isn't sent.
123const SUMMARY_MIN = 200
124const SUMMARY_TAIL = 6000
125const SUMMARY_SYSTEM =
126 'You write one- or two-line status notes for a developer glancing back at ' +
127 'their terminal.'
128const SUMMARY_ASK =
129 'In two short lines (about 15 words each, under 200 characters in all), ' +
130 'first give the gist of what was done or found, then what the developer ' +
131 'should do next: review, answer its question, test, decide. Use only what ' +
132 "the reply says: don't add work, changes or results it doesn't mention. " +
133 'There is ' +
134 'always a next step; never say nothing is needed. Plain text, no markdown. ' +
135 'Reply with exactly two lines: the gist on the first, the next step on the ' +
136 'second. Keep each line under 120 characters.'
137const EDIT_TOOLS: readonly string[] = [
138 'Edit',
139 'Write',
140 'MultiEdit',
141 'NotebookEdit',
142]
143
144// Reading state
145
146/**
147 * The model from whatever `$.state` holds. Every field is checked, and one
148 * that doesn't fit falls back to its initial value, so a value written by
149 * older code, or by a forgotten shape bump, can never break the band.
150 */
151export function modelOf(raw: unknown): Model {
152 if (!isRecord(raw)) return INITIAL_MODEL
153 const board = isRecord(raw.board) ? raw.board : {}
154 return {
155 isOn: raw.isOn === true,
156 isSeeded: raw.isSeeded === true,
157 turn: turnOf(raw.turn),
158 mode: oneOf(raw.mode, MODES, INITIAL_MODEL.mode),
159 game: gameOf(raw.game),
160 board: { epoch: countOf(board.epoch), count: countOf(board.count) },
161 hasStrayKey: raw.hasStrayKey === true,
162 isFaulted: raw.isFaulted === true,
163 }
164}
165
166/**
167 * A game from a kept value. A board that isn't 16 tiles, each 0 or a power of
168 * two from 2, or that holds more than any real game could, is dropped for an
169 * empty one; the best score survives if it is a score, and is never lower than
170 * the game's own score. Whether the game is over, or won, comes from the board
171 * itself, whatever was kept.
172 */
173export function gameOf(raw: unknown): Game {
174 if (!isRecord(raw)) return EMPTY_GAME
175 const best = countOf(raw.best)
176 const cells = tilesOf(raw.cells)
177 if (
178 cells === null ||
179 cells.reduce((sum, value) => sum + value, 0) > MAX_BOARD_SUM
180 ) {
181 return { ...EMPTY_GAME, best }
182 }
183 const score = countOf(raw.score)
184 return {
185 cells,
186 score,
187 best: Math.max(best, score),
188 isOver: !canMove(cells),
189 hasWon: raw.hasWon === true || cells.some(value => value >= 2048),
190 moves: countOf(raw.moves),
191 }
192}
193
194/**
195 * The best score to keep: the higher of the kept one and the current one. A
196 * kept value that isn't a score counts as none, so the result is never below
197 * either.
198 */
199export function bestOf(kept: unknown, current: number): number {
200 return Math.max(countOf(kept), countOf(current))
201}
202
203/** What a store read gave: the value kept, or that it couldn't be read. */
204export type StoreRead = { isRead: true; value: unknown } | { isRead: false }
205
206/**
207 * Whether what is stored must be read before this value is written: only for
208 * the best score, which may only rise. On/off is written straight away.
209 */
210export function isReadFirst(effect: SaveEffect): boolean {
211 switch (effect.key) {
212 case 'isOn':
213 return false
214 case 'best':
215 return true
216 }
217}
218
219/**
220 * What to write for a value to keep, given what the store holds now, or null
221 * to write nothing. On/off is this session's own choice and always written.
222 * The best score is written only when it beats what is stored, which another
223 * session may have raised meanwhile, and never over a store that couldn't be
224 * read, where it could lower a higher one.
225 */
226export function writeOf(
227 effect: SaveEffect,
228 stored: StoreRead,
229): SaveEffect | null {
230 switch (effect.key) {
231 case 'isOn':
232 return effect
233 case 'best': {
234 if (!stored.isRead) return null
235 const value = bestOf(stored.value, effect.value)
236 return value === stored.value ? null : { ...effect, value }
237 }
238 }
239}
240
241// On and off
242
243/**
244 * `/fortywait`: switches the band on or off and keeps the choice for later
245 * sessions; the choice is this session's from now on. Either way the keys
246 * belong to the prompt; switching off also retires the board, so a board
247 * drawn before can't take keys again. The band doesn't follow turns while
248 * off, so switching on starts from an idle turn, with no stale card, whose
249 * board waits for the next turn; it also clears a fault.
250 */
251export function afterToggle(model: Model): Step {
252 const isOn = !model.isOn
253 return {
254 model: {
255 ...model,
256 isOn,
257 isSeeded: true,
258 mode: 'input',
259 hasStrayKey: false,
260 board: isOn ? model.board : retired(model.board),
261 turn: isOn ? IDLE_TURN : model.turn,
262 isFaulted: isOn ? false : model.isFaulted,
263 },
264 effects: [{ kind: 'save', key: 'isOn', value: isOn }],
265 }
266}
267
268/**
269 * A session starts, the mod reloads, or Claude Code emptied the session's
270 * state (see `isDealt`). The session's own on/off stands; only a session that
271 * has none yet takes it from the store, on if it was last switched on. The
272 * best score is the higher of the kept one and the model's, and a best the
273 * store doesn't have yet (its save failed) is saved again.
274 * A session with no game yet gets a new one; a reload keeps the game as it
275 * was, with no new tile. A reload also drops any board that held the keys, so
276 * the keys are the prompt's and the board is retired, and new code gets a
277 * fresh chance after a fault; the turn, and any lock, carry on.
278 */
279export function afterRestore(
280 model: Model,
281 kept: { isOn: unknown; best: unknown },
282 random: Random,
283): Step {
284 const best = bestOf(kept.best, model.game.best)
285 return {
286 model: {
287 ...model,
288 isOn: model.isSeeded ? model.isOn : kept.isOn === true,
289 isSeeded: true,
290 mode: 'input',
291 hasStrayKey: false,
292 isFaulted: false,
293 board: retired(model.board),
294 game: dealt({ ...model.game, best }, random),
295 },
296 effects:
297 best > bestOf(kept.best, 0)
298 ? [{ kind: 'save', key: 'best', value: best }]
299 : [],
300 }
301}
302
303/**
304 * Whether the game has its tiles. Every restore deals, and nothing after it
305 * takes the last tile away, so a board without tiles, once restored, means
306 * Claude Code emptied the session's state: a `/clear` or a resume inside
307 * Claude Code, which no session start follows.
308 */
309export function isDealt(game: Game): boolean {
310 return game.cells.some(value => value !== 0)
311}
312
313/** What `/fortywait` answers. */
314export function toggleNoteOf(model: Model): string {
315 return model.isOn
316 ? 'Twenty Forty-Wait on. Run /fortywait again to turn it off.'
317 : 'Twenty Forty-Wait off.'
318}
319
320// Turn and hand-back
321
322/** A turn of the main loop starting, as the shell saw it. */
323export type TurnStart = { turnId: string; at: number; agentId?: string }
324
325/** A tool call, from the main loop or a subagent, as the shell saw it. */
326export type ToolCall = {
327 tool: string
328 input: unknown
329 at: number
330 agentId?: string
331}
332
333/** Which turn a tool call ran in: its id and when it started. */
334export type TurnKey = { turnId: string; startedAt: number }
335
336/**
337 * A tool call that has returned: whether it succeeded, and the turn it was
338 * called in (null if none was running).
339 */
340export type ToolDone = {
341 tool: string
342 input: unknown
343 isOk: boolean
344 turn: TurnKey | null
345}
346
347/** A turn ending, as the shell saw it. */
348export type TurnEnd = {
349 turnId: string
350 reason: string
351 answer: string
352 durationMs: number
353 at: number
354 agentId?: string
355}
356
357/**
358 * A turn of the main loop starts, the person's or an automated one: the band
359 * shows it live, and any lock lifts. A lock that came due before the start is
360 * handed back first. The mode stays as it was otherwise. A repeated start of
361 * the running turn, subagent turns, and turns while the band is off change
362 * nothing.
363 */
364export function afterTurnStart(model: Model, start: TurnStart): Step {
365 if (
366 !model.isOn ||
367 start.agentId !== undefined ||
368 (model.turn.phase === 'running' && model.turn.turnId === start.turnId)
369 ) {
370 return unchanged(model)
371 }
372 const at = timeOf(start.at)
373 return {
374 model: {
375 ...locked(model, at),
376 turn: {
377 ...IDLE_TURN,
378 turnId: start.turnId,
379 phase: 'running',
380 startedAt: at,
381 isShown: true,
382 },
383 },
384 effects: [],
385 }
386}
387
388/**
389 * A tool call while a turn runs: it becomes the current call, the two before
390 * it stay in view, and the count goes up. Subagents' calls count as the
391 * turn's work. A main-loop call with no turn seen (the band was switched on
392 * mid-turn) starts following the turn from here; a subagent's never does,
393 * since it may outlive the turn.
394 */
395export function afterToolCall(model: Model, call: ToolCall): Step {
396 const isAdopted = model.turn.phase === 'idle' && call.agentId === undefined
397 if (!model.isOn || (model.turn.phase !== 'running' && !isAdopted)) {
398 return unchanged(model)
399 }
400 const activity = callOf(call.tool, call.input)
401 const turn = isAdopted
402 ? {
403 ...IDLE_TURN,
404 phase: 'running' as const,
405 startedAt: timeOf(call.at),
406 isShown: true,
407 }
408 : model.turn
409 return {
410 model: {
411 ...model,
412 turn: {
413 ...turn,
414 tools: turn.tools + 1,
415 current: activity,
416 recent: [...turn.recent, activity].slice(-RECENT),
417 },
418 },
419 effects: [],
420 }
421}
422
423/**
424 * Whether a tool call's result is a success: not refused and not an error.
425 */
426export function toolSucceeded(result: unknown): boolean {
427 return (
428 isRecord(result) && result.deny === undefined && result.isError !== true
429 )
430}
431
432/** The running turn's key, which a tool call keeps to report back to; null when none runs. */
433export function turnKeyOf(model: Model): TurnKey | null {
434 return model.isOn && model.turn.phase === 'running'
435 ? { turnId: model.turn.turnId, startedAt: model.turn.startedAt }
436 : null
437}
438
439/**
440 * A tool call returned. A file counts as changed only once an edit to it has
441 * succeeded, each file counts once, and only for the turn the call ran in:
442 * a call that outlives its turn changes nothing.
443 */
444export function afterToolDone(model: Model, done: ToolDone): Step {
445 const input = isRecord(done.input) ? done.input : {}
446 const path = input.file_path ?? input.notebook_path
447 const running = turnKeyOf(model)
448 if (
449 running === null ||
450 done.turn === null ||
451 done.turn.turnId !== running.turnId ||
452 done.turn.startedAt !== running.startedAt ||
453 !done.isOk ||
454 !EDIT_TOOLS.includes(done.tool) ||
455 typeof path !== 'string' ||
456 model.turn.files.includes(path)
457 ) {
458 return unchanged(model)
459 }
460 return {
461 model: {
462 ...model,
463 turn: { ...model.turn, files: [...model.turn.files, path] },
464 },
465 effects: [],
466 }
467}
468
469/**
470 * The main loop's turn ends, for any reason: the turn is handed back. A turn of
471 * 10 s or longer that ended on its own while the game has the keys, and isn't
472 * over, fades for 5 s, the game still playable, and then locks; any other turn
473 * locks at once (see `isLocked`): a shorter or interrupted one, one that ends
474 * in input mode or on a finished game. At the lock the keys go to the prompt
475 * and the board is retired. The turn's length is the longer of what the band
476 * saw and what Claude Code reports, so a turn followed from mid-way is timed
477 * whole. A long turn that ended normally with a reply asks the model, once, for
478 * the card's summary. The late end of an older turn (one that began before the
479 * turn handed back ended), a repeated end, subagent turns, and turns while the
480 * band is off change nothing; a turn that began after, whose start went unseen,
481 * is handed back as usual, after any lock that came due before it.
482 */
483export function afterTurnEnd(model: Model, end: TurnEnd): Step {
484 const isRunning = model.turn.phase === 'running'
485 if (
486 !model.isOn ||
487 end.agentId !== undefined ||
488 (isRunning &&
489 model.turn.turnId !== '' &&
490 model.turn.turnId !== end.turnId) ||
491 (model.turn.phase === 'done' &&
492 (end.turnId === model.turn.turnId ||
493 timeOf(end.at) - timeOf(end.durationMs) < model.turn.endedAt))
494 ) {
495 return unchanged(model)
496 }
497 const at = timeOf(end.at)
498 const length = Math.max(
499 isRunning ? at - model.turn.startedAt : 0,
500 timeOf(end.durationMs),
501 )
502 const reason = oneOf(end.reason, REASONS, '')
503 const isLong = length >= LONG_TURN_MS && reason !== 'aborted'
504 // A lock that came due before this end is handed back first, so a fade
505 // whose lock wasn't committed yet never carries the game into another.
506 const due = locked(model, at)
507 const ended: Model = {
508 ...due,
509 turn: {
510 ...due.turn,
511 turnId: end.turnId,
512 phase: 'done',
513 startedAt: Math.max(0, at - length),
514 endedAt: at,
515 current: null,
516 reason,
517 hasAnswer: end.answer.trim().length > 0,
518 summary: '',
519 next: '',
520 lockAt: isLong ? at + FADE_MS : at,
521 isShown: true,
522 },
523 }
524 // Only a turn that ended normally with a reply long enough to need one: for
525 // any other stop the reason is the news, and its placeholder already says
526 // what to do. The reply is counted as the request reads it, in code points.
527 const isSummarized =
528 !model.isFaulted &&
529 isLong &&
530 reason === 'answer' &&
531 Array.from(end.answer.trim()).length >= SUMMARY_MIN
532 return {
533 model: locked(ended, at),
534 effects: isSummarized
535 ? [
536 {
537 kind: 'summarize',
538 turn: { turnId: end.turnId, endedAt: at },
539 request: summaryRequestOf(end.answer),
540 },
541 ]
542 : [],
543 }
544}
545
546/**
547 * Time passes: once a fading turn's lock is due, the lock takes effect and
548 * holds from then on, and a board that had the keys gives them to the prompt.
549 */
550export function afterTick(model: Model, now: number): Step {
551 return { model: locked(model, timeOf(now)), effects: [] }
552}
553
554/**
555 * Whether the board is locked behind the your-turn card: a turn handed back,
556 * once its fade is over, and at once while the game doesn't have the keys or
557 * is over. The fade is only for finishing a move, so a turn that ends in
558 * input mode or on a finished game locks as it ends, and leaving the game, or
559 * the move that ends it, ends the fade.
560 */
561export function isLocked(model: Model, now: number): boolean {
562 return (
563 model.isOn &&
564 model.turn.phase === 'done' &&
565 model.turn.isShown &&
566 (timeOf(now) >= model.turn.lockAt ||
567 model.mode !== 'game' ||
568 model.game.isOver)
569 )
570}
571
572/**
573 * Whether the board can be played: while a turn runs, and while a turn's end
574 * fades with the game still on. Idle, before any turn or since the band was
575 * switched on, and once locked, it can't be entered: the game is for the
576 * wait, and Claude isn't working.
577 */
578export function isPlayable(model: Model, now: number): boolean {
579 return (
580 model.isOn &&
581 (model.turn.phase === 'running' ||
582 (model.turn.phase === 'done' &&
583 model.turn.isShown &&
584 !isLocked(model, now)))
585 )
586}
587
588/**
589 * How far the board has faded, from 0 to 1: 0 while a turn runs, rising over
590 * a turn's end in the game, and 1 while the board can't be played, idle or
591 * locked. Off, nothing is drawn, and it is 0.
592 */
593export function fadeOf(model: Model, now: number): number {
594 if (!model.isOn || model.turn.phase === 'running') return 0
595 if (!isPlayable(model, now)) return 1
596 return Math.min(
597 1,
598 Math.max(0, 1 - (model.turn.lockAt - timeOf(now)) / FADE_MS),
599 )
600}
601
602/**
603 * Whether the band needs the clock: while the board can be played, a turn
604 * runs or its end fades in the game. A faulted band draws nothing and needs
605 * none.
606 */
607export function isTicking(model: Model, now: number): boolean {
608 return !model.isFaulted && isPlayable(model, now)
609}
610
611/** Whether the band shows the time at all, so time is worth keeping: on, and not stopped by a fault. */
612export function isTimeShown(model: Model): boolean {
613 return model.isOn && !model.isFaulted
614}
615
616// Lifecycle
617
618/**
619 * Claude Code shows a permission prompt or a question: the dialog takes the
620 * keys, so they leave the board and the board is retired. While a turn runs
621 * nothing locks, and the turn's end hands back as usual; during a turn's end,
622 * leaving the game ends the fade (see `isLocked`).
623 */
624export function afterDialog(model: Model): Step {
625 if (!model.isOn) return unchanged(model)
626 return { model: toPrompt(model), effects: [] }
627}
628
629/**
630 * A part of the mod failed: the band stops drawing the board, the keys go to
631 * the prompt and the board is retired, so nothing can keep them.
632 */
633export function afterFault(model: Model): Step {
634 return { model: { ...toPrompt(model), isFaulted: true }, effects: [] }
635}
636
637/**
638 * The band found, while drawing, that the board can't keep the keys (it has
639 * no room, or a survey took the band) and drew it out. If the game still held
640 * the keys under the board it saw, they go to the prompt and that board is
641 * retired; otherwise the request is stale and changes nothing.
642 */
643export function afterHandoff(model: Model, epochSeen: number): Step {
644 return model.mode === 'game' && model.board.epoch === epochSeen
645 ? { model: toPrompt(model), effects: [] }
646 : unchanged(model)
647}
648
649// Mode and board input
650
651/** The board widget's key under the current epoch; input counts only from it. */
652export function boardKeyOf(model: Model): string {
653 return `board-${model.board.epoch}`
654}
655
656/** One input the board saw: a click on it, or a key while it had the keys. */
657export type BoardInput =
658 | { kind: 'click' }
659 | { kind: 'key'; key: string; ctrl: boolean; shift: boolean; meta: boolean }
660
661/**
662 * What the board posts: every input not yet acknowledged, oldest first, and
663 * the number of the last one. The board numbers its inputs on from the count
664 * it was last acknowledged, so an input sent again keeps its number and
665 * counts once. An entry that is no input is null: it takes its number and
666 * changes nothing.
667 */
668export type BoardPost = { count: number; inputs: (BoardInput | null)[] }
669
670/** The most inputs the board keeps unacknowledged, and so sends in one post. */
671export const BOARD_BACKLOG = 64
672
673/** The post the board sent, or null when the data isn't one. */
674export function boardPostOf(data: unknown): BoardPost | null {
675 if (!isRecord(data) || !Array.isArray(data.inputs)) return null
676 const inputs: unknown[] = data.inputs
677 const count = data.count
678 return typeof count === 'number' &&
679 Number.isSafeInteger(count) &&
680 count >= inputs.length &&
681 inputs.length <= BOARD_BACKLOG
682 ? { count, inputs: Array.from(inputs, inputOf) }
683 : null
684}
685
686/**
687 * The board posted. A lock that came due takes effect first. Only the board
688 * drawn now is heard: a post from a retired board changes nothing, nor does
689 * an input counted before, so a backlog sent again applies once. A post that
690 * starts past the next number lost inputs the board couldn't keep, maybe a
691 * Tab, so the keys go back to the prompt and the board is retired. Inputs
692 * apply in order until one hands the keys back, which retires the board and
693 * drops the rest; otherwise the count acknowledged moves to the post's last.
694 *
695 * - A click enters the game, and a click on a finished game deals a new one,
696 * while the board can be played: a turn runs, or its end fades in the
697 * game. A click on the board otherwise, idle or locked, can't be refused,
698 * since it moves the keys itself, so the board is retired and the keys go
699 * straight back.
700 * - In the game, the arrows play, also while a turn's end fades; Tab hands
701 * the keys back. Any other key, Claude Code's shortcuts included, only
702 * shows how to leave.
703 * - A key while the model says the prompt has the keys means the board got
704 * them unseen: they go back, so the band and the keys agree again.
705 */
706export function afterBoardPost(
707 model: Model,
708 element: string,
709 post: BoardPost,
710 now: number,
711 random: Random,
712): Step {
713 const due = locked(model, now)
714 if (!due.isOn || due.isFaulted || element !== boardKeyOf(due)) {
715 return unchanged(due)
716 }
717 const first = post.count - post.inputs.length + 1
718 if (first > due.board.count + 1) return { model: toPrompt(due), effects: [] }
719 let current = due
720 let effects: Effect[] = []
721 for (const [k, input] of post.inputs.entries()) {
722 if (input === null || first + k <= due.board.count) continue
723 const step = afterInput(current, input, now, random)
724 current = step.model
725 effects = [
726 ...effects.filter(
727 kept =>
728 !step.effects.some(
729 e =>
730 e.kind === 'save' && kept.kind === 'save' && e.key === kept.key,
731 ),
732 ),
733 ...step.effects,
734 ]
735 if (current.board.epoch !== due.board.epoch) {
736 return { model: current, effects }
737 }
738 }
739 return post.count > current.board.count
740 ? {
741 model: { ...current, board: { ...current.board, count: post.count } },
742 effects,
743 }
744 : { model: current, effects }
745}
746
747/** Activity in the prompt box: an edit, or a prompt submitted from somewhere. */
748export type PromptActivity =
749 { kind: 'edit' } | { kind: 'submit'; origin: string }
750
751/**
752 * The person used the prompt box while the model still gives the game the
753 * keys: they left the board in a way Claude Code doesn't report (Esc, a click
754 * on the prompt, Ctrl+C while idle, collapsing the band), and the band shows
755 * input mode from now on; during a turn's end that also ends the fade. The
756 * board isn't retired: the keys already left it, and a click on it right
757 * after must still count while a turn runs. A prompt from anywhere but
758 * the person's own typing (a notification, a peer, a plugin) says nothing
759 * about the keys.
760 */
761export function afterPrompt(model: Model, activity: PromptActivity): Step {
762 const isTyped = activity.kind === 'edit' || activity.origin === 'composer'
763 if (model.mode !== 'game' || !isTyped) return unchanged(model)
764 return {
765 model: { ...model, mode: 'input', hasStrayKey: false },
766 effects: [],
767 }
768}
769
770// Card text
771
772/** The model's summary for the card: what Claude did, and the next step. */
773export type Summary = { line: string; next: string }
774
775/**
776 * Which handed-back turn a summary is for: its id and when it ended, so a
777 * summary never lands on another turn, even one that reuses the id.
778 */
779export type SummaryKey = { turnId: string; endedAt: number }
780
781/**
782 * What the model is asked for a summary: its instructions, and Claude's final
783 * reply, nothing else. A reply of up to 8,000 characters goes whole; a longer
784 * one as its first 2,000 and last 6,000, with a mark where its middle is left
785 * out, so nothing is sent twice. It is cut between characters, never inside
786 * one. The token budget holds two lines at the cap even in dense text, so an
787 * answer the budget cut off is always too long for the card to accept.
788 */
789export function summaryRequestOf(answer: string): SummarizeEffect['request'] {
790 const characters = Array.from(answer.trim())
791 const reply =
792 characters.length <= SUMMARY_WHOLE
793 ? `The assistant's final reply:\n\n${characters.join('')}`
794 : "The beginning and the end of the assistant's final reply, with its " +
795 `middle left out:\n\n${characters.slice(0, SUMMARY_HEAD).join('')}` +
796 `\n\n[… the middle of the reply is left out …]\n\n` +
797 characters.slice(-SUMMARY_TAIL).join('')
798 return {
799 model: 'haiku',
800 system: SUMMARY_SYSTEM,
801 prompt: `${SUMMARY_ASK}\n\n${reply}`,
802 maxTokens: SUMMARY_TOKENS,
803 timeoutMs: 20_000,
804 }
805}
806
807/**
808 * A summary from the model, fit for the card: answered, and exactly two
809 * lines, the gist and the next step, each non-empty and at most 200
810 * characters, for the card to wrap. Its words are not judged. Markdown
811 * emphasis, list marks (arrows too) and labels such as "Gist:" or "Next:"
812 * are dropped.
813 * Otherwise null, and the card shows the placeholder.
814 */
815export function summaryOf(result: unknown): Summary | null {
816 if (!isRecord(result) || result.isAnswered !== true) return null
817 const lines = textOf(result.text)
818 .split(/\r?\n/)
819 .map(line =>
820 line
821 .replace(/\*\*|__|`/g, '')
822 .replace(/^\s*(?:#+|[-*•→]|->|\d+[.)])\s+/, '')
823 .replace(
824 /^\s*(?:summary|gist|done|did|result|what (?:claude )?did|next(?:\s+step)?)\s*:\s*/i,
825 '',
826 )
827 .replace(/\s+/g, ' ')
828 .trim(),
829 )
830 .filter(line => line.length > 0)
831 const [line = '', next = ''] = lines
832 return lines.length !== 2 ||
833 Array.from(line).length > SUMMARY_PART ||
834 Array.from(next).length > SUMMARY_PART
835 ? null
836 : { line, next }
837}
838
839/**
840 * The model's summary arrives for a turn. It is shown only if it is a usable
841 * summary, the band is on, and that turn, by its id and end, is still the one
842 * handed back, so a late summary never lands on another turn's card.
843 */
844export function afterSummary(
845 model: Model,
846 turn: SummaryKey,
847 result: unknown,
848): Step {
849 const summary = summaryOf(result)
850 return summary !== null &&
851 model.isOn &&
852 model.turn.phase === 'done' &&
853 model.turn.turnId === turn.turnId &&
854 model.turn.endedAt === turn.endedAt
855 ? {
856 model: {
857 ...model,
858 turn: { ...model.turn, summary: summary.line, next: summary.next },
859 },
860 effects: [],
861 }
862 : unchanged(model)
863}
864
865/**
866 * The card's line when there is no summary: a fixed sentence for how the turn
867 * ended, always ending in the next step. Claude's answer itself never shows.
868 */
869export function placeholderOf(turn: Turn): string {
870 if (turn.reason === 'aborted') return 'Interrupted — your turn.'
871 if (turn.reason === 'refusal') {
872 return 'Claude declined — your turn: read why and rephrase.'
873 }
874 if (turn.reason === 'error') {
875 return 'Stopped on an error — your turn: check the transcript.'
876 }
877 return turn.reason === 'answer' && turn.hasAnswer
878 ? 'Claude finished — your turn: read the reply.'
879 : 'Stopped without a reply — your turn.'
880}
881
882// View models
883
884/**
885 * The card shown when the turn is handed back, row by row: the heading, the
886 * stats, the summary as one paragraph and the files changed. When the
887 * paragraph needs more rows than are left, the files give way first, then
888 * the stats join the heading, so the card never takes more rows than it has.
889 */
890export type CardView = {
891 heading: string
892 /** The stats row, or null once the stats joined the heading. */
893 stats: string | null
894 /** The paragraph, whole, and as drawn: word-wrapped to its rows. */
895 text: string
896 rows: string[]
897 /** The files changed, or null once their row gave way. */
898 files: string | null
899}
900
901/** Where the card or the live view is drawn: its width, and the rows it has. */
902export type SideRoom = { width: number; rows: number }
903
904// A side of any width, and a card's usual rows: the band cuts each row.
905const WIDE: SideRoom = { width: Number.POSITIVE_INFINITY, rows: 5 }
906
907/**
908 * The live view of a turn, or the idle line before any turn. Its tone says
909 * how the first row is drawn: plain while Claude works, as a banner while a
910 * turn's end fades (the only time the band warns), and dim while idle. Then
911 * come the rows that matter now, and dimmer ones under them.
912 */
913export type StatusView = {
914 tone: 'working' | 'waiting' | 'idle'
915 headline: string
916 current: string[]
917 before: string[]
918}
919
920/**
921 * Where the band is drawn: its body columns, the rows the engine lets it take,
922 * whether the terminal is fullscreen (unknown counts as not), and whether a
923 * Claude Code survey holds the band.
924 */
925export type Room = {
926 columns: number
927 maxRows: number
928 isFullscreen?: boolean
929 hasSurvey?: boolean
930}
931
932/**
933 * The band's bottom row: where the keys go and how to switch, or an empty
934 * line when the card already says it, and then the row isn't drawn. This
935 * row is highlighted while the game has the keys, and dimmed otherwise; the
936 * tiles never dim for the mode, only for the turn: dimming step by step over
937 * a turn's end, and grey while the board can't be played, idle or locked.
938 */
939export type ModeView = { line: string; isActive: boolean }
940
941/**
942 * How the board's frame is drawn: highlighted while the model gives the game
943 * the keys, the fade's warning while a turn's end fades with the game on, and
944 * dim otherwise. Like the mode row, it follows the mode the model knows, so
945 * after Esc or a prompt click it changes at the first edit or send.
946 */
947export type Frame = 'game' | 'fade' | 'dim'
948
949/**
950 * The minimized band: one line whose head shows the state, drawn in its
951 * tone as the full band draws it (the live view's first row, or the card's
952 * heading), and a tail that says the rest needs more rows, then, on the
953 * card's line, its paragraph.
954 */
955export type MinimizedView = {
956 kind: 'minimized'
957 tone: StatusView['tone'] | 'card'
958 head: string
959 tail: string
960}
961
962/**
963 * What the band draws: nothing, one minimized line, the full band with the
964 * board.
965 */
966export type BandView =
967 | { kind: 'none' }
968 | MinimizedView
969 | {
970 kind: 'full'
971 direction: 'row' | 'column'
972 status: StatusView | null
973 card: CardView | null
974 board: BoardView
975 /** The board widget's key, and the count of its inputs acknowledged. */
976 boardKey: string
977 count: number
978 frame: Frame
979 mode: ModeView
980 }
981
982/** Body columns from which the board and the activity sit side by side. */
983export const ROW_COLUMNS = 64
984
985/** Columns between the board and the card or live view beside it. */
986export const SIDE_GAP = 2
987
988/** Columns the board takes: four 6-column tiles, a column between each, and its frame. */
989export const BOARD_COLUMNS = 29
990
991// Rows each part of the band takes, as the band draws them: the board in its
992// frame with its caption row, the card or the live view, and the mode row,
993// the band's bottom row.
994const BOARD_ROWS = 7
995const SIDE_ROWS = 5
996const MODE_ROWS = 1
997
998// What the one-line view says after the state: how to see the rest.
999const ONE_LINE_HINT = ' · more rows show the game'
1000
1001/**
1002 * The rows the full band takes: the board beside the card or live view, or
1003 * above it when narrow, and the mode row under both. Behind the card the band
1004 * draws no mode row and takes a row fewer, but the count keeps it, so the band
1005 * doesn't turn to one line and back as the card comes and goes.
1006 */
1007export function bandRowsOf(direction: 'row' | 'column'): number {
1008 const body =
1009 direction === 'row'
1010 ? Math.max(BOARD_ROWS, SIDE_ROWS)
1011 : BOARD_ROWS + SIDE_ROWS
1012 return body + MODE_ROWS
1013}
1014
1015/**
1016 * The card for a turn handed back: how long it took and what it did, then
1017 * the summary as one paragraph, word-wrapped over the rows it needs in the
1018 * room's width, so it reads in full. Only when the paragraph needs more rows
1019 * than the heading, the stats and the files leave do the files give way, and
1020 * then the stats join the heading in short; where even that isn't enough,
1021 * the paragraph ends where the band cuts it.
1022 */
1023export function cardOf(turn: Turn, room: SideRoom = WIDE): CardView {
1024 const count = turn.files.length
1025 const elapsed = elapsedText(turn.endedAt - turn.startedAt)
1026 const text = paragraphOf(turn)
1027 const needed = rowsOf(text, room.width)
1028 const isFilesShown = needed <= room.rows - 3
1029 const isStatsJoined = needed > room.rows - 2
1030 const rows = wrapped(text, {
1031 width: room.width,
1032 rows: Math.max(1, room.rows - (isStatsJoined ? 1 : isFilesShown ? 3 : 2)),
1033 })
1034 return {
1035 heading: isStatsJoined
1036 ? `✓ YOUR TURN · ${elapsed} · ${plural(turn.tools, 'tool')} · ${plural(count, 'file')}`
1037 : '✓ YOUR TURN',
1038 stats: isStatsJoined
1039 ? null
1040 : `${elapsed} · ${plural(turn.tools, 'tool call')} · ${plural(count, 'file')} changed`,
1041 text,
1042 rows,
1043 files: isFilesShown
1044 ? turn.files.map(path => oneLine(shortPath(path))).join(' ')
1045 : null,
1046 }
1047}
1048
1049/**
1050 * The summary as one plain paragraph: the gist, then the next step, each a
1051 * sentence of its own; or, with no summary, the placeholder for how the turn
1052 * ended, whole.
1053 */
1054export function paragraphOf(turn: Turn): string {
1055 return turn.summary.length > 0
1056 ? `${sentenceOf(turn.summary)} ${sentenceOf(turn.next)}`
1057 : placeholderOf(turn)
1058}
1059
1060// A sentence: its first letter capitalized, after any opening quote or
1061// bracket, and a full stop unless it already ends in one, a question or
1062// exclamation mark or an ellipsis (closing quotes or brackets after it
1063// included).
1064function sentenceOf(text: string): string {
1065 const sentence = text.replace(
1066 /^([\p{Ps}\p{Pi}"']*)(\p{Ll})/u,
1067 (_, opening: string, letter: string) => opening + letter.toUpperCase(),
1068 )
1069 return /[.!?…][\p{Pe}\p{Pf}"']*$/u.test(sentence) ? sentence : `${sentence}.`
1070}
1071
1072/**
1073 * The live view: idle before any turn, the running turn's work, or the wait
1074 * before a lock, its banner in the words that fit the room's width.
1075 */
1076export function statusOf(
1077 model: Model,
1078 now: number,
1079 room: SideRoom = WIDE,
1080): StatusView {
1081 const turn = model.turn
1082 if (turn.phase === 'running') {
1083 return {
1084 tone: 'working',
1085 headline: `● Claude is working · ${elapsedText(timeOf(now) - turn.startedAt)} · ${plural(turn.tools, 'tool call')}`,
1086 current: [
1087 turn.current === null
1088 ? 'thinking…'
1089 : `${turn.current.tool} ${turn.current.target}`.trimEnd(),
1090 ],
1091 before: turn.recent
1092 .slice(0, -1)
1093 .slice(-(RECENT - 1))
1094 .map(call => `${call.tool} ${call.target}`.trimEnd()),
1095 }
1096 }
1097 if (turn.phase === 'done' && turn.isShown) {
1098 const left = Math.max(1, Math.ceil((turn.lockAt - timeOf(now)) / 1000))
1099 // Under the banner, the paragraph wraps as on the card, over the rest.
1100 return {
1101 tone: 'waiting',
1102 headline: bannerOf(left, room.width),
1103 current: wrapped(paragraphOf(turn), {
1104 width: room.width,
1105 rows: Math.max(1, room.rows - 1),
1106 }),
1107 before: [],
1108 }
1109 }
1110 return {
1111 tone: 'idle',
1112 headline: "No activity yet · Claude's work shows here",
1113 current: [],
1114 before: [],
1115 }
1116}
1117
1118/**
1119 * The band for this moment and this room. Off, after a fault, or while a
1120 * survey holds the band, it draws nothing. With room it
1121 * draws the board beside the live view or the card, or above it when narrow;
1122 * without room the whole band shrinks to one line.
1123 */
1124export function bandOf(model: Model, now: number, room: Room): BandView {
1125 if (!model.isOn || model.isFaulted || room.hasSurvey === true) {
1126 return { kind: 'none' }
1127 }
1128 const isCardShown = isLocked(model, now)
1129 const direction = room.columns >= ROW_COLUMNS ? 'row' : 'column'
1130 const isRoomy =
1131 fits(bandRowsOf(direction), room) && room.columns >= BOARD_COLUMNS
1132 if (!isRoomy) {
1133 // The hint comes right after the state, so the band cuts what follows it
1134 // rather than the hint. The fade's banner takes the form that leaves the
1135 // hint its room, and the card's paragraph follows only where there is a
1136 // column past the hint, so the cut's ellipsis never lands on it. Below
1137 // 37 columns, and on the working and idle lines, whose state is fixed,
1138 // the hint can still be cut.
1139 const status = statusOf(model, now, {
1140 width: room.columns - ONE_LINE_HINT.length,
1141 rows: SIDE_ROWS,
1142 })
1143 const heading = '✓ YOUR TURN'
1144 return isCardShown
1145 ? {
1146 kind: 'minimized',
1147 tone: 'card',
1148 head: heading,
1149 tail:
1150 room.columns > heading.length + ONE_LINE_HINT.length
1151 ? `${ONE_LINE_HINT} · ${paragraphOf(model.turn)}`
1152 : ONE_LINE_HINT,
1153 }
1154 : {
1155 kind: 'minimized',
1156 tone: status.tone,
1157 head: status.headline,
1158 tail: ONE_LINE_HINT,
1159 }
1160 }
1161 // Beside the board the card has the board's rows, so the board and the
1162 // card keep their height; under it, the card's own, at the band's width.
1163 const side: SideRoom =
1164 direction === 'row'
1165 ? { width: room.columns - BOARD_COLUMNS - SIDE_GAP, rows: BOARD_ROWS }
1166 : { width: room.columns, rows: SIDE_ROWS }
1167 return {
1168 kind: 'full',
1169 direction,
1170 status: isCardShown ? null : statusOf(model, now, side),
1171 card: isCardShown ? cardOf(model.turn, side) : null,
1172 board: boardOf(model.game, fadeOf(model, now)),
1173 boardKey: boardKeyOf(model),
1174 count: model.board.count,
1175 frame: frameOf(model, now),
1176 mode: modeOf(model, now),
1177 }
1178}
1179
1180/** The board's frame: the game's while it has the keys, the fade's while a turn's end fades in the game, dim otherwise. */
1181export function frameOf(model: Model, now: number): Frame {
1182 if (model.mode !== 'game' || !isPlayable(model, now)) return 'dim'
1183 return model.turn.phase === 'done' ? 'fade' : 'game'
1184}
1185
1186/**
1187 * The mode row: when the board can't be played, none behind the card, which
1188 * already says it's your turn, or that the board waits for Claude while idle;
1189 * in the game, the move to finish during a turn's end, which already says how
1190 * to leave, and otherwise the arrows, or how to leave after a stray key; or
1191 * the prompt, where a finished game says a click deals a new one.
1192 */
1193export function modeOf(model: Model, now: number): ModeView {
1194 if (!isPlayable(model, now)) {
1195 return {
1196 line: isLocked(model, now) ? '' : '○ playable while Claude works',
1197 isActive: false,
1198 }
1199 }
1200 if (model.mode !== 'game') {hooks/board.tsx 107 lines1import type { ClientModule, ClientSurface, JsonValue } from 'claude-code'
2
3import { BOARD_BACKLOG } from './logic'
4import type { BoardProps, Tile } from './logic'
5
6// The inputs not yet acknowledged, each with its number, and the last number
7// given. Dropped with the instance; a new one numbers on from the count the
8// model acknowledged, which its props carry.
9type Local = { last: number; backlog: { number: number; input: JsonValue }[] }
10
11const TILE_WIDTH = 6
12
13/**
14 * The 2048 board in the band: its tiles in a frame, highlighted while the
15 * game has the keys, in Claude Code's warning colour while a turn's end fades
16 * with the game on, and the scores under it. It draws its props and posts
17 * every click and key it gets; the hooks decide what they mean.
18 *
19 * Each input gets the next number and is posted at once together with every
20 * input before it the model hasn't acknowledged yet, so an input lost
21 * between frames arrives with the next one, and one sent twice counts once.
22 */
23const Board: ClientModule<BoardProps, Local> = (props, surface) => {
24 // Set again on each call, so an input is numbered and trimmed against the
25 // count acknowledged in the latest props.
26 surface.onKey(event => {
27 sent(surface, props.count, {
28 kind: 'key',
29 key: event.key,
30 ...(event.ctrl === true ? { ctrl: true } : {}),
31 ...(event.shift === true ? { shift: true } : {}),
32 ...(event.meta === true ? { meta: true } : {}),
33 })
34 })
35 surface.onPointer(event => {
36 if (event.type === 'down' && event.button === 'left') {
37 sent(surface, props.count, { kind: 'click' })
38 }
39 })
40 const { Box, Text } = surface.elements
41 const rows = [0, 1, 2, 3].map(row => (
42 <Text wrap="truncate-end">
43 {props.tiles.slice(row * 4, row * 4 + 4).flatMap((tile, column) => {
44 const drawn = tileTree(surface, tile)
45 return column === 0 ? [drawn] : [<Text> </Text>, drawn]
46 })}
47 </Text>
48 ))
49 return (
50 <Box flexDirection="column">
51 {props.frame === 'game' ? (
52 <Box flexDirection="column" borderStyle="round" borderColor="cyan">
53 {rows}
54 </Box>
55 ) : props.frame === 'fade' ? (
56 <Box flexDirection="column" borderStyle="round" borderColor="warning">
57 {rows}
58 </Box>
59 ) : (
60 <Box flexDirection="column" borderStyle="round" borderDimColor>
61 {rows}
62 </Box>
63 )}
64 <Text dimColor wrap="truncate-end">
65 {props.caption}
66 </Text>
67 </Box>
68 )
69}
70
71export default Board
72
73// Numbers the input, keeps it with the backlog not yet acknowledged, and
74// posts them all. Past the bound the oldest go, and the model, seeing the
75// gap, hands the keys back.
76function sent(
77 surface: ClientSurface<Local>,
78 acknowledged: number,
79 input: JsonValue,
80): void {
81 const kept = surface.state ?? { last: acknowledged, backlog: [] }
82 const last = Math.max(kept.last, acknowledged) + 1
83 const backlog = [
84 ...kept.backlog.filter(entry => entry.number > acknowledged),
85 { number: last, input },
86 ].slice(-BOARD_BACKLOG)
87 surface.setState({ last, backlog })
88 surface.post({ count: last, inputs: backlog.map(entry => entry.input) })
89}
90
91// One tile, its value centred in a fixed width; an empty cell in the
92// theme's own text colour, dimmed.
93function tileTree(surface: ClientSurface<Local>, tile: Tile) {
94 const { Text } = surface.elements
95 const left = Math.floor((TILE_WIDTH - tile.text.length) / 2)
96 const label = tile.text
97 .padStart(tile.text.length + Math.max(0, left))
98 .padEnd(TILE_WIDTH)
99 return tile.isEmpty ? (
100 <Text dimColor>{label}</Text>
101 ) : (
102 <Text backgroundColor={tile.background} color={tile.color} bold>
103 {label}
104 </Text>
105 )
106}
107hooks/game.ts 125 lines1import type { Game } from '../types'
2
3/** A direction the tiles slide in. */
4export type Move = 'left' | 'right' | 'up' | 'down'
5
6/** A source of random numbers in [0, 1), passed in so the rules stay pure. */
7export type Random = () => number
8
9const SIDE = 4
10
11// For each move, the board's lines as cell indexes, each listed from the edge
12// the tiles slide toward.
13const LINES: Record<Move, number[][]> = {
14 left: rows(),
15 right: rows().map(row => [...row].reverse()),
16 up: columns(),
17 down: columns().map(column => [...column].reverse()),
18}
19
20/** A new game: two tiles on an empty board, with the best score carried over. */
21export function newGame(best: number, random: Random): Game {
22 const empty = new Array<number>(SIDE * SIDE).fill(0)
23 return {
24 cells: spawned(spawned(empty, random), random),
25 score: 0,
26 best,
27 isOver: false,
28 hasWon: false,
29 moves: 0,
30 }
31}
32
33/**
34 * The board with one new tile in a random empty cell: a 2 nine times in ten,
35 * otherwise a 4. A full board comes back as it was.
36 */
37export function spawned(cells: readonly number[], random: Random): number[] {
38 const empty = cells.flatMap((value, at) => (value === 0 ? [at] : []))
39 const next = [...cells]
40 const at = empty[indexOf(random(), empty.length)]
41 if (at !== undefined) next[at] = random() < 0.9 ? 2 : 4
42 return next
43}
44
45/** Whether any move would change the board: an empty cell, or two equal tiles side by side. */
46export function canMove(cells: readonly number[]): boolean {
47 return [...rows(), ...columns()].some(line =>
48 line.some((at, k) => {
49 const value = cells[at] ?? 0
50 const after = line[k + 1]
51 return value === 0 || (after !== undefined && cells[after] === value)
52 }),
53 )
54}
55
56/**
57 * The game after a move. Tiles slide toward the move's edge, and each pair of
58 * equal tiles meeting there merges once into their sum, which is added to the
59 * score. Then a new tile appears. A move that changes nothing returns the very
60 * same game, with no new tile and no move counted, and so does any move on a
61 * finished game.
62 */
63export function played(game: Game, move: Move, random: Random): Game {
64 if (game.isOver) return game
65 const cells = [...game.cells]
66 let gained = 0
67 for (const line of LINES[move]) {
68 const result = slid(line.map(at => game.cells[at] ?? 0))
69 line.forEach((at, k) => {
70 cells[at] = result.values[k] ?? 0
71 })
72 gained += result.gained
73 }
74 if (cells.every((value, at) => value === game.cells[at])) return game
75 const next = spawned(cells, random)
76 const score = game.score + gained
77 return {
78 cells: next,
79 score,
80 best: Math.max(game.best, score),
81 isOver: !canMove(next),
82 hasWon: game.hasWon || next.some(value => value >= 2048),
83 moves: game.moves + 1,
84 }
85}
86
87// One line slid toward its first cell: equal neighbours merge once, left to
88// right, so 2 2 2 2 becomes 4 4 and never 8.
89function slid(line: readonly number[]): { values: number[]; gained: number } {
90 const tiles = line.filter(value => value !== 0)
91 const values: number[] = []
92 let gained = 0
93 for (let k = 0; k < tiles.length; k++) {
94 const value = tiles[k] ?? 0
95 if (tiles[k + 1] === value) {
96 values.push(value * 2)
97 gained += value * 2
98 k++
99 } else {
100 values.push(value)
101 }
102 }
103 while (values.length < line.length) values.push(0)
104 return { values, gained }
105}
106
107function rows(): number[][] {
108 return range(SIDE).map(row => range(SIDE).map(column => row * SIDE + column))
109}
110
111function columns(): number[][] {
112 return range(SIDE).map(column => range(SIDE).map(row => row * SIDE + column))
113}
114
115function range(count: number): number[] {
116 return Array.from({ length: count }, (_, k) => k)
117}
118
119// A random number turned into an index below `count`, even when the source
120// strays outside [0, 1).
121function indexOf(random: number, count: number): number {
122 if (!Number.isFinite(random)) return 0
123 return Math.min(count - 1, Math.max(0, Math.floor(random * count)))
124}
125types/index.d.ts 60 lines1/** One tool call as the band names it: the tool and what it works on. */
2export type Activity = { tool: string; target: string }
3
4/** Why a turn stopped, as Claude Code reports it; empty before any stop. */
5export type StopReason = 'answer' | 'aborted' | 'refusal' | 'error' | ''
6
7/** The main loop's current or last turn. */
8export type Turn = {
9 turnId: string
10 phase: 'idle' | 'running' | 'done'
11 startedAt: number
12 endedAt: number
13 tools: number
14 current: Activity | null
15 recent: Activity[]
16 files: string[]
17 reason: StopReason
18 /** Whether the turn ended with an answer, not an empty one. */
19 hasAnswer: boolean
20 /** The model's summary of what Claude did, or empty for the placeholder. */
21 summary: string
22 /** The next step the summary gives the person; set with `summary`. */
23 next: string
24 /** When the board locks; 0 once the lock has taken effect, so it holds. */
25 lockAt: number
26 isShown: boolean
27}
28
29/** A 2048 game: 16 cells in rows, 0 for an empty cell. */
30export type Game = {
31 cells: number[]
32 score: number
33 best: number
34 isOver: boolean
35 hasWon: boolean
36 moves: number
37}
38
39/** Where the keys go: the game, or the prompt. */
40export type Mode = 'game' | 'input'
41
42/** Everything the band shows and decides on, kept as one value. */
43export type Model = {
44 isOn: boolean
45 /** Whether this session's on/off is settled; until then the store seeds it. */
46 isSeeded: boolean
47 turn: Turn
48 mode: Mode
49 game: Game
50 board: { epoch: number; count: number }
51 hasStrayKey: boolean
52 isFaulted: boolean
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 'twenty-forty-wait': { model: Shaped<Model>; now: number }
58 }
59}
60