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

DOOM (the shareware episode) in a Claude Code 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.
| key | does |
|---|---|
w s | forward, back |
a d | turn |
q e | strafe |
f | fire |
u | use: doors, switches |
r | run, toggled |
n | enter |
x | menu (DOOM's Escape) |
y | yes |
m | map |
p | pause |
1 to 7 | fist, 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.
bun on the PATH, or node 23.6 or newer. The sidecar is TypeScript and either runs it as is.
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).
/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.
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.
GPL-2.0, see LICENSE. The rest of this repository is MIT; this mod is the exception because it embeds DOOM.
hooks/register.tsx 288 lines1import { 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}
288hooks/wire.ts 213 lines1export 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}
213types/index.d.ts 27 lines1export 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