SLOPSHOPPER

claudoom

Plays Doom-engine WADs in a Claude Code pane: runs doom-cli (GPLv2, built from upstream) in a private tmux server and repaints its frames into a Raster…

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-08MadAppGang/magus-alpha/plugins/claudoom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudoom
│ ┃ claudoom ✕ › 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 │ ┃ │ ┃ › /claudoom │ ┃ ⎿ claudoom: claudoom is not installed in /Users/dev/.claude/claudo │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ○ w: ↑ a: ← s: ↓ d: → f: fire e: use r: ente │ ┃ 1: fist 2: pistol 3: shotgun 4: chain 5: roc │ ┃ ▣ client module ./controls.tsx │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claudoom
○ w: ↑ a: ← s: ↓ d: → f: fire e: use r: enter q: menu y: yes 1: fist 2: pistol 3: shotgun 4: chain 5: rocket 6: plasma 7: ▣ client module ./controls.tsx
README

claudoom

Plays Doom-engine WADs in a Claude Code pane. It is a Claude Code mod: a plugin whose behaviour lives in a function-hooks module (hooks/register.tsx). It runs doom-cli in a private tmux server, reads each frame with tmux capture-pane, and repaints it into a terminal Raster, scaled to fit the pane.

Function hooks are early access and off by default. Start Claude Code with the flag and the plugin folder:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/claudoom

Commands

CommandDoes
/claudoom installBuilds doom-cli and downloads Freedoom
/claudoom install sharewareShows the shareware licence terms; add accept to download
/claudoomPlays the shareware episode if installed, else Freedoom; again closes it
/claudoom freedoom2Plays Freedoom Phase 2
/claudoom <file.wad>Plays a WAD you own
/claudoom stopStops the game
/claudoom uninstallStops the game and removes what the install wrote; files you added yourself stay

While the pane holds the keys: WASD or ↑↓ move, F fire, E use, Enter or R select, Q menu, 1–7 weapons. Esc hands the keys back to the prompt; ctrl+x tab takes them again. It needs tmux, make, curl, tar, unzip and shasum. It never runs git, so a git config that rewrites GitHub URLs to SSH cannot break the install.

What is downloaded, and under which licence

Nothing below ships with this plugin. /claudoom install fetches it onto your machine, into ~/.claude/claudoom, over HTTPS only (a redirect to plain http is refused), and verifies each download against a pinned digest before anything is built, kept or run.

The engine's digest covers the extracted source tree, not the tarball, so a forge re-compressing the same commit does not break the install while a changed file still does. The command that recomputes it is in hooks/doom.ts, beside the digest.

ComponentLicenceSource
doom-cli, a doomgeneric portGPLv2fetched from upstream at a pinned commit, verified against a pinned source-tree digest, then built locally; the mod talks to it only through tmux
Freedoom 0.13.03-clause BSD, notice kept as freedoom-COPYING.txtthe official GitHub release, checked against its signed SHA-256
DOOM1.WAD, the shareware episode (opt-in)id Software's shareware licence: not free software, no commercial useid's own doom19s.zip from /idgames, archive and WAD both checked

