SLOPSHOPPER

slop-doom

DOOM (shareware) in a pane: doomgeneric runs as WebAssembly in a sidecar, every frame lands as native pixels (kitty, Ghostty) or a Raster.

newpanebandcommandprocesstimer
v0.2.0MITupdated 2026-10-08wobondar/claude-plugins/mods/slop-doom
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slop-doom
│ ┃ Slop Doom ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ DOOM │ ┃ w: ↑ s: ↓ a: ↶ d: ↷ q: ← e: → f: fire u: us ⏺ Read(src/auth.ts) │ ┃ 1: fist 2: pistol 3: shotgun 4: chaingun 5: ⎿ 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 │ │ › /doom │ ⎿ slop-doom: Doom is up at 320x240 pixels over 154x54 cells, 15 fp │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slop Doom
▣ DOOM w: ↑ s: ↓ a: ↶ d: ↷ q: ← e: → f: fire u: use r: run n: ente 1: fist 2: pistol 3: shotgun 4: chaingun 5: rocket 6: plasm
README

slop-doom

DOOM (the shareware episode) in a Claude Code pane.

slop-doom: the DOOM menu over E1M1, native pixels in a pane

In kitty and Ghostty the pane shows DOOM's own 320 by 200 pixels; everywhere else it draws them in quadrant characters, four pixels a cell.

/doom opens the game at the largest 4:3 picture the terminal fits, up to 160 columns; /doom 120 picks a width; /doom 120 20 also sets the frames a second for that run; /doom cells forces the character display and /doom image the pixel one; /doom close ends it. Esc hands the keys back to the prompt while the game keeps running; ctrl+x tab or a click returns them.

Keys

