SLOPSHOPPER

Twenty Forty-Wait

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.

newbandguardcommandpromptmodel
★ 1v0.2.0Apache-2.0updated 2026-10-13leonidb/claude-code-mods/mods/twenty-forty-wait
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · twenty-forty-wait
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fortywait ⎿ twenty-forty-wait: Twenty Forty-Wait on. Run /fortywait again to turn it off. ▣ client module ./board.tsx No activity yet · Claude's work shows here ○ playable while Claude works ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./board.tsx No activity yet · Claude's work shows here ○ playable while Claude works
README

Twenty Forty-Wait

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

A game of 2048 played above the Claude Code prompt while Claude works, then the board locking in grey beside the your-turn card when Claude stops

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.

Install

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

Use

  • Play: click the board, then use the arrows.
  • Type: press Tab or Esc, or click the prompt.
  • When Claude stops: a long turn gets 5 seconds to finish your move, then the board locks until your next prompt.
  • One game per session. Your best score is kept.

What leaves your machine

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.

Known limits

  • Entering the game takes a click; there's no keyboard way in yet.
  • After Esc or a prompt click, the label updates once you type. Tab switches at once.
  • Small windows show one line: the full game needs about 24 rows.
  • While you play, Claude Code's shortcuts wait until you press Tab. Ctrl+C still interrupts.
  • --resume starts a new game.

Update and uninstall

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.

Source 6 files
hooks/register.tsx 549 lines
1import { 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}
549
hooks/band.tsx 144 lines
1import 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}
144
hooks/logic.ts 1670 lines
1import 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 lines
1import 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}
107
hooks/game.ts 125 lines
1import 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}
125
types/index.d.ts 60 lines
1/** 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