The shareware licence (LICENSE.DOC, shipped with id's v1.8 shareware) allows electronic distribution royalty free and "only in compressed format", and forbids modifying the WAD (§1), commercial use (§2) and charging anyone for it without id's prior written consent (§4). That is why the shareware download is opt-in, shows those terms first, and fetches id's compressed archive rather than a bare WAD.

DOOM is a trademark of the ZeniMax group of companies. Claude is a trademark of Anthropic. This plugin is not affiliated with or endorsed by id Software, ZeniMax or Anthropic. This is not legal advice.

Source 5 files
hooks/register.tsx 339 lines
1import type { Elements, On, Timer, UiBlitArgs } from 'claude-code'
2
3import * as Doom from './doom'
4import { bannerWords, encode, fitGrid, fitWords, paintedRowsOf, wordsOf, type Grid } from './frame'
5import { ACTION_HOTKEYS, WEAPON_HOTKEYS, pressesOf, tmuxKeyOf } from './keys'
6
7const PANE_ID = 'claudoom'
8const SCREEN_KEY = 'screen'
9const CONTROLS_KEY = 'controls'
10const FRAME_MS = 33
11// A capture can fail once while tmux is busy; only a run of failures means
12// the game quit.
13const FAILURES_TO_END = 3
14// Controls under the screen: all three rows when the pane has room for the
15// whole frame, only the movement row when it does not.
16const FULL_CONTROL_ROWS = 3
17const TIGHT_CONTROL_ROWS = 1
18// The drawing is always one row taller than the pane's body, whatever the
19// picture's size. An inline pane is as tall as its content up to what the
20// layout spares, so content sized from the body would shrink the body with
21// it; and the arrows only raise `ui.scroll` while there is a row to scroll.
22const CONTENT_ROWS = Doom.GRID.rows + FULL_CONTROL_ROWS + 1
23// A height change alone does not redraw a pane, so redraw on a clock to pick
24// one up: every 15th frame, twice a second.
25const RELAYOUT_EVERY_TICKS = 15
26
27const LOADING = ['C L A U D O O M', '', 'loading…']
28
29/**
30 * What the frame loop needs from `$` after the hook that started it returned.
31 * `$` itself may not be kept (the engine refuses a module that stores it), so
32 * `session.start` binds one closure per call, each spelling `$` out.
33 */
34type Host = {
35  run: Doom.Run
36  files: Doom.Files
37  home: () => Promise<string | undefined>
38  every: (ms: number, fn: () => void) => Timer
39  blit: (args: UiBlitArgs) => Promise<unknown>
40  invalidate: () => void
41  closePane: () => Promise<void>
42  log: (text: string) => void
43}
44
45/**
46 * `/claudoom` opens a pane holding the game: a Raster repainted with each
47 * captured frame, scaled to the room the pane has, and the controls under it.
48 */
49export function register(on: On) {
50  let host: Host | null = null
51  let session = ''
52  let frame: Uint32Array | null = null
53  let view: Grid = Doom.GRID
54  let timer: Timer | null = null
55  let isOpen = false
56  let isCapturing = false
57  let isInstalling = false
58  let failures = 0
59  let fullRows = 0
60  let ticks = 0
61
62  const run: Doom.Run = (argv, init) =>
63    host ? host.run(argv, init) : Promise.reject(new Error('doom: no session yet'))
64
65  const press = (key: string): void => {
66    void Doom.sendKeys(run, session, [key]).catch(() => undefined)
67  }
68
69  async function paths(): Promise<Doom.Paths> {
70    return Doom.pathsOf((await host?.home()) ?? '')
71  }
72
73  /** The Raster's cells at the size it is drawn: the last whole frame, or the banner. */
74  function cellsFor(grid: Grid): string {
75    return encode(frame ? fitWords(frame, Doom.GRID, grid) : bannerWords(LOADING, grid))
76  }
77
78  async function tick(): Promise<void> {
79    if (!host || !isOpen || isCapturing) return
80    isCapturing = true
81    ticks++
82    if (ticks % RELAYOUT_EVERY_TICKS === 0) host.invalidate()
83    try {
84      const { frame: text, error } = await Doom.capture(run, session)
85      if (text === undefined) {
86        failures++
87        // Say what tmux said the first time: a game that quit and a tmux whose
88        // capture-pane has no -N both end here, looking identical.
89        if (failures === 1 && error) host.log(`claudoom: reading the game's screen failed: ${error}`)
90        if (failures >= FAILURES_TO_END) await endGame('claudoom exited.')
91        return
92      }
93      failures = 0
94      // A torn capture paints fewer rows than a whole frame; keep the last
95      // whole one on screen instead.
96      const rows = paintedRowsOf(text)
97      if (rows < fullRows) return
98      fullRows = rows
99      frame = wordsOf(text, Doom.GRID)
100      await host.blit({ requestId: PANE_ID, key: SCREEN_KEY, cells: cellsFor(view) })
101    } catch {
102      // A missed frame is harmless; the next tick paints over it.
103    } finally {
104      isCapturing = false
105    }
106  }
107
108  function startLoop(): void {
109    stopLoop()
110    timer = host?.every(FRAME_MS, () => {
111      void tick()
112    }) ?? null
113  }
114
115  function stopLoop(): void {
116    timer?.cancel()
117    timer = null
118  }
119
120  /** Stops the loop and the game; leaves the pane to whoever is closing it. */
121  async function stopGame(): Promise<void> {
122    stopLoop()
123    isOpen = false
124    await Doom.stop(run, session)
125  }
126
127  async function endGame(note?: string): Promise<void> {
128    const wasOpen = isOpen
129    await stopGame()
130    if (wasOpen && host) {
131      await host.closePane().catch(() => undefined)
132      if (note) host.log(note)
133    }
134  }
135
136  on('session.start', async ($, e, next) => {
137    host = {
138      run: (argv, init) => $.process.run(argv, init),
139      files: {
140        exists: path => $.fs.exists(path),
141        read: path => $.fs.read(path),
142        write: (path, text) => $.fs.write(path, text),
143      },
144      home: () => $.env.get('HOME'),
145      every: (ms, fn) => $.clock.every(ms, fn),
146      blit: args => $.ui.blit(args),
147      invalidate: () => $.ui.invalidate('ui.render'),
148      closePane: () => $.ui.close({ id: PANE_ID }),
149      log: text => $.ui.log(text),
150    }
151    session = `claudoom-${(await $.session.id()).slice(0, 8)}`
152    await $.command.register({
153      name: 'claudoom',
154      description: 'Play Doom-engine WADs (Freedoom by default) in a pane beside the conversation',
155      argumentHint: '[freedoom2 | <file.wad> | install [shareware] | stop | uninstall]',
156    })
157
158    // A reload (the plugin folder saved under --plugin-dir) runs this again
159    // with fresh state while the pane stays open: pick the running game back up.
160    const panes = await $.ui.panes()
161    if (panes.some(pane => pane.id === PANE_ID) && (await Doom.isRunning(run, session))) {
162      isOpen = true
163      startLoop()
164    }
165
166    return next(e)
167  })
168
169  on('command.run', { command: 'claudoom' }, async ($, e) => {
170    const args = e.args.trim()
171    const [verb = '', option = '', consent = ''] = args.toLowerCase().split(/\s+/)
172    const bound = host
173    if (!bound) return { text: 'claudoom is not ready: this session has not started yet.' }
174
175    if (verb === 'stop') {
176      await endGame()
177      return { text: 'claudoom stopped.' }
178    }
179
180    if (verb === 'uninstall') {
181      if (isInstalling) return { text: 'claudoom is installing; run /claudoom uninstall once it finishes.' }
182      await endGame()
183      const installed = await paths()
184      const { removed, kept } = await Doom.uninstall(run, bound.files, installed)
185      if (removed.length === 0) return { text: `Nothing to remove: claudoom has no files in ${installed.dir}.` }
186      const left = kept.length > 0 ? ` Left in place, not claudoom's: ${kept.join(', ')}.` : ' The folder is gone.'
187      return {
188        text: `Removed ${removed.join(', ')} from ${installed.dir}.${left} The plugin itself stays installed; remove it with /plugin. If a game ever outlives its session, "tmux -L claudoom kill-server" stops it.`,
189      }
190    }
191
192    if (!(await Doom.hasTmux(run))) {
193      return { text: 'claudoom runs inside tmux, and tmux is not on PATH. Install it (brew install tmux), then run /claudoom again.' }
194    }
195
196    const where = await paths()
197
198    if (verb === 'install') {
199      const isShareware = option === 'shareware'
200      if (isShareware && consent !== 'accept') return { text: Doom.SHAREWARE_TERMS }
201      if (isInstalling) return { text: 'claudoom is still installing.' }
202      isInstalling = true
203      const fetchWad = isShareware ? Doom.fetchShareware : Doom.fetchFreedoom
204      const installed = isShareware ? 'The shareware episode' : 'Freedoom'
205      void (async () => (await Doom.buildEngine(run, bound.files, where)) ?? (await fetchWad(run, bound.files, where)))()
206        .then(error => bound.log(error ? `claudoom install failed: ${error}` : `${installed} is installed. Run /claudoom to play.`))
207        .catch(error => bound.log(`claudoom install failed: ${String(error)}`))
208        .finally(() => {
209          isInstalling = false
210        })
211      return {
212        text: isShareware
213          ? `Downloading id's shareware archive into ${where.dir}, under the terms shown.`
214          : `Installing into ${where.dir}: building doom-cli (GPLv2, built from upstream source) and downloading Freedoom (BSD, about 25 MB). This takes a minute or two.`,
215      }
216    }
217
218    if (isOpen) {
219      await endGame()
220      return { text: 'claudoom closed.' }
221    }
222
223    // `/claudoom freedoom2` or `/claudoom <path to a .wad>` picks the game; a bare
224    // `/claudoom` plays the shareware episode if it was installed, else Freedoom.
225    const home = (await bound.home()) ?? ''
226    const chosen =
227      verb === 'freedoom' ? where.freedoom1
228      : verb === 'freedoom2' ? where.freedoom2
229      : verb.endsWith('.wad') ? args.replace(/^~(?=\/)/, home)
230      : undefined
231    if (verb && !chosen) {
232      return { text: 'Usage: /claudoom [freedoom | freedoom2 | <file.wad> | install [shareware] | stop | uninstall]' }
233    }
234
235    const wad = await Doom.wadOf(bound.files, where, chosen)
236    if (chosen && !wad) return { text: `There is no WAD at ${chosen}.` }
237    if (!wad || !(await bound.files.exists(where.bin))) {
238      return {
239        text: `claudoom is not installed in ${where.dir}. Run /claudoom install: it builds doom-cli (GPLv2, built from upstream source) and downloads Freedoom, a free BSD-licensed game for this engine. The shareware episode is opt-in: /claudoom install shareware.`,
240      }
241    }
242
243    const error = await Doom.start(run, session, where, wad)
244    if (error) return { text: `claudoom did not start: ${error}` }
245
246    frame = null
247    failures = 0
248    fullRows = 0
249    isOpen = true
250    await $.ui.open({
251      id: PANE_ID,
252      title: 'claudoom',
253      focus: true,
254      rows: CONTENT_ROWS - 1,
255      columns: Doom.GRID.columns,
256    })
257    startLoop()
258
259    return {
260      text: 'claudoom is running and its pane has the keys: WASD or ↑↓ move, F fire, E use, Enter or R select, Q menu. Esc hands the keys back, ctrl+x tab takes them again, /claudoom closes it.',
261    }
262  })
263
264  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
265    if (e.requestId !== PANE_ID) return next(e)
266
267    if (e.surface !== 'terminal') {
268      const { Text } = $.ui.resolve(e)
269      return <Text>claudoom draws on the terminal only.</Text>
270    }
271
272    const { Box, Button, Raster, Client, Text } = $.ui.resolve(e) as Elements['terminal']
273    const bodyRows = e.props.scroll.bodyRows
274    const isRoomy = bodyRows >= Doom.GRID.rows + FULL_CONTROL_ROWS
275    const controlRows = isRoomy ? FULL_CONTROL_ROWS : TIGHT_CONTROL_ROWS
276    view = fitGrid(Doom.GRID, { columns: e.props.bodyColumns, rows: Math.max(1, bodyRows - controlRows) })
277    const spacerRows = Math.max(CONTENT_ROWS, bodyRows + 1) - view.rows - controlRows
278
279    const buttonsOf = (entries: typeof ACTION_HOTKEYS) =>
280      entries.map(entry => (
281        <Button
282          key={`key-${entry.hotkey}`}
283          hotkey={entry.hotkey}
284          label={entry.label}
285          plain
286          dimColor
287          // Enter presses the focused Button, so the ring starts on Doom's own
288          // Enter: menus select with the key a player reaches for.
289          autoFocus={entry.hotkey === 'r' ? true : undefined}
290          onPress={() => press(entry.tmux)}
291        />
292      ))
293
294    return (
295      <Box flexDirection="column">
296        <Raster key={SCREEN_KEY} columns={view.columns} rows={view.rows} cells={cellsFor(view)} />
297        <Box flexDirection="row" gap={1}>
298          {e.props.isFocused ? <Text color="green">●</Text> : <Text dimColor>○</Text>}
299          {buttonsOf(ACTION_HOTKEYS)}
300        </Box>
301        {isRoomy && (
302          <Box flexDirection="row" gap={1}>
303            {buttonsOf(WEAPON_HOTKEYS)}
304          </Box>
305        )}
306        {isRoomy && <Client key={CONTROLS_KEY} module="./controls.tsx" height={1} />}
307        <Box key="spacer" height={spacerRows} flexShrink={0} />
308      </Box>
309    )
310  })
311
312  // A focused pane keeps the arrow keys for scrolling. Each one still arrives
313  // here as a one-row scroll of this pane: hand ↑/↓ to the game and keep the
314  // pane still. A wheel (it carries a pointer) or a page key moves nothing either.
315  on('ui.scroll', { requestId: PANE_ID }, ($, e, next) => {
316    if (!isOpen || e.origin.kind !== 'person') return next(e)
317    if (!e.pointer && Math.abs(e.by) === 1) press(e.by < 0 ? 'Up' : 'Down')
318    return {}
319  })
320
321  on('ui.message', ($, e, next) => {
322    if (e.requestId !== PANE_ID || e.element !== CONTROLS_KEY) return next(e)
323    const keys = pressesOf(e.data).flatMap(press => tmuxKeyOf(press) ?? [])
324    void Doom.sendKeys(run, session, keys).catch(() => undefined)
325    return {}
326  })
327
328  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
329    const result = await next(e)
330    await stopGame()
331    return result
332  })
333
334  on('session.end', async ($, e, next) => {
335    await stopGame()
336    return next(e)
337  })
338}
339
hooks/doom.ts 391 lines
1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type { Grid } from './frame'
4
5/**
6 * doom-cli, a native build of doomgeneric that draws to a terminal, driven
7 * through a tmux server of the mod's own. `$.process.run` is one-shot, so a
8 * game that keeps running cannot be streamed; a detached tmux session holds
9 * it, `capture-pane` reads a frame, `send-keys` types into it.
10 */
11
12export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
13
14export type Files = {
15  exists: (path: string) => Promise<boolean>
16  read: (path: string) => Promise<string>
17  write: (path: string, text: string) => Promise<void>
18}
19
20export type Paths = {
21  dir: string
22  bin: string
23  repo: string
24  downloads: string
25  freedoom1: string
26  freedoom2: string
27  shareware: string
28}
29
30// 80 columns in quadrant mode is 160x52 pixels: 26 rows of 2x2 cells.
31export const GRID: Grid = { columns: 80, rows: 26 }
32
33// The window is taller than the frame so doom-cli's trailing newline never
34// scrolls the first row away.
35const WINDOW_ROWS = 30
36
37// A socket of its own keeps the game out of the person's `tmux ls`, and
38// `-f /dev/null` keeps their tmux.conf from changing what a capture holds.
39const TMUX = ['tmux', '-L', 'claudoom', '-f', '/dev/null'] as const
40
41// Every download is pinned and verified before anything is built, kept or run,
42// so an upstream change or a swapped file fails the install instead of running
43// something nobody reviewed.
44
45// doom-cli is GPLv2. Its source is fetched from upstream and built on the
46// person's own machine; the plugin ships none of it. The source arrives as the
47// tarball of one pinned commit over HTTPS, never through git: a person's git
48// config may rewrite github URLs to SSH, and then a clone needs their agent.
49//
50// What is pinned is the digest of the EXTRACTED TREE, not of the tarball: a
51// forge may re-compress an archive it serves for the same commit, which changes
52// the tarball's bytes and nothing inside it. This digest is the sha256 of the
53// sorted per-file sha256 list, so it survives that and still fails closed on a
54// changed file. Recompute it with the command in DOOM_CLI.digestCommand.
55const DOOM_CLI = {
56  commit: '018e1edf67a093f8ac48e57591eb934e9bc01b26',
57  tarball: 'https://codeload.github.com/ludocode/doom-cli/tar.gz/018e1edf67a093f8ac48e57591eb934e9bc01b26',
58  sourceSha256: 'a83bdb933f32601b2fab63b23b0cd6bc07cdbc77dcf5fe9de23fc7898cb2a9d4',
59  digestCommand: 'find . -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 | shasum -a 256',
60}
61
62// Freedoom is 3-clause BSD: free to fetch and keep, with its COPYING notice.
63// The digest is the one in the release's PGP-signed freedoom-0.13.0-CHECKSUM.
64const FREEDOOM = {
65  url: 'https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedoom-0.13.0.zip',
66  sha256: '3f9b264f3e3ce503b4fb7f6bdcb1f419d93c7b546f4df3e874dd878db9688f59',
67  folder: 'freedoom-0.13.0',
68}
69
70// id's own v1.9 shareware archive from /idgames. Its licence allows electronic
71// distribution "only in compressed format", so the archive is fetched and
72// unpacked here, never a bare WAD from a mirror. The WAD digest is v1.9's.
73const SHAREWARE = {
74  url: 'https://www.gamers.org/pub/idgames/idstuff/doom/doom19s.zip',
75  sha256: 'cacf0142b31ca1af00796b4a0339e07992ac5f21bc3f81e7532fe1b5e1b486e6',
76  wadSha1: '5b2e249b9c5133ec987b3ea77596381dc0d6bc1d',
77}
78
79/** What `/claudoom install shareware` shows before anything is downloaded. */
80export const SHAREWARE_TERMS = [
81  "The shareware episode (DOOM1.WAD) is id Software's, and it is not free software.",
82  "Its licence (LICENSE.DOC, shipped with id's v1.8 shareware; the v1.9 archive carries none) lets you play it and copy it for others,",
83  'and share it electronically, royalty free, "only in compressed format". It forbids modifying it (§1),',
84  'any commercial use (§2, "Commercial Use is Prohibited"), and charging anyone for it without "ID\'s prior written consent" (§4).',
85  "To accept those terms and download id's original doom19s.zip from /idgames, run /claudoom install shareware accept.",
86].join(' ')
87
88// mempcpy is a GNU extension macOS lacks.
89const MEMPCPY_PATCH = `
90#ifndef mempcpy
91static inline void* mempcpy_compat(void* dest, const void* src, size_t n) {
92    memcpy(dest, src, n);
93    return (char*)dest + n;
94}
95#define mempcpy mempcpy_compat
96#endif
97`
98
99export function pathsOf(home: string): Paths {
100  const dir = `${home}/.claude/claudoom`
101  return {
102    dir,
103    bin: `${dir}/doomgeneric`,
104    repo: `${dir}/repo`,
105    downloads: `${dir}/downloads`,
106    freedoom1: `${dir}/freedoom1.wad`,
107    freedoom2: `${dir}/freedoom2.wad`,
108    shareware: `${dir}/DOOM1.WAD`,
109  }
110}
111
112async function succeeds(run: Run, argv: readonly string[], init?: ProcessRunInit): Promise<boolean> {
113  try {
114    return (await run(argv, init)).exitCode === 0
115  } catch {
116    return false
117  }
118}
119
120export function hasTmux(run: Run): Promise<boolean> {
121  return succeeds(run, ['tmux', '-V'])
122}
123
124async function missingTool(run: Run, tools: readonly string[]): Promise<string | undefined> {
125  for (const tool of tools) {
126    if (!(await succeeds(run, ['which', tool]))) return `${tool} is not installed`
127  }
128  return undefined
129}
130
131async function digestOf(run: Run, algorithm: '1' | '256', path: string): Promise<string | undefined> {
132  const result = await run(['shasum', '-a', algorithm, path])
133  return result.exitCode === 0 ? result.stdout.split(/\s+/)[0] : undefined
134}
135
136// `-L` follows redirects, so the scheme is pinned too: a redirect to plain http
137// is refused rather than followed.
138const CURL = ['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https'] as const
139
140/** Downloads a file and refuses it unless its SHA-256 is the pinned one. */
141async function fetchChecked(run: Run, url: string, sha256: string, to: string): Promise<string | undefined> {
142  const fetched = await run([...CURL, '-o', to, url], { timeoutMs: 300_000 })
143  if (fetched.exitCode !== 0) return `downloading ${url} failed: ${fetched.stderr.trim()}`
144  const digest = await digestOf(run, '256', to)
145  if (digest !== sha256) {
146    await run(['rm', '-f', to])
147    return `${url} does not match its pinned SHA-256 (got ${digest ?? 'nothing'}); nothing was installed`
148  }
149  return undefined
150}
151
152/**
153 * The WAD `/claudoom` plays: one the person named, else the shareware episode if
154 * they opted in to it, else Freedoom.
155 */
156export async function wadOf(files: Files, paths: Paths, chosen?: string): Promise<string | undefined> {
157  const candidates = chosen ? [chosen] : [paths.shareware, paths.freedoom1]
158  for (const wad of candidates) {
159    if (await files.exists(wad)) return wad
160  }
161  return undefined
162}
163
164/** Fetches doom-cli at the pinned commit, patches it for macOS and builds it. */
165export async function buildEngine(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
166  if (await files.exists(paths.bin)) return undefined
167
168  const missing = await missingTool(run, ['curl', 'tar', 'make', 'shasum'])
169  if (missing) return missing
170
171  await run(['mkdir', '-p', paths.dir])
172
173  // The source lands in a staging directory and is verified there. `repo`
174  // itself appears only once the tree is whole and checked, so an interrupted
175  // install leaves nothing that a later run reads as "the source is present".
176  if (!(await files.exists(paths.repo))) {
177    const staging = `${paths.repo}.partial`
178    const abandon = async (why: string) => {
179      await run(['rm', '-rf', staging, paths.downloads])
180      return why
181    }
182
183    await run(['rm', '-rf', staging])
184    await run(['mkdir', '-p', paths.downloads, staging])
185
186    const tarball = `${paths.downloads}/doom-cli.tar.gz`
187    const fetched = await run([...CURL, '-o', tarball, DOOM_CLI.tarball], { timeoutMs: 180_000 })
188    if (fetched.exitCode !== 0) {
189      return abandon(`downloading doom-cli ${DOOM_CLI.commit.slice(0, 8)} failed: ${fetched.stderr.trim()}`)
190    }
191
192    const unpacked = await run(['tar', '-xzf', tarball, '-C', staging, '--strip-components=1'])
193    if (unpacked.exitCode !== 0) return abandon(`unpacking doom-cli failed: ${unpacked.stderr.trim()}`)
194
195    // The script text is fixed here and the directory travels as an argument.
196    const digested = await run(['sh', '-c', `cd "$1" && ${DOOM_CLI.digestCommand}`, 'sh', staging])
197    const digest = digested.stdout.split(/\s+/)[0]
198    if (digest !== DOOM_CLI.sourceSha256) {
199      return abandon(
200        `doom-cli ${DOOM_CLI.commit.slice(0, 8)} does not match its pinned source digest ` +
201          `(got ${digest || 'nothing'}); nothing was built`,
202      )
203    }
204
205    const staged = await run(['mv', staging, paths.repo])
206    if (staged.exitCode !== 0) return abandon(`staging doom-cli failed: ${staged.stderr.trim()}`)
207    await run(['rm', '-rf', paths.downloads])
208  }
209
210  const source = `${paths.repo}/doomgeneric/doomgeneric_cli.c`
211  if (await files.exists(source)) {
212    const text = await files.read(source)
213    if (text.includes('mempcpy') && !text.includes('mempcpy_compat')) {
214      const lineEnd = text.indexOf('\n', text.lastIndexOf('#include'))
215      await files.write(source, text.slice(0, lineEnd + 1) + MEMPCPY_PATCH + text.slice(lineEnd + 1))
216    }
217  }
218
219  const build = await run(['make', '-f', 'Makefile.cli'], {
220    cwd: `${paths.repo}/doomgeneric`,
221    env: { CFLAGS: '-Wno-absolute-value' },
222    timeoutMs: 300_000,
223  })
224  if (build.exitCode !== 0) {
225    // Both ends of the build log: a missing compiler or header says so in the
226    // first lines, and make's own "Error 1" is all the last lines carry.
227    const lines = build.stderr.trim().split('\n')
228    const said = lines.length > 6 ? [...lines.slice(0, 3), '…', ...lines.slice(-3)] : lines
229    return `make failed: ${said.join(' ')}`
230  }
231
232  const copy = await run(['cp', `${paths.repo}/doomgeneric/doomgeneric`, paths.bin])
233  if (copy.exitCode !== 0) return `copying the binary failed: ${copy.stderr.trim()}`
234
235  return undefined
236}
237
238/** Fetches the Freedoom release, checks it, and keeps both WADs and the licence. */
239export async function fetchFreedoom(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
240  const copying = `${paths.dir}/${FREEDOOM_COPYING}`
241  // All three, so a half-finished install repairs itself on the next run
242  // instead of reporting success with a WAD or the licence notice missing.
243  const kept = [paths.freedoom1, paths.freedoom2, copying]
244  if ((await Promise.all(kept.map(path => files.exists(path)))).every(Boolean)) return undefined
245
246  const missing = await missingTool(run, ['curl', 'shasum', 'unzip'])
247  if (missing) return missing
248
249  await run(['mkdir', '-p', paths.downloads])
250  const zip = `${paths.downloads}/${FREEDOOM.folder}.zip`
251  const error = await fetchChecked(run, FREEDOOM.url, FREEDOOM.sha256, zip)
252  if (error) return error
253
254  const members = ['freedoom1.wad', 'freedoom2.wad', 'COPYING.txt'].map(name => `${FREEDOOM.folder}/${name}`)
255  const unpacked = await run(['unzip', '-o', '-j', zip, ...members, '-d', paths.downloads], { timeoutMs: 120_000 })
256  if (unpacked.exitCode !== 0) return `unpacking Freedoom failed: ${unpacked.stderr.trim()}`
257
258  for (const [from, to] of [
259    ['freedoom1.wad', paths.freedoom1],
260    ['freedoom2.wad', paths.freedoom2],
261    ['COPYING.txt', copying],
262  ] as const) {
263    const moved = await run(['mv', `${paths.downloads}/${from}`, to])
264    if (moved.exitCode !== 0) return `keeping ${from} failed: ${moved.stderr.trim()}`
265  }
266
267  await run(['rm', '-rf', paths.downloads])
268  return undefined
269}
270
271/**
272 * Fetches id's shareware archive, joins the two halves of its 1995 installer
273 * (one LHA archive, which macOS `tar` reads) and keeps only the WAD, checked.
274 */
275export async function fetchShareware(run: Run, files: Files, paths: Paths): Promise<string | undefined> {
276  if (await files.exists(paths.shareware)) return undefined
277
278  const missing = await missingTool(run, ['curl', 'shasum', 'unzip', 'tar'])
279  if (missing) return missing
280
281  await run(['mkdir', '-p', paths.downloads])
282  const zip = `${paths.downloads}/doom19s.zip`
283  const error = await fetchChecked(run, SHAREWARE.url, SHAREWARE.sha256, zip)
284  if (error) return error
285
286  const parts = ['DOOMS_19.1', 'DOOMS_19.2']
287  const unzipped = await run(['unzip', '-o', zip, ...parts, '-d', paths.downloads])
288  if (unzipped.exitCode !== 0) return `unpacking doom19s.zip failed: ${unzipped.stderr.trim()}`
289
290  const lzh = `${paths.downloads}/dooms19.lzh`
291  const [first, second] = parts.map(part => `${paths.downloads}/${part}`)
292  // The paths travel as arguments, never inside the script text.
293  const joined = await run(['sh', '-c', 'cat "$1" "$2" > "$3"', 'sh', first!, second!, lzh])
294  if (joined.exitCode !== 0) return `joining the installer halves failed: ${joined.stderr.trim()}`
295
296  const extracted = await run(['tar', '-xf', lzh, '-C', paths.downloads, 'DOOM1.WAD'])
297  if (extracted.exitCode !== 0) return `extracting DOOM1.WAD failed: ${extracted.stderr.trim()}`
298
299  const wad = `${paths.downloads}/DOOM1.WAD`
300  const digest = await digestOf(run, '1', wad)
301  if (digest !== SHAREWARE.wadSha1) {
302    await run(['rm', '-rf', paths.downloads])
303    return `DOOM1.WAD is not the v1.9 shareware file (SHA-1 ${digest ?? 'unreadable'}); nothing was installed`
304  }
305
306  await run(['mv', wad, paths.shareware])
307  await run(['rm', '-rf', paths.downloads])
308  return undefined
309}
310
311/** Freedoom's licence notice, kept beside its WADs. */
312export const FREEDOOM_COPYING = 'freedoom-COPYING.txt'
313
314/**
315 * Removes what `install` wrote, by name, and nothing else: a file the person
316 * put in the folder themselves stays, and so does the folder around it.
317 */
318export async function uninstall(run: Run, files: Files, paths: Paths): Promise<{ removed: string[]; kept: string[] }> {
319  const owned = [
320    paths.bin,
321    paths.repo,
322    `${paths.repo}.partial`,
323    paths.downloads,
324    paths.freedoom1,
325    paths.freedoom2,
326    `${paths.dir}/${FREEDOOM_COPYING}`,
327    paths.shareware,
328    // The game's own leavings: a save directory on every start, the cfg files
329    // when it is quit from its menu.
330    `${paths.dir}/.savegame`,
331    `${paths.dir}/default.cfg`,
332    `${paths.dir}/doom.cfg`,
333  ]
334  const removed: string[] = []
335  for (const path of owned) {
336    if (!(await files.exists(path))) continue
337    await run(['rm', '-rf', path])
338    removed.push(path.slice(paths.dir.length + 1))
339  }
340
341  if (!(await files.exists(paths.dir))) return { removed, kept: [] }
342  const listing = await run(['ls', '-A', paths.dir])
343  const kept = listing.stdout.split('\n').filter(Boolean)
344  if (kept.length === 0) await run(['rmdir', paths.dir])
345  return { removed, kept }
346}
347
348/** Starts the game in a fresh detached session; resolves an error, or nothing. */
349export async function start(run: Run, session: string, paths: Paths, wad: string): Promise<string | undefined> {
350  await stop(run, session)
351  const result = await run([
352    ...TMUX,
353    'new-session', '-d', '-s', session,
354    // The game's own directory, never the session's: doomgeneric writes
355    // `.savegame/` and its cfg files relative to the cwd it inherits, which
356    // would otherwise be whatever repository Claude Code was started in.
357    '-c', paths.dir,
358    '-x', String(GRID.columns), '-y', String(WINDOW_ROWS),
359    paths.bin, '-iwad', wad, '-charset', 'quadrant', '-color', '24bit', '-columns', String(GRID.columns),
360  ])
361  return result.exitCode === 0 ? undefined : result.stderr.trim() || `tmux exited ${result.exitCode}`
362}
363
364/**
365 * The visible frame as text with its colour escapes, or nothing once the game
366 * is gone. `-N` keeps each row's trailing spaces: without it tmux trims them,
367 * coloured or not, and a row ending in one flat colour comes back short.
368 *
369 * A failure carries tmux's own words: the game ending and a tmux too old for
370 * `-N` both stop the frames, and only the second says so.
371 */
372export async function capture(run: Run, session: string): Promise<{ frame?: string; error?: string }> {
373  const result = await run([...TMUX, 'capture-pane', '-t', session, '-p', '-e', '-N', '-S', '0', '-E', String(GRID.rows - 1)])
374  if (result.exitCode === 0) return { frame: result.stdout }
375  return { error: result.stderr.trim() || `capture-pane exited ${result.exitCode}` }
376}
377
378export async function sendKeys(run: Run, session: string, keys: readonly string[]): Promise<void> {
379  if (keys.length === 0) return
380  const result = await run([...TMUX, 'send-keys', '-t', session, ...keys])
381  if (result.exitCode !== 0) throw new Error(`send-keys exited ${result.exitCode}: ${result.stderr.trim()}`)
382}
383
384export function isRunning(run: Run, session: string): Promise<boolean> {
385  return succeeds(run, [...TMUX, 'has-session', '-t', session])
386}
387
388export async function stop(run: Run, session: string): Promise<void> {
389  await succeeds(run, [...TMUX, 'kill-session', '-t', session])
390}
391
hooks/frame.ts 254 lines
1/**
2 * Turns a `tmux capture-pane -p -e -N` frame into a Raster's `cells`: standard
3 * base64 of little-endian u32 triplets `[codePoint, foreground, background]`,
4 * row-major, exactly `columns * rows` of them.
5 */
6
7export type Grid = { columns: number; rows: number }
8
9type Pen = { fg: number; bg: number }
10
11// The picture is the game's, not the terminal's: a cell with no colour of its
12// own is black, so a light theme does not show through the frame.
13const BLACK = 0x000000
14
15const SPACE = 0x20
16const UPPER_HALF = 0x2580
17const FULL_BLOCK = 0x2588
18
19// doom-cli's quadrant glyphs by lit sub-cells: bit 0 upper left, 1 upper
20// right, 2 lower left, 3 lower right, as its `quadrants[]` table orders them.
21const QUADRANT_MASK = new Map<number, number>([
22  [SPACE, 0], [0x2598, 1], [0x259d, 2], [0x2580, 3],
23  [0x2596, 4], [0x258c, 5], [0x259e, 6], [0x259b, 7],
24  [0x2597, 8], [0x259a, 9], [0x2590, 10], [0x259c, 11],
25  [0x2584, 12], [0x2599, 13], [0x259f, 14], [FULL_BLOCK, 15],
26])
27
28const BASIC = [
29  0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5,
30  0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
31]
32
33function xterm256(n: number): number {
34  if (n < 16) return BASIC[n] ?? BLACK
35  if (n >= 232) {
36    const v = 8 + (n - 232) * 10
37    return (v << 16) | (v << 8) | v
38  }
39  const i = n - 16
40  const level = (c: number) => (c === 0 ? 0 : 55 + c * 40)
41  return (level(Math.floor(i / 36)) << 16) | (level(Math.floor(i / 6) % 6) << 8) | level(i % 6)
42}
43
44function applySgr(params: string, pen: Pen): void {
45  const p = params === '' ? [0] : params.split(';').map(n => Number(n) || 0)
46
47  for (let i = 0; i < p.length; i++) {
48    const code = p[i] ?? 0
49
50    if (code === 0) {
51      pen.fg = BLACK
52      pen.bg = BLACK
53    } else if (code === 39) {
54      pen.fg = BLACK
55    } else if (code === 49) {
56      pen.bg = BLACK
57    } else if (code === 38 || code === 48) {
58      let color: number | undefined
59      if (p[i + 1] === 2) {
60        color = ((p[i + 2] ?? 0) << 16) | ((p[i + 3] ?? 0) << 8) | (p[i + 4] ?? 0)
61        i += 4
62      } else if (p[i + 1] === 5) {
63        color = xterm256(p[i + 2] ?? 0)
64        i += 2
65      }
66      if (color !== undefined) {
67        if (code === 38) pen.fg = color
68        else pen.bg = color
69      }
70    } else if (code >= 30 && code <= 37) {
71      pen.fg = BASIC[code - 30] ?? BLACK
72    } else if (code >= 90 && code <= 97) {
73      pen.fg = BASIC[code - 90 + 8] ?? BLACK
74    } else if (code >= 40 && code <= 47) {
75      pen.bg = BASIC[code - 40] ?? BLACK
76    } else if (code >= 100 && code <= 107) {
77      pen.bg = BASIC[code - 100 + 8] ?? BLACK
78    }
79  }
80}
81
82// A Raster refuses the whole tree over one cell that is not a width-1 BMP
83// character, so control characters blank out and anything past the BMP (the
84// sextant glyphs at U+1FB00) becomes a full block in its foreground colour.
85function glyphOf(codePoint: number): number {
86  if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return SPACE
87  if (codePoint > 0xffff) return FULL_BLOCK
88  return codePoint
89}
90
91function blankWords(grid: Grid, bg = BLACK): Uint32Array {
92  const words = new Uint32Array(grid.columns * grid.rows * 3)
93  for (let cell = 0; cell < grid.columns * grid.rows; cell++) {
94    words[cell * 3] = SPACE
95    words[cell * 3 + 1] = BLACK
96    words[cell * 3 + 2] = bg
97  }
98  return words
99}
100
101export function encode(words: Uint32Array): string {
102  return new Uint8Array(words.buffer).toBase64()
103}
104
105/**
106 * Paints the captured text over a black grid. SGR state carries across lines
107 * as it would on a terminal; rows and columns past the grid are dropped, and
108 * missing ones stay black.
109 */
110export function wordsOf(frame: string, grid: Grid): Uint32Array {
111  const words = blankWords(grid)
112  const pen: Pen = { fg: BLACK, bg: BLACK }
113  const lines = frame.split('\n')
114
115  for (let row = 0; row < grid.rows && row < lines.length; row++) {
116    const line = lines[row] ?? ''
117    let column = 0
118    let i = 0
119
120    while (i < line.length) {
121      if (line.charCodeAt(i) === 0x1b) {
122        const next = line[i + 1]
123        if (next === '[') {
124          let end = i + 2
125          while (end < line.length && !/[@-~]/.test(line[end] ?? '')) end++
126          if (line[end] === 'm') applySgr(line.slice(i + 2, end), pen)
127          i = end + 1
128          continue
129        }
130        if (next === ']') {
131          // OSC (a hyperlink, a title): skip to BEL or ESC \.
132          let end = i + 2
133          while (end < line.length && line.charCodeAt(end) !== 0x07 && line.charCodeAt(end) !== 0x1b) end++
134          i = line.charCodeAt(end) === 0x1b ? end + 2 : end + 1
135          continue
136        }
137        i += 2
138        continue
139      }
140
141      const codePoint = line.codePointAt(i) ?? SPACE
142      i += codePoint > 0xffff ? 2 : 1
143
144      if (column < grid.columns) {
145        const cell = (row * grid.columns + column) * 3
146        words[cell] = glyphOf(codePoint)
147        words[cell + 1] = pen.fg
148        words[cell + 2] = pen.bg
149      }
150      column++
151    }
152  }
153
154  return words
155}
156
157/** Unpacks quadrant cells into a pixel grid two wide and two tall per cell. */
158function pixelsOf(words: Uint32Array, grid: Grid): Uint32Array {
159  const width = grid.columns * 2
160  const pixels = new Uint32Array(width * grid.rows * 2)
161
162  for (let row = 0; row < grid.rows; row++) {
163    for (let column = 0; column < grid.columns; column++) {
164      const cell = (row * grid.columns + column) * 3
165      const glyph = words[cell] ?? SPACE
166      const fg = words[cell + 1] ?? BLACK
167      const bg = words[cell + 2] ?? BLACK
168      const mask = QUADRANT_MASK.get(glyph) ?? (glyph === SPACE ? 0 : 15)
169      const top = row * 2 * width + column * 2
170      pixels[top] = mask & 1 ? fg : bg
171      pixels[top + 1] = mask & 2 ? fg : bg
172      pixels[top + width] = mask & 4 ? fg : bg
173      pixels[top + width + 1] = mask & 8 ? fg : bg
174    }
175  }
176
177  return pixels
178}
179
180/**
181 * Resamples a frame to another cell grid. The same grid passes through as
182 * drawn; any other becomes upper-half blocks, one exact colour per half, each
183 * sampled from the nearest source pixel. The caller keeps the proportions.
184 */
185export function fitWords(words: Uint32Array, source: Grid, target: Grid): Uint32Array {
186  if (source.columns === target.columns && source.rows === target.rows) return words
187
188  const pixels = pixelsOf(words, source)
189  const width = source.columns * 2
190  const height = source.rows * 2
191  const out = new Uint32Array(target.columns * target.rows * 3)
192
193  for (let row = 0; row < target.rows; row++) {
194    const top = Math.min(height - 1, Math.floor(((row + 0.25) * height) / target.rows))
195    const bottom = Math.min(height - 1, Math.floor(((row + 0.75) * height) / target.rows))
196    for (let column = 0; column < target.columns; column++) {
197      const x = Math.min(width - 1, Math.floor(((column + 0.5) * width) / target.columns))
198      const cell = (row * target.columns + column) * 3
199      out[cell] = UPPER_HALF
200      out[cell + 1] = pixels[top * width + x] ?? BLACK
201      out[cell + 2] = pixels[bottom * width + x] ?? BLACK
202    }
203  }
204
205  return out
206}
207
208/**
209 * The largest grid of the source's proportions that fits the room, never
210 * larger than the source: shrinking loses detail, growing only adds blur.
211 */
212export function fitGrid(source: Grid, room: Grid): Grid {
213  if (room.columns >= source.columns && room.rows >= source.rows) return source
214  const columns = Math.max(1, Math.min(room.columns, Math.floor((room.rows * source.columns) / source.rows)))
215  const rows = Math.max(1, Math.min(room.rows, Math.round((columns * source.rows) / source.columns)))
216  return { columns, rows }
217}
218
219/**
220 * How many rows of the capture carry anything: the last line with any bytes.
221 * doom-cli opens every frame with a clear screen, and tmux applies the ~60 KB
222 * that follows in chunks, so a capture taken mid-frame ends early.
223 */
224export function paintedRowsOf(frame: string): number {
225  const lines = frame.split('\n')
226  for (let row = lines.length - 1; row >= 0; row--) {
227    if ((lines[row] ?? '').length > 0) return row + 1
228  }
229  return 0
230}
231
232/**
233 * A dark grid with lines of text centred on it: what the Raster shows before
234 * the first captured frame arrives.
235 */
236export function bannerWords(lines: readonly string[], grid: Grid, fg = 0xcc2200): Uint32Array {
237  const words = blankWords(grid)
238
239  const top = Math.max(0, Math.floor((grid.rows - lines.length) / 2))
240  lines.forEach((text, index) => {
241    const row = top + index
242    if (row >= grid.rows) return
243    const glyphs = [...text].slice(0, grid.columns)
244    const left = Math.max(0, Math.floor((grid.columns - glyphs.length) / 2))
245    glyphs.forEach((glyph, offset) => {
246      const cell = (row * grid.columns + left + offset) * 3
247      words[cell] = glyphOf(glyph.codePointAt(0) ?? SPACE)
248      words[cell + 1] = fg
249    })
250  })
251
252  return words
253}
254
hooks/keys.ts 75 lines
1/**
2 * Maps a key a `Client` heard to the key name `tmux send-keys` takes.
3 */
4
5export type KeyPress = { key: string; ctrl?: boolean }
6
7const NAMED: Readonly<Record<string, string>> = {
8  up: 'Up',
9  down: 'Down',
10  left: 'Left',
11  right: 'Right',
12  return: 'Enter',
13  enter: 'Enter',
14  tab: 'Tab',
15  backspace: 'BSpace',
16  delete: 'DC',
17  space: 'Space',
18  ' ': 'Space',
19  pageup: 'PPage',
20  pagedown: 'NPage',
21  home: 'Home',
22  end: 'End',
23}
24
25// doom-cli reads a terminal, which reports no Ctrl-as-a-key, so fire is Z
26// there. F fires too, as it did in the widget this mod replaces.
27const FIRE = new Set(['f', 'F', 'z', 'Z'])
28
29// Escape never reaches a Client: it hands the keyboard back to the prompt.
30// Backtick stands in for Doom's own Escape, which opens the menu.
31const MENU = '`'
32
33export function tmuxKeyOf(press: KeyPress): string | undefined {
34  const named = NAMED[press.key]
35  if (named) return press.ctrl ? `C-${named}` : named
36  if (press.key === MENU) return 'Escape'
37  if (FIRE.has(press.key)) return 'Z'
38  // tmux reads a lone `;` argument as its own command separator.
39  if ([...press.key].length !== 1 || press.key === ';') return undefined
40  return press.ctrl ? `C-${press.key}` : press.key
41}
42
43/**
44 * The keys a focused pane can press without a click: a Button's `hotkey` is
45 * one lowercase letter or digit, and the arrows scroll the pane rather than
46 * reach it, so movement sits on WASD.
47 */
48export type Hotkey = { hotkey: string; label: string; tmux: string }
49
50export const ACTION_HOTKEYS: readonly Hotkey[] = [
51  { hotkey: 'w', label: '↑', tmux: 'Up' },
52  { hotkey: 'a', label: '←', tmux: 'Left' },
53  { hotkey: 's', label: '↓', tmux: 'Down' },
54  { hotkey: 'd', label: '→', tmux: 'Right' },
55  { hotkey: 'f', label: 'fire', tmux: 'Z' },
56  { hotkey: 'e', label: 'use', tmux: 'Space' },
57  { hotkey: 'r', label: 'enter', tmux: 'Enter' },
58  { hotkey: 'q', label: 'menu', tmux: 'Escape' },
59  { hotkey: 'y', label: 'yes', tmux: 'y' },
60]
61
62export const WEAPON_HOTKEYS: readonly Hotkey[] = ['fist', 'pistol', 'shotgun', 'chain', 'rocket', 'plasma', 'bfg'].map(
63  (label, index) => ({ hotkey: String(index + 1), label, tmux: String(index + 1) }),
64)
65
66/** Reads what the controls strip posted; anything malformed is dropped. */
67export function pressesOf(data: unknown): KeyPress[] {
68  if (!Array.isArray(data)) return []
69  return data.flatMap(item => {
70    if (typeof item !== 'object' || item === null) return []
71    const { key, ctrl } = item as { key?: unknown; ctrl?: unknown }
72    return typeof key === 'string' ? [{ key, ctrl: ctrl === true }] : []
73  })
74}
75
hooks/controls.tsx 39 lines
1import type { ClientModule, ClientKeyEvent } from 'claude-code'
2
3/**
4 * The strip under the screen. A Raster takes no keys, so this Client holds
5 * the keyboard once clicked and posts what it hears to the hooks module,
6 * which types it into the game.
7 */
8
9type State = { isArmed: true }
10
11// A post not yet delivered is replaced by the next one in the same frame, so
12// keys queue here and leave once per frame, never one post per key.
13const FLUSH_MS = 16
14
15const HINT = '▶ or click here for the whole keyboard: arrows, Z fire, space use, ` menu'
16
17const Controls: ClientModule<null, State> = (_props, surface) => {
18  if (surface.state === undefined) {
19    let queue: { key: string; ctrl: boolean }[] = []
20
21    surface.onKey((event: ClientKeyEvent) => {
22      queue.push({ key: event.key, ctrl: event.ctrl === true })
23    })
24
25    surface.every(FLUSH_MS, () => {
26      if (queue.length === 0) return
27      surface.post(queue)
28      queue = []
29    })
30
31    surface.setState({ isArmed: true })
32  }
33
34  const { Text } = surface.elements
35  return <Text dimColor>{HINT}</Text>
36}
37
38export default Controls
39