keydoes
w sforward, back
a dturn
q estrafe
ffire
uuse: doors, switches
rrun, toggled
nenter
xmenu (DOOM's Escape)
yyes
mmap
ppause
1 to 7fist, pistol, shotgun, chaingun, rocket, plasma, bfg

A press holds the key for 160 ms, and the terminal's key repeat extends the hold, so holding w walks. Run stays on until pressed again.

Requirements

bun on the PATH, or node 23.6 or newer. The sidecar is TypeScript and either runs it as is.

How it works

The plugin sandbox has no WebAssembly and no Node, so DOOM runs in a sidecar process that $.process.spawn starts: sidecar/doom.ts loads the doomgeneric WASM build, ticks it at 35 Hz, and hands over frames at the chosen rate. The child's stdin is closed at start, so keys travel through a small file the hooks module rewrites on every press and release and the sidecar polls between ticks.

Pixels. On a terminal that draws pictures (TERM_PROGRAM says ghostty or kitty, or KITTY_WINDOW_ID is set, and not under tmux) the pane holds an Image. The sidecar writes each frame as raw RGB to a file in the temp directory, renaming it into place so no read sees half a frame, and the hooks module blits the file's name with a new generation number; the terminal reads the pixels itself, nothing crosses the plugin. DOOM's 320 by 200 was drawn for a 4:3 screen with pixels 1.2 times taller than wide, so the frame goes out as 320 by 240 and fills a 4:3 box exactly. If the terminal refuses the picture, the mod restarts on the character display and stays there for the session.

Characters. Elsewhere each frame is a terminal Raster: every cell shows a 2 by 2 block of pixels as one quadrant glyph (▘ ▝ ▀ ▖ ▌ ▞ ▛ ▗ ▚ ▐ ▜ ▄ ▙ ▟) in two colours, chosen as the split of the four that loses least, so 127 columns show 254 by 90 pixels. Each picture pixel covers a small rectangle of the 640 by 400 frame, and --filter picks how that rectangle becomes one colour: mode (the default) averages only the pixels of the rectangle's most common colour, which keeps edges crisp; box averages everything; nearest takes one pixel. A Raster frame may hold 1024 distinct colour pairs; past that the rarest pairs are redirected to their nearest kept pair, which measures within 0.1 dB of no cap at all, and the redirects are remembered from frame to frame so the search runs only for pairs whose target dropped out.

The pane's height follows the terminal's: the band above the prompt reports the viewport's rows on every draw, and a bare /doom keeps the picture plus its hotkey rows on screen.

Settings in /config: Frames per second (default 15); Display (auto, image, cells); Cell shape, your cell's width over its height (default 0.47; raise it if bands show beside the picture, lower it if above and below); Cells glyphs (quad, or half for fonts whose quadrants leave seams); Cells filter (mode, box, nearest).

Install

/plugin install slop-doom --marketplace wobondar/claude-plugins

Answer y to add the marketplace, then pick a scope. To run from a checkout instead: claude --plugin-dir ./mods/slop-doom.

Develop

claude plugin validate ./mods/slop-doom
claude plugin test ./mods/slop-doom
bun ./mods/slop-doom/sidecar/doom.ts --cols 127 --rows 45 --fps 20 --keys /tmp/keys.txt
bun ./mods/slop-doom/sidecar/doom.ts --fps 20 --out /tmp/doom.rgb

The first prints F <base64 cells> lines and, every 200 frames, how long packing took; the second writes 320 by 240 RGB frames to the file and prints I <generation>. The key-hold model, the line splitter, the display choice and the pane shapes live in hooks/wire.ts; the quadrant packer and the pair budget in sidecar/cells.ts, with no Node imports so the plugin tests cover it. The WASM build is pi-doom's, unchanged; rebuilding it needs Emscripten and doomgeneric_pi.c from that repo.

Credits

  • id Software for DOOM and the shareware episode
  • doomgeneric by Ozkan Sezer, the portable DOOM this runs
  • pi-doom by Mario Zechner, whose Emscripten build this uses as is, and whose half-block rendering the character display grew from
  • opentui-doom by Muhammed Aksam, which showed DOOM in a TUI first

License

GPL-2.0, see LICENSE. The rest of this repository is MIT; this mod is the exception because it embeds DOOM.

Source 3 files
hooks/register.tsx 288 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ResolveInput } from 'claude-code'
3
4import type { DoomScreen } from '../types'
5import { CONTROLS, DEFAULT_CELL_ASPECT, IMAGE_DENIES_BEFORE_FALLBACK, NATIVE_HEIGHT, NATIVE_WIDTH, WEAPONS, autoColumns, blankCells, cellsLength, clampCellAspect, clampFps, emptyLog, isImageRefusal, parseDisplay, pickDisplay, press, releaseAll, releaseDue, serialize, shapeFor, splitLines } from './wire'
6import type { Control, Display, KeyLog, Shape } from './wire'
7
8const PANE = 'slop-doom'
9const RELEASE_TICK_MS = 40
10
11const idleScreen = (): DoomScreen => ({ isRunning: false, display: 'cells', picture: null, columns: 80, rows: 30, status: 'idle', frames: 0 })
12
13const screen = atom({ plugin: 'slop-doom', key: 'screen' } as const, idleScreen())
14
15let fps = 15
16let filter = 'mode'
17let blocks = 'quad'
18let cellAspect = DEFAULT_CELL_ASPECT
19let configDisplay: Display | 'auto' = 'auto'
20let frame: string | null = null
21let keys: KeyLog = emptyLog()
22let keysPath = ''
23let outPath = ''
24let runner = 'bun'
25let generation = 0
26let imageDenies = 0
27let hasImageFailed = false
28let terminalRows: number | undefined
29let releaseTimer: { cancel: () => void } | null = null
30
31const writeKeys = async ($: EngineInterface): Promise<void> => {
32  if (keysPath === '') return
33  await $.fs.write(keysPath, serialize(keys)).catch(() => undefined)
34}
35
36const tap = async ($: EngineInterface, control: Control): Promise<void> => {
37  const s = await read($, screen)
38  if (!s.isRunning) return
39  keys = press(keys, control, await $.clock.now())
40  await writeKeys($)
41}
42
43const startReleasing = ($: EngineInterface): void => {
44  releaseTimer?.cancel()
45  let chain = Promise.resolve()
46  releaseTimer = $.clock.every(RELEASE_TICK_MS, () => {
47    chain = chain.then(async () => {
48      const now = await $.clock.now()
49      const next = releaseDue(keys, now)
50      if (next === keys) return
51      keys = next
52      await writeKeys($)
53    })
54  })
55}
56
57const pickRunner = async ($: EngineInterface): Promise<string> => {
58  for (const candidate of ['bun', 'node']) {
59    const probe = await $.process.run([candidate, '--version'], { timeoutMs: 5000 }).catch(() => null)
60    if (probe !== null && probe.exitCode === 0) return candidate
61  }
62  return 'bun'
63}
64
65const pickDisplayHere = async ($: EngineInterface): Promise<Display> => {
66  if (configDisplay !== 'auto') return configDisplay
67  if (hasImageFailed) return 'cells'
68  return pickDisplay({
69    termProgram: await $.env.get('TERM_PROGRAM'),
70    kittyWindow: await $.env.get('KITTY_WINDOW_ID'),
71    tmux: await $.env.get('TMUX'),
72  })
73}
74
75const scratchDir = async ($: EngineInterface): Promise<string> => {
76  if (await $.fs.exists('/dev/shm')) return '/dev/shm'
77  const tmp = await $.env.get('TMPDIR')
78  return tmp === undefined || tmp === '' ? '/tmp' : tmp.replace(/\/$/, '')
79}
80
81const fallBackToCells = async ($: EngineInterface, shape: Shape, reason: string): Promise<void> => {
82  hasImageFailed = true
83  await halt($)
84  await launch($, shapeFor(shape.columns, 'cells', cellAspect), 'cells')
85  await update($, screen, held => ({ ...held, status: `cells (no pictures here: ${reason.slice(0, 80)})` }))
86}
87
88const showPicture = async ($: EngineInterface, shape: Shape, pictureGeneration: number): Promise<boolean> => {
89  const result = await $.ui.blit({
90    requestId: PANE,
91    key: 'screen',
92    source: { file: outPath, format: 'rgb', width: NATIVE_WIDTH, height: NATIVE_HEIGHT, generation: pictureGeneration },
93  }).catch(() => ({ deny: 'blit threw' }))
94  if (result.deny === undefined) {
95    imageDenies = 0
96    return true
97  }
98  imageDenies++
99  if (isImageRefusal(result.deny) || imageDenies >= IMAGE_DENIES_BEFORE_FALLBACK) {
100    await fallBackToCells($, shape, result.deny)
101    return false
102  }
103  return true
104}
105
106const pump = async ($: EngineInterface, shape: Shape, display: Display, mine: number): Promise<void> => {
107  const argv = [runner, `${$.plugin.root}/sidecar/doom.ts`, '--keys', keysPath, '--cols', String(shape.columns), '--rows', String(shape.rows), '--fps', String(fps), '--filter', filter, '--blocks', blocks]
108  if (display === 'image') argv.push('--out', outPath)
109  const child = $.process.spawn({ argv, cwd: $.plugin.root })
110  let rest = ''
111  let frames = 0
112  let status = 'exited'
113  try {
114    for await (const piece of child) {
115      if (mine !== generation) break
116      if (piece.stream === 'stderr') {
117        status = piece.text.trim().slice(0, 200)
118        continue
119      }
120      const split = splitLines(rest, piece.text)
121      rest = split.rest
122      let latestCells: string | null = null
123      let latestPicture = 0
124      for (const line of split.lines) {
125        if (line.startsWith('F ')) latestCells = line.slice(2)
126        else if (line.startsWith('I ')) latestPicture = Number(line.slice(2))
127        else if (line.startsWith('L ')) status = line.slice(2)
128      }
129      if (latestCells !== null) {
130        frame = latestCells
131        frames++
132        void $.ui.blit({ requestId: PANE, key: 'screen', cells: latestCells }).catch(() => undefined)
133      }
134      if (latestPicture > 0) {
135        frames++
136        const isStillImage = await showPicture($, shape, latestPicture)
137        if (!isStillImage) return
138      }
139      const isCheckpoint = (latestCells !== null || latestPicture > 0) && (frames === 1 || frames % 200 === 0)
140      if (isCheckpoint) {
141        await update($, screen, held => ({
142          ...held,
143          frames,
144          status: held.status.startsWith('starting') ? 'running' : held.status,
145          picture: held.picture === null ? null : { ...held.picture, generation: latestPicture },
146        }))
147      }
148    }
149  } catch (error) {
150    status = error instanceof Error ? error.message : String(error)
151  }
152  if (mine !== generation) return
153  await update($, screen, held => ({ ...held, isRunning: false, status: `stopped: ${status}` }))
154}
155
156const launch = async ($: EngineInterface, shape: Shape, display: Display): Promise<void> => {
157  generation++
158  const mine = generation
159  keys = emptyLog()
160  frame = null
161  imageDenies = 0
162  const dir = await scratchDir($)
163  const stamp = Math.floor(await $.clock.now())
164  keysPath = `${dir}/slop-doom-${stamp}.keys`
165  outPath = `${dir}/slop-doom-${stamp}.rgb`
166  await writeKeys($)
167  runner = await pickRunner($)
168  const picture = display === 'image' ? { file: outPath, width: NATIVE_WIDTH, height: NATIVE_HEIGHT, generation: 0 } : null
169  await update($, screen, () => ({ isRunning: true, display, picture, columns: shape.columns, rows: shape.rows, status: `starting with ${runner}`, frames: 0 }))
170  startReleasing($)
171  void pump($, shape, display, mine)
172}
173
174const halt = async ($: EngineInterface): Promise<void> => {
175  generation++
176  keys = releaseAll(keys)
177  await writeKeys($)
178  releaseTimer?.cancel()
179  releaseTimer = null
180  frame = null
181  await update($, screen, held => ({ ...held, isRunning: false, status: 'closed' }))
182}
183
184const draw = async ($: EngineInterface, e: ResolveInput) => {
185  const s = await read($, screen)
186  if (e.surface !== 'terminal') {
187    const { Box, Text } = $.ui.resolve(e)
188    return (
189      <Box paddingX={1}>
190        <Text dimColor>Slop Doom draws into a terminal; this surface has no Raster or Image.</Text>
191      </Box>
192    )
193  }
194  const { Box, Button, Image, Raster, Text } = $.ui.resolve(e)
195  const shape = { columns: s.columns, rows: s.rows }
196  const strip = (controls: readonly Control[]) => controls.map(c => <Button key={c.key} hotkey={c.hotkey} plain onPress={() => tap($, c)}>{c.label}</Button>)
197  const picture = s.display === 'image' && s.picture !== null
198    ? <Image key="screen" source={{ file: s.picture.file, format: 'rgb', width: s.picture.width, height: s.picture.height, generation: s.picture.generation }} columns={shape.columns} rows={shape.rows} alt="DOOM" />
199    : <Raster key="screen" columns={shape.columns} rows={shape.rows} cells={frame !== null && frame.length === cellsLength(shape) ? frame : blankCells(shape)} />
200  return (
201    <Box flexDirection="column" paddingX={1}>
202      {picture}
203      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
204        {strip(CONTROLS)}
205      </Box>
206      <Box flexDirection="row" columnGap={1}>
207        {strip(WEAPONS)}
208        <Text dimColor>  {s.status}</Text>
209      </Box>
210    </Box>
211  )
212}
213
214type Ask = { readonly columns?: number; readonly fps?: number; readonly display?: Display; readonly isClose: boolean; readonly isBad: boolean }
215
216const parseAsk = (args: string): Ask => {
217  const words = args.trim().split(/\s+/).filter(w => w !== '')
218  let ask: Ask = { isClose: false, isBad: false }
219  for (const word of words) {
220    if (word === 'close') return { isClose: true, isBad: false }
221    const display = parseDisplay(word)
222    if (display !== undefined) {
223      ask = { ...ask, display }
224      continue
225    }
226    const n = Number(word)
227    if (!Number.isFinite(n)) return { isClose: false, isBad: true }
228    ask = ask.columns === undefined ? { ...ask, columns: n } : { ...ask, fps: n }
229  }
230  return ask
231}
232
233export const register: Register = (on, options) => {
234  fps = clampFps(typeof options.fps === 'number' ? options.fps : 15)
235  filter = options.filter === 'box' || options.filter === 'nearest' ? options.filter : 'mode'
236  blocks = options.blocks === 'half' ? 'half' : 'quad'
237  cellAspect = clampCellAspect(typeof options.cell === 'number' ? options.cell : DEFAULT_CELL_ASPECT)
238  configDisplay = options.display === 'image' || options.display === 'cells' ? options.display : 'auto'
239
240  on('session.start', async ($, e, next) => {
241    await update($, screen, () => idleScreen())
242    await $.command.register({
243      name: 'doom',
244      description: 'Slop Doom: play DOOM in a pane, or close it',
245      argumentHint: '[columns [fps]] [image|cells] | close',
246      immediate: true,
247    })
248    return next(e)
249  })
250
251  on('command.run', { command: 'doom' }, async ($, e) => {
252    const ask = parseAsk(e.args)
253    if (ask.isClose) {
254      await halt($)
255      await $.ui.close({ id: PANE })
256      return { text: 'Doom closed.' }
257    }
258    if (ask.isBad) return { text: 'Usage: /doom [columns [fps]] [image|cells] | close' }
259    if (ask.fps !== undefined) fps = clampFps(ask.fps)
260    const display = ask.display ?? await pickDisplayHere($)
261    const shape = shapeFor(ask.columns ?? autoColumns(e.presentation.columns, terminalRows, cellAspect), display, cellAspect)
262    const opened = await $.ui.open({ id: PANE, title: 'Slop Doom', focus: true, rows: shape.rows + 4, columns: shape.columns + 4 })
263    if (!opened.isPlaced) return { text: 'Doom is waiting for a wider terminal.' }
264    const s = await read($, screen)
265    if (!s.isRunning || s.columns !== shape.columns || s.display !== display || ask.fps !== undefined) {
266      await halt($)
267      await launch($, shape, display)
268    }
269    const size = display === 'image' ? `${NATIVE_WIDTH}x${NATIVE_HEIGHT} pixels over ${shape.columns}x${shape.rows} cells` : `${shape.columns * (blocks === 'quad' ? 2 : 1)}x${shape.rows * 2} in ${shape.columns}x${shape.rows} cells`
270    return { text: `Doom is up at ${size}, ${fps} fps. w/s move, a/d turn, q/e strafe, f fire, u use, n enter, x menu. Esc hands the keys back.` }
271  })
272
273  on('ui.close', { id: PANE }, async ($, e, next) => {
274    await halt($)
275    return next(e)
276  })
277
278  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
279    terminalRows = e.viewport?.rows ?? terminalRows
280    return next(e)
281  })
282
283  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
284    terminalRows = e.viewport?.rows ?? terminalRows
285    return draw($, e)
286  })
287}
288
hooks/wire.ts 213 lines
1export const KEY = {
2  right: 0xae,
3  left: 0xac,
4  up: 0xad,
5  down: 0xaf,
6  strafeLeft: 0xa0,
7  strafeRight: 0xa1,
8  use: 0xa2,
9  fire: 0xa3,
10  escape: 27,
11  enter: 13,
12  tab: 9,
13  pause: 0xff,
14  shift: 0x80 + 0x36,
15  y: 0x79,
16  n: 0x6e,
17} as const
18
19export type Control = {
20  readonly key: string
21  readonly hotkey: string
22  readonly label: string
23  readonly codes: readonly number[]
24  readonly toggles?: true
25}
26
27export const CONTROLS: readonly Control[] = [
28  { key: 'forward', hotkey: 'w', label: '↑', codes: [KEY.up] },
29  { key: 'back', hotkey: 's', label: '↓', codes: [KEY.down] },
30  { key: 'turnLeft', hotkey: 'a', label: '↶', codes: [KEY.left] },
31  { key: 'turnRight', hotkey: 'd', label: '↷', codes: [KEY.right] },
32  { key: 'strafeLeft', hotkey: 'q', label: '←', codes: [KEY.strafeLeft] },
33  { key: 'strafeRight', hotkey: 'e', label: '→', codes: [KEY.strafeRight] },
34  { key: 'fire', hotkey: 'f', label: 'fire', codes: [KEY.fire] },
35  { key: 'use', hotkey: 'u', label: 'use', codes: [KEY.use] },
36  { key: 'run', hotkey: 'r', label: 'run', codes: [KEY.shift], toggles: true },
37  { key: 'enter', hotkey: 'n', label: 'enter', codes: [KEY.enter] },
38  { key: 'menu', hotkey: 'x', label: 'menu', codes: [KEY.escape] },
39  { key: 'yes', hotkey: 'y', label: 'yes', codes: [KEY.y] },
40  { key: 'map', hotkey: 'm', label: 'map', codes: [KEY.tab] },
41  { key: 'pause', hotkey: 'p', label: 'pause', codes: [KEY.pause] },
42]
43
44const WEAPON_NAMES = ['fist', 'pistol', 'shotgun', 'chaingun', 'rocket', 'plasma', 'bfg'] as const
45
46export const WEAPONS: readonly Control[] = WEAPON_NAMES.map((name, i) => ({
47  key: `weapon${i + 1}`,
48  hotkey: String(i + 1),
49  label: name,
50  codes: [0x31 + i],
51}))
52
53export const HOLD_MS = 160
54
55export type KeyEvent = { readonly seq: number; readonly isDown: boolean; readonly code: number }
56
57export type Held = { readonly code: number; readonly until: number; readonly isToggled: boolean }
58
59export type KeyLog = {
60  readonly seq: number
61  readonly held: readonly Held[]
62  readonly events: readonly KeyEvent[]
63}
64
65export const emptyLog = (): KeyLog => ({ seq: 0, held: [], events: [] })
66
67const KEPT_EVENTS = 64
68
69const append = (log: KeyLog, isDown: boolean, code: number): KeyLog => {
70  const seq = log.seq + 1
71  return { ...log, seq, events: [...log.events, { seq, isDown, code }].slice(-KEPT_EVENTS) }
72}
73
74export const press = (log: KeyLog, control: Control, now: number): KeyLog => {
75  let out = log
76  for (const code of control.codes) {
77    const already = out.held.find(h => h.code === code)
78    if (control.toggles) {
79      if (already?.isToggled) {
80        out = append(out, false, code)
81        out = { ...out, held: out.held.filter(h => h.code !== code) }
82      } else {
83        if (already === undefined) out = append(out, true, code)
84        out = { ...out, held: [...out.held.filter(h => h.code !== code), { code, until: Number.POSITIVE_INFINITY, isToggled: true }] }
85      }
86      continue
87    }
88    if (already === undefined) out = append(out, true, code)
89    out = { ...out, held: [...out.held.filter(h => h.code !== code), { code, until: now + HOLD_MS, isToggled: false }] }
90  }
91  return out
92}
93
94export const releaseDue = (log: KeyLog, now: number): KeyLog => {
95  let out = log
96  for (const h of log.held) {
97    if (h.until > now) continue
98    out = append(out, false, h.code)
99    out = { ...out, held: out.held.filter(k => k.code !== h.code) }
100  }
101  return out
102}
103
104export const releaseAll = (log: KeyLog): KeyLog => {
105  let out = log
106  for (const h of log.held) out = append(out, false, h.code)
107  return { ...out, held: [] }
108}
109
110export const serialize = (log: KeyLog): string => log.events.map(e => `${e.seq} ${e.isDown ? 'd' : 'u'} ${e.code}`).join('\n') + '\n'
111
112export type Split = { readonly lines: readonly string[]; readonly rest: string }
113
114export const splitLines = (rest: string, chunk: string): Split => {
115  const text = rest + chunk
116  const parts = text.split('\n')
117  const tail = parts.pop() ?? ''
118  return { lines: parts, rest: tail }
119}
120
121export type Shape = { readonly columns: number; readonly rows: number }
122
123export type Display = 'image' | 'cells'
124
125export const MIN_COLUMNS = 40
126export const MAX_COLUMNS = 512
127export const MAX_IMAGE_COLUMNS = 255
128export const MAX_AUTO_COLUMNS = 160
129export const PANE_FRAME_COLUMNS = 6
130
131// DOOM was drawn for 4:3 screens. How many rows make a 4:3 box depends on the
132// cell's own shape, width over height: 7x15 and 8x17 coding fonts sit near
133// 0.47, a 1:2 bitmap font at 0.5. Nothing reports it, so it is a setting.
134export const DEFAULT_CELL_ASPECT = 0.47
135const PICTURE_ASPECT = 4 / 3
136
137export const clampCellAspect = (aspect: number): number => (Number.isFinite(aspect) ? Math.max(0.3, Math.min(0.8, aspect)) : DEFAULT_CELL_ASPECT)
138
139const rowsPerColumn = (cellAspect: number): number => cellAspect / PICTURE_ASPECT
140
141export const NATIVE_WIDTH = 320
142export const NATIVE_HEIGHT = 240
143
144// Rows the pane cannot give the picture: its frame, the two hotkey rows, and
145// the transcript lines, prompt and footer that must stay on screen with it.
146export const CHROME_ROWS = 16
147
148export const autoColumns = (terminalColumns: number, terminalRows?: number, cellAspect = DEFAULT_CELL_ASPECT): number => {
149  const byWidth = Math.min(MAX_AUTO_COLUMNS, terminalColumns - PANE_FRAME_COLUMNS)
150  if (terminalRows === undefined) return byWidth
151  const byHeight = Math.floor((terminalRows - CHROME_ROWS) / rowsPerColumn(cellAspect))
152  return Math.max(MIN_COLUMNS, Math.min(byWidth, byHeight))
153}
154
155export const clampFps = (fps: number): number => (Number.isFinite(fps) ? Math.max(5, Math.min(35, Math.round(fps))) : 15)
156
157export const shapeFor = (columns: number, display: Display = 'cells', cellAspect = DEFAULT_CELL_ASPECT): Shape => {
158  const max = display === 'image' ? MAX_IMAGE_COLUMNS : MAX_COLUMNS
159  const cols = Math.max(MIN_COLUMNS, Math.min(max, Math.floor(columns)))
160  const rows = Math.max(12, Math.min(255, Math.round(cols * rowsPerColumn(cellAspect))))
161  return { columns: cols, rows }
162}
163
164export type DisplayHints = {
165  readonly termProgram?: string
166  readonly kittyWindow?: string
167  readonly tmux?: string
168}
169
170const IMAGE_TERMINALS = new Set(['ghostty', 'kitty', 'wezterm'])
171
172export const pickDisplay = (hints: DisplayHints): Display => {
173  if (hints.tmux !== undefined && hints.tmux !== '') return 'cells'
174  if (hints.kittyWindow !== undefined && hints.kittyWindow !== '') return 'image'
175  return IMAGE_TERMINALS.has((hints.termProgram ?? '').toLowerCase()) ? 'image' : 'cells'
176}
177
178export const parseDisplay = (word: string): Display | undefined => (word === 'image' || word === 'cells' ? word : undefined)
179
180export const IMAGE_DENIES_BEFORE_FALLBACK = 20
181
182export const isImageRefusal = (deny: string): boolean => /\balt\b|image|picture|placeholder/i.test(deny)
183
184export const cellsLength = ({ columns, rows }: Shape): number => Math.ceil((columns * rows * 12) / 3) * 4
185
186export const blankCells = ({ columns, rows }: Shape): string => {
187  const bytes = new Uint8Array(columns * rows * 12)
188  const view = new DataView(bytes.buffer)
189  for (let i = 0; i < columns * rows; i++) {
190    view.setUint32(i * 12, 0x2580, true)
191    view.setUint32(i * 12 + 4, 0x101018, true)
192    view.setUint32(i * 12 + 8, 0x101018, true)
193  }
194  return toBase64(bytes)
195}
196
197const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
198
199export const toBase64 = (bytes: Uint8Array): string => {
200  let out = ''
201  for (let i = 0; i < bytes.length; i += 3) {
202    const a = bytes[i] as number
203    const b = bytes[i + 1]
204    const c = bytes[i + 2]
205    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
206    out += B64[(n >> 18) & 63]
207    out += B64[(n >> 12) & 63]
208    out += b === undefined ? '=' : B64[(n >> 6) & 63]
209    out += c === undefined ? '=' : B64[n & 63]
210  }
211  return out
212}
213
types/index.d.ts 27 lines
1export type DoomDisplay = 'image' | 'cells'
2
3export type DoomPicture = {
4  file: string
5  width: number
6  height: number
7  generation: number
8}
9
10export type DoomScreen = {
11  isRunning: boolean
12  display: DoomDisplay
13  picture: DoomPicture | null
14  columns: number
15  rows: number
16  status: string
17  frames: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'slop-doom': {
23      screen: DoomScreen
24    }
25  }
26}
27