Halloween theme for Claude Code: a haunted frame of pumpkins, leaves and skulls around the prompt, bats flying across the conversation, spooky spinner words…

A Halloween theme for the Claude Code terminal.
<img src="assets/demo.svg" alt="Claude Code with the Halloween theme: bats fly across the conversation above a prompt framed by flickering jack-o'-lanterns, leaves and skulls" width="100%">
In Claude Code:
/plugin marketplace add phuclh/claude-halloween
/plugin install halloween@phuclh-plugins
Or from a shell:
claude plugin marketplace add phuclh/claude-halloween
claude plugin install halloween@phuclh-plugins
Then start a new session.
/halloween turns the theme off and on. Your choice is remembered across sessions./halloween sounds turns the sounds on and off, and is remembered too. Sounds play on macOS only: Claude Code plays them with afplay, and plays nothing on Linux or Windows."tui": "fullscreen" in ~/.claude/settings.json, or switch with /tui. On other screens you get the spooky words and a small garland on the hint line.While animating, the theme redraws the screen 8 times a second, which costs about 8–11% of one CPU core on a 110×40 terminal. Once paused, it costs nothing.
With sounds on, each keystroke starts a short afplay, at most one every 70 ms. That adds about 0.02 s of CPU per keystroke, and only while you type.
claude --plugin-dir ./plugins/halloween # run it from this folder
claude plugin validate ./plugins/halloween # check the manifest and hooks
claude plugin test ./plugins/halloween # run the tests
The demo above is drawn by the plugin's own frame and flight code. To render it again after a change:
npx -p typescript tsc -p scripts
node .demo-build/scripts/render-demo.js > assets/demo.svg
The sounds are synthesized from oscillators, noise and filters by scripts/render-sounds.ts; the laughs come from a small formant synthesizer. Nothing is recorded or sampled. To render them again into plugins/halloween/sounds/:
npx -p typescript tsc -p scripts
node .demo-build/scripts/render-sounds.jshooks/register.tsx 614 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement, RenderInput, RenderNode, Timer } from 'claude-code'
3
4import { flockAt } from './bats'
5import { bottomRuleCells, createFrameLayout, sideGlyph, topRuleCells } from './frame'
6import type { Cell } from './frame'
7import { isUnderBats } from './selection'
8import { createEnterDeck, keystrokeClip } from './sounds'
9
10const isEnabled = atom({ plugin: 'halloween', key: 'isEnabled' } as const, true)
11const hasSounds = atom({ plugin: 'halloween', key: 'hasSounds' } as const, false)
12const draftRows = atom({ plugin: 'halloween', key: 'draftRows' } as const, 1)
13const frameTick = atom({ plugin: 'halloween', key: 'frameTick' } as const, 0)
14const flightTick = atom({ plugin: 'halloween', key: 'flightTick' } as const, 0)
15const isAwake = atom({ plugin: 'halloween', key: 'isAwake' } as const, false)
16const isSelecting = atom({ plugin: 'halloween', key: 'isSelecting' } as const, false)
17
18const STORE_KEY = 'isEnabled'
19const SOUNDS_STORE_KEY = 'hasSounds'
20const SOUNDS_ARGUMENT = 'sounds'
21/**
22 * One clock drives everything: the bats move every tick, the frame every
23 * other tick, so both land in the same redraw. With no typing and no turn
24 * running for IDLE_MS the clock stops and the scene sleeps, costing nothing.
25 */
26const ANIMATION_TICK_MS = 125
27const TICKS_PER_FRAME_STEP = 2
28const IDLE_MS = 90_000
29const IDLE_TICKS = Math.round(IDLE_MS / ANIMATION_TICK_MS)
30const MIN_FRAME_COLUMNS = 30
31/**
32 * How long a selection stays the same before the bats fly again: by then the
33 * drag has ended and copy-on-select has taken the text.
34 */
35const SELECTION_SETTLE_MS = 1_000
36const SELECTION_SETTLE_TICKS = Math.round(SELECTION_SETTLE_MS / ANIMATION_TICK_MS)
37
38/** The hint line's garland, for where the frame cannot be drawn. */
39const HINT_TAIL = ' 🎃 🍁 💀 🍂 🦇'
40const SPOOKY_MODE = '🦇 spooky season'
41
42/**
43 * Where the prompt box sits relative to the footer's mode labels, which the
44 * terminal keeps right-aligned on the first row under the box whatever else
45 * the footer holds (a status line or none): the bottom rule is the row above
46 * them, the draft rows above it, then the top rule. The labels end this many
47 * columns short of the right edge.
48 */
49const MODES_RIGHT_MARGIN = 2
50const BOTTOM_RULE_ROW = -1
51
52const BAT = '🦇'
53
54/**
55 * Screen rows below the conversation besides the draft: the line above the
56 * prompt box, its two rules, the status line, the hint line and room for a
57 * notice. Erring high keeps bats off the conversation's top edge.
58 */
59const PROMPT_AREA_ROWS = 8
60
61/** The conversation rows whose newest one carries the flock. */
62type TranscriptSite = RenderInput<'AssistantMessage' | 'UserMessage' | 'CommandOutput' | 'ToolUse'>
63
64const SPINNER_WORDS = [
65 'Haunting',
66 'Brewing',
67 'Conjuring',
68 'Cackling',
69 'Summoning',
70 'Bewitching',
71 'Lurking',
72 'Creeping',
73 'Hexing',
74 'Spellcasting',
75 'Carving pumpkins',
76 'Howling',
77 'Rattling bones',
78 'Stirring the cauldron',
79]
80
81const PAST_WORDS = [
82 'Haunted',
83 'Brewed',
84 'Conjured',
85 'Cackled',
86 'Summoned',
87 'Bewitched',
88 'Lurked',
89 'Crept',
90 'Hexed',
91 'Carved',
92 'Howled',
93]
94
95/** The terminal's width as the prompt box last drew, for wrapping the draft. */
96let promptColumns = 80
97
98/** This load's scatter of props on the frame: rolled once, so redraws keep it. */
99const frameLayout = createFrameLayout()
100
101/** This load's flock: every journey of every bat follows from it. */
102const flockSeed = Math.floor(Math.random() * 1_000_000)
103
104/** The order each conversation row was first drawn in; the latest is the newest row. */
105const rowOrder = new Map<string, number>()
106
107/** The running animation clock and how many ticks have passed with nothing happening. */
108let animation: { timer: Timer; ticks: number; idleTicks: number } | undefined
109let isTurnRunning = false
110
111/** How many rows of each on-screen conversation row show, by its id. */
112const visibleRows = new Map<string, number>()
113
114/** The row carrying the flock (`''` while none on screen does), and its order. */
115let flockRow = { requestId: '', order: -1 }
116
117/** The selection's text as the last tick read it, and for how many ticks it has stayed so. */
118let selection: { text: string | undefined; quietTicks: number } = { text: undefined, quietTicks: 0 }
119
120/** When the last keystroke sound played, in the clock's milliseconds. */
121let lastKeystrokeAt = Number.NEGATIVE_INFINITY
122
123/** Deals the next Enter sound, and stops the one still playing when it does. */
124const nextEnterClip = createEnterDeck()
125let enterClip: AbortController | undefined
126
127function hashOf(text: string): number {
128 let hash = 0
129
130 for (const character of text) {
131 hash = (hash * 31 + character.charCodeAt(0)) >>> 0
132 }
133
134 return hash
135}
136
137/**
138 * Picks a word from the list by the engine's own sampled word, so the same
139 * turn keeps the same spooky word on every redraw.
140 */
141function spookyWordFor(word: string, words: readonly string[]): string {
142 return words[hashOf(word) % words.length] ?? word
143}
144
145/** Rows the draft takes in the prompt box: its lines, each wrapped to the box. */
146function rowsOfDraft(text: string): number {
147 const width = Math.max(10, promptColumns - 4)
148
149 return text
150 .split('\n')
151 .reduce((rows, line) => rows + Math.max(1, Math.ceil(Array.from(line).length / width)), 0)
152}
153
154/** Whether the frame is drawn here: the fullscreen terminal, wide enough for it. */
155function isFramed(e: { surface: string; viewport?: { columns: number; isFullscreen?: boolean } }): e is {
156 surface: 'terminal'
157 viewport: { columns: number; isFullscreen: true }
158} {
159 return e.surface === 'terminal' && e.viewport?.isFullscreen === true && e.viewport.columns >= MIN_FRAME_COLUMNS
160}
161
162/** Cells a label takes in the terminal: two for an emoji, one for the rest. */
163function widthOf(text: string): number {
164 return Array.from(text).reduce((cells, character) => cells + ((character.codePointAt(0) ?? 0) > 0xffff ? 2 : 1), 0)
165}
166
167/**
168 * Draws a row of cells as one Text, a nested Text per run of one color, the
169 * right halves of emoji skipped.
170 */
171function drawCells(Text: ElementTable<'terminal'>['Text'], cells: readonly Cell[]): RenderElement {
172 const runs: { color?: string; text: string }[] = []
173
174 for (const cell of cells) {
175 if (cell === null) {
176 continue
177 }
178
179 const last = runs[runs.length - 1]
180
181 if (last !== undefined && last.color === cell.color) {
182 last.text += cell.glyph
183 } else {
184 runs.push({ color: cell.color, text: cell.glyph })
185 }
186 }
187
188 const children: RenderNode[] = runs.map(run =>
189 run.color === undefined ? run.text : <Text color={run.color}>{run.text}</Text>,
190 )
191
192 return <Text wrap="truncate">{children}</Text>
193}
194
195/**
196 * Whether this conversation row carries the flock: the newest row on screen
197 * takes it, and a row scrolled off screen lets it go to the next on-screen
198 * row drawn. A row that lost it to a newer one finds out on its next tick.
199 */
200function carriesFlock(requestId: string, isOnScreen: boolean): boolean {
201 let order = rowOrder.get(requestId)
202
203 if (order === undefined) {
204 order = rowOrder.size
205 rowOrder.set(requestId, order)
206 }
207
208 if (!isOnScreen) {
209 if (flockRow.requestId === requestId) {
210 flockRow = { requestId: '', order: flockRow.order }
211 }
212
213 return false
214 }
215
216 if (flockRow.requestId === '' || order > flockRow.order) {
217 flockRow = { requestId, order }
218 }
219
220 return flockRow.requestId === requestId
221}
222
223/**
224 * Rows of conversation shown above the row carrying the flock: every
225 * on-screen row's visible rows and the blank row before it. Rows the mod
226 * does not hook (the logo, command records) are left out, so this errs low.
227 */
228function conversationRowsOnScreen(): number {
229 let rows = -1
230
231 for (const visible of visibleRows.values()) {
232 rows += visible + 1
233 }
234
235 return Math.max(0, rows)
236}
237
238/**
239 * Draws a conversation row and, on the row carrying it in the fullscreen
240 * terminal, the flock crossing the conversation above it.
241 *
242 * The flock flies a sky a screen tall, its bottom on this row's bottom, and
243 * only the bats over rows of conversation on screen are drawn: the terminal
244 * pins anything placed past the conversation's edges to those edges, so a bat
245 * there is left out until its journey brings it back over the conversation.
246 *
247 * The flock lands while text is being selected: a mouse copy takes the cells
248 * on screen, so a bat over the selection would be copied in place of the text
249 * under it.
250 */
251async function withFlock($: EngineInterface, e: TranscriptSite, row: RenderElement): Promise<RenderElement> {
252 const { onScreen } = e.props
253
254 if (onScreen == null) {
255 visibleRows.delete(e.requestId)
256 } else {
257 visibleRows.set(e.requestId, onScreen.last - onScreen.first + 1)
258 }
259
260 const isFlying = carriesFlock(e.requestId, onScreen != null)
261
262 if (!isFlying || onScreen == null || e.surface !== 'terminal' || e.viewport?.isFullscreen !== true) {
263 return row
264 }
265
266 if (!(await read($, isEnabled)) || !(await read($, isAwake)) || (await read($, isSelecting))) {
267 return row
268 }
269
270 const { Box, Text } = $.ui.resolve(e)
271 const { columns, rows: screenRows } = e.viewport
272 const skyRows = Math.max(1, screenRows - PROMPT_AREA_ROWS - (await read($, draftRows)))
273 const shownRows = Math.min(skyRows, conversationRowsOnScreen())
274 const skyTop = onScreen.of - skyRows
275 const tick = await read($, flightTick)
276 const bats = flockAt(flockSeed, columns, skyRows, tick).filter(spot => spot.y >= skyRows - shownRows)
277
278 return (
279 <Box flexDirection="column">
280 {row}
281 {bats.map(spot => (
282 <Box position="absolute" top={skyTop + spot.y} left={spot.x}>
283 <Text>{BAT}</Text>
284 </Box>
285 ))}
286 </Box>
287 )
288}
289
290/** Stops the clock and hides what moves: bats go home, the ghost leaves. */
291async function sleep($: EngineInterface): Promise<void> {
292 animation?.timer.cancel()
293 animation = undefined
294 await update($, isAwake, () => false)
295}
296
297/**
298 * Something happened: keeps the clock running, or starts it again after a
299 * sleep. A no-op while the theme is off.
300 */
301async function wake($: EngineInterface): Promise<void> {
302 if (animation !== undefined) {
303 animation.idleTicks = 0
304
305 return
306 }
307
308 if (!(await read($, isEnabled))) {
309 return
310 }
311
312 const clock = {
313 ticks: 0,
314 idleTicks: 0,
315 timer: $.clock.every(ANIMATION_TICK_MS, () => {
316 void advance($, clock)
317 }),
318 }
319 animation = clock
320 await update($, isAwake, () => true)
321}
322
323/**
324 * Lands the flock while the person is selecting text, and lets it fly again
325 * once the selection has stayed the same for SELECTION_SETTLE_MS. The engine
326 * reports a selection while the mouse still drags, so the bats are gone
327 * before copy-on-select reads the screen on release.
328 *
329 * It waits for the selection to settle, not to go: after a click takes the
330 * highlight down the engine keeps reporting the last selection, until the
331 * next prompt or command, and it reads the same as one still highlighted. A
332 * bat crossing a highlight still up changes its text without making it a new
333 * selection. A surface that cannot say counts as no selection.
334 */
335async function checkSelection($: EngineInterface): Promise<void> {
336 const text = (await $.ui.selection().catch(() => undefined))?.text
337 const isLanded = await read($, isSelecting)
338 const isBatCrossing =
339 !isLanded && text !== undefined && selection.text !== undefined && isUnderBats(selection.text, text, BAT)
340
341 if (text === selection.text || isBatCrossing) {
342 selection.quietTicks++
343 } else {
344 selection = { text, quietTicks: 0 }
345 }
346
347 const isNowSelecting = text !== undefined && selection.quietTicks < SELECTION_SETTLE_TICKS
348
349 if (isNowSelecting !== isLanded) {
350 await update($, isSelecting, () => isNowSelecting)
351 }
352}
353
354async function advance($: EngineInterface, clock: NonNullable<typeof animation>): Promise<void> {
355 clock.idleTicks = isTurnRunning ? 0 : clock.idleTicks + 1
356
357 if (clock.idleTicks > IDLE_TICKS) {
358 await sleep($)
359
360 return
361 }
362
363 await checkSelection($)
364 clock.ticks++
365 await update($, flightTick, tick => tick + 1)
366
367 if (clock.ticks % TICKS_PER_FRAME_STEP === 0) {
368 await update($, frameTick, tick => tick + 1)
369 }
370}
371
372async function isSounding($: EngineInterface): Promise<boolean> {
373 return (await read($, hasSounds)) && (await read($, isEnabled))
374}
375
376/**
377 * Plays one of the plugin's clips without holding up the hook that asked; a
378 * terminal with no player plays nothing.
379 */
380function play($: EngineInterface, asset: string, signal?: AbortSignal): void {
381 $.audio.play({ asset }, signal === undefined ? undefined : { signal }).catch(() => undefined)
382}
383
384/** A bat squeaks or lightning cracks for an edit the person typed. */
385async function soundKeystroke($: EngineInterface, edit: { start: number; end: number; inputText: string }): Promise<void> {
386 if (!(await isSounding($))) {
387 return
388 }
389
390 const now = await $.clock.now()
391 const clip = keystrokeClip(edit, now - lastKeystrokeAt)
392
393 if (clip !== undefined) {
394 lastKeystrokeAt = now
395 play($, clip)
396 }
397}
398
399/** The next laugh, wail, organ or thunder in the deck, cutting off the last one. */
400function soundEnter($: EngineInterface): void {
401 enterClip?.abort()
402 enterClip = new AbortController()
403 play($, nextEnterClip(), enterClip.signal)
404}
405
406async function toggleSounds($: EngineInterface): Promise<{ text: string }> {
407 const isNowOn = !(await read($, hasSounds))
408 await update($, hasSounds, () => isNowOn)
409 await $.store.set(SOUNDS_STORE_KEY, isNowOn)
410
411 if (!isNowOn) {
412 enterClip?.abort()
413
414 return { text: 'Spooky sounds off. Run /halloween sounds to bring them back.' }
415 }
416
417 const isThemeComingBack = !(await read($, isEnabled))
418
419 if (isThemeComingBack) {
420 await setTheme($, true)
421 }
422
423 soundEnter($)
424
425 return {
426 text: `🔊 Spooky sounds on${isThemeComingBack ? ', and the Halloween theme with them' : ''}. Bats squeak and lightning cracks as you type, and Enter gets a cackle. Sounds play on macOS.`,
427 }
428}
429
430/** Turns the theme on or off and remembers it; off, the bats go home and any sound stops. */
431async function setTheme($: EngineInterface, isOn: boolean): Promise<void> {
432 await update($, isEnabled, () => isOn)
433 await $.store.set(STORE_KEY, isOn)
434
435 if (isOn) {
436 await wake($)
437
438 return
439 }
440
441 enterClip?.abort()
442 await sleep($)
443}
444
445export const register: Register = on => {
446 on('session.start', async ($, e, next) => {
447 await $.command.register({
448 name: 'halloween',
449 description: 'Toggle the Halloween theme (bats, pumpkins, skulls around the prompt); "sounds" toggles its sounds',
450 argumentHint: '[sounds]',
451 })
452
453 if ((await $.store.get(STORE_KEY)) === false) {
454 await update($, isEnabled, () => false)
455 }
456
457 if ((await $.store.get(SOUNDS_STORE_KEY)) === true) {
458 await update($, hasSounds, () => true)
459 }
460
461 await wake($)
462
463 return next(e)
464 })
465
466 on('command.run', { command: 'halloween' }, async ($, e) => {
467 const option = e.args.trim()
468
469 if (option.toLowerCase() === SOUNDS_ARGUMENT) {
470 return toggleSounds($)
471 }
472
473 if (option !== '') {
474 return {
475 text: `No option "${option}": run /halloween to turn the theme on or off, or /halloween sounds for its sounds.`,
476 }
477 }
478
479 const isNowEnabled = !(await read($, isEnabled))
480 await setTheme($, isNowEnabled)
481
482 if (!isNowEnabled) {
483 return { text: 'Halloween theme off. Run /halloween to bring the bats back.' }
484 }
485
486 return {
487 text: (await read($, hasSounds))
488 ? '🎃 Halloween theme on. The bats are back.'
489 : '🎃 Halloween theme on. The bats are back. Try /halloween sounds for spooky sounds.',
490 }
491 })
492
493 on('prompt.edit', async ($, e, next) => {
494 void wake($)
495 void soundKeystroke($, e)
496 const box = await next(e)
497 const rows = rowsOfDraft(box.text)
498
499 if (rows !== (await read($, draftRows))) {
500 await update($, draftRows, () => rows)
501 }
502
503 return box
504 })
505
506 on('prompt.fill', async ($, e, next) => {
507 void wake($)
508 const filled = await next(e)
509 const box = await $.prompt.read()
510 await update($, draftRows, () => rowsOfDraft(box.text))
511
512 return filled
513 })
514
515 on('prompt.submit', async ($, e, next) => {
516 void wake($)
517 await update($, draftRows, () => 1)
518
519 if (e.origin.kind === 'composer' && (await isSounding($))) {
520 soundEnter($)
521 }
522
523 return next(e)
524 })
525
526 on('turn.start', async ($, e, next) => {
527 isTurnRunning = true
528 void wake($)
529
530 return next(e)
531 })
532
533 on('turn.complete', async ($, e, next) => {
534 isTurnRunning = false
535 void wake($)
536
537 return next(e)
538 })
539
540 on('tool.call', async ($, e, next) => {
541 void wake($)
542
543 return next(e)
544 })
545
546 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
547 if (!(await read($, isEnabled)) || isFramed(e)) {
548 return next(e)
549 }
550
551 return next({ ...e, props: { ...e.props, tail: HINT_TAIL } })
552 })
553
554 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
555 if (!(await read($, isEnabled))) {
556 return next(e)
557 }
558
559 const modes = [...e.props.modes, SPOOKY_MODE]
560 const labels = await next({ ...e, props: { ...e.props, modes } })
561
562 if (!isFramed(e)) {
563 return labels
564 }
565
566 const { columns } = e.viewport
567 promptColumns = columns
568 const rows = await read($, draftRows)
569 const tick = await read($, frameTick)
570 const hasGhost = await read($, isAwake)
571 const { Box, Text } = $.ui.resolve(e)
572 const siteLeft = columns - MODES_RIGHT_MARGIN - widthOf(modes.join(' & '))
573 const topRuleRow = BOTTOM_RULE_ROW - rows - 1
574
575 return (
576 <Box flexDirection="column">
577 {labels}
578 <Box position="absolute" top={topRuleRow} left={-siteLeft} width={columns}>
579 {drawCells(Text, topRuleCells(frameLayout, columns, tick, hasGhost))}
580 </Box>
581 {Array.from({ length: rows }, (_, index) => (
582 <Box position="absolute" top={BOTTOM_RULE_ROW - rows + index} left={columns - 2 - siteLeft}>
583 <Text>{sideGlyph(frameLayout, index)}</Text>
584 </Box>
585 ))}
586 <Box position="absolute" top={BOTTOM_RULE_ROW} left={-siteLeft} width={columns}>
587 {drawCells(Text, bottomRuleCells(frameLayout, columns, tick))}
588 </Box>
589 </Box>
590 )
591 })
592
593 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
594 if (e.surface !== 'terminal' || e.props.message !== null || !(await read($, isEnabled))) {
595 return next(e)
596 }
597
598 return next({ ...e, props: { ...e.props, word: spookyWordFor(e.props.word, SPINNER_WORDS) } })
599 })
600
601 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
602 if (!(await read($, isEnabled))) {
603 return next(e)
604 }
605
606 return next({ ...e, props: { ...e.props, word: spookyWordFor(e.props.word, PAST_WORDS) } })
607 })
608
609 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => withFlock($, e, await next(e)))
610 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => withFlock($, e, await next(e)))
611 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => withFlock($, e, await next(e)))
612 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => withFlock($, e, await next(e)))
613}
614hooks/bats.ts 95 lines1import type { BatSpot } from '../types'
2
3import { noise } from './noise'
4
5/**
6 * A flock of bats crossing a region of the conversation. Every bat flies whole
7 * journeys: in over one side, across at a steady pace while rising or falling
8 * along a gentle arc of its own, out over the other side, then a rest
9 * off-stage before the next one, which may head the other way.
10 *
11 * Steady is what makes it smooth: a bat moves the same one or two cells every
12 * tick, and its height eases along a curve that never leaves the region, so
13 * no frame jumps further than the one before.
14 *
15 * Pure: where every bat is follows from the flock's seed and the tick alone,
16 * so a redraw at any moment lands each bat where it should be.
17 */
18
19/** Cells past each side a journey starts and ends, so a bat enters and leaves out of sight. */
20const OFFSTAGE = 3
21/** Ticks a bat rests off-stage between two journeys, at least and at most. */
22const SHORTEST_REST = 6
23const LONGEST_REST = 40
24
25/** Cells a bat moves each tick: half the flock cruise, half dart. */
26function paceOf(seed: number, bat: number): number {
27 return noise(seed, bat, 1) < 0.5 ? 1 : 2
28}
29
30function restOf(seed: number, bat: number): number {
31 return SHORTEST_REST + Math.floor(noise(seed, bat, 2) * (LONGEST_REST - SHORTEST_REST))
32}
33
34/**
35 * Which way journey `journey` of bat `bat` crosses, and the heights it starts
36 * at, bends toward and ends at, all inside the region's rows.
37 */
38function journeyOf(seed: number, bat: number, journey: number, rows: number) {
39 const roll = (salt: number): number => noise(seed, bat, journey, salt)
40 const lowest = Math.max(0, rows - 1)
41
42 return {
43 isLeftward: roll(1) < 0.5,
44 from: roll(2) * lowest,
45 bend: roll(3) * lowest,
46 to: roll(4) * lowest,
47 }
48}
49
50/**
51 * Where bat `bat` is at `tick`, or `undefined` while it rests between
52 * journeys. Positions lie past the sides as it enters and leaves.
53 */
54export function batAt(seed: number, bat: number, columns: number, rows: number, tick: number): BatSpot | undefined {
55 const pace = paceOf(seed, bat)
56 const flight = Math.ceil((columns + OFFSTAGE * 2) / pace)
57 const cycle = flight + restOf(seed, bat)
58 const shifted = tick + Math.floor(noise(seed, bat, 9) * cycle)
59 const elapsed = shifted % cycle
60
61 if (elapsed >= flight) {
62 return undefined
63 }
64
65 const { isLeftward, from, bend, to } = journeyOf(seed, bat, Math.floor(shifted / cycle), rows)
66 const travelled = elapsed * pace
67 const t = elapsed / flight
68 const height = (1 - t) * (1 - t) * from + 2 * (1 - t) * t * bend + t * t * to
69
70 return {
71 x: isLeftward ? columns + OFFSTAGE - 2 - travelled : -OFFSTAGE + travelled,
72 y: Math.round(height),
73 }
74}
75
76/** How many bats a region of this size holds: more room, a bigger flock. */
77export function flockSize(columns: number, rows: number): number {
78 return Math.min(12, Math.max(4, Math.round((columns * rows) / 500)))
79}
80
81/** Every bat of the flock inside the region at `tick`. */
82export function flockAt(seed: number, columns: number, rows: number, tick: number): BatSpot[] {
83 const spots: BatSpot[] = []
84
85 for (let bat = 0; bat < flockSize(columns, rows); bat++) {
86 const spot = batAt(seed, bat, columns, rows, tick)
87
88 if (spot !== undefined && spot.x >= 0 && spot.x <= columns - 2 && spot.y >= 0 && spot.y < rows) {
89 spots.push(spot)
90 }
91 }
92
93 return spots
94}
95hooks/frame.ts 209 lines1/**
2 * The haunted frame around the prompt box: a violet night edge above with
3 * perched bats and a drifting ghost, a moss vine below where jack-o'-lanterns
4 * grow in random clumps and flicker their glow onto the vine.
5 *
6 * Pure: a session's layout is rolled once, and every frame is drawn from it
7 * and the animation tick, so redraws never reshuffle anything.
8 */
9
10import { noise } from './noise'
11
12/** One terminal cell; `null` is the right half of the emoji to its left. */
13export type Cell = { glyph: string; color?: string } | null
14
15/** Something sitting on a rule, at a fraction of the rule's length. */
16type Prop = { at: number; glyph: string }
17
18export type FrameLayout = {
19 seed: number
20 top: Prop[]
21 bottom: Prop[]
22 corners: { topLeft: string; topRight: string; bottomLeft: string; bottomRight: string }
23 sides: string[]
24}
25
26export const PALETTE = {
27 dusk: '#6B4E9B',
28 moss: '#5E7D32',
29 ember: ['#FFE08A', '#FF9F43', '#D9531E'],
30 ecto: ['#C8FFE0', '#7FE0A8', '#3F8F6A'],
31} as const
32
33const LANTERN = '🎃'
34const GHOST = '👻'
35const RULE = '─'
36const HOT_RULE = '━'
37const VINE_CURL = '∿'
38
39/** Ticks the ghost waits off-stage between two crossings. */
40const GHOST_PAUSE_TICKS = 70
41
42const TOP_POOL = ['🦇', '🦇', '🦇', '🦇', '🦇', '🍁', '🍂', '💀']
43const BOTTOM_POOL = [LANTERN, LANTERN, LANTERN, LANTERN, '🍂', '🍂', '🍁', '🍁', '🦴', '🍄', '💀', '🍬']
44const SIDE_POOL = ['💀', '🦴', '🥀', '🍂', '🍁']
45
46const randomOf = <T>(list: readonly T[], random: () => number): T =>
47 list[Math.floor(random() * list.length)] as T
48
49/**
50 * Scatters props along a rule at irregular gaps: mostly wide, now and then a
51 * tight clump of two or three, so no two sessions look alike.
52 */
53function scatter(pool: readonly string[], density: number, random: () => number): Prop[] {
54 const props: Prop[] = []
55 let at = random() * 0.08
56
57 while (at < 1) {
58 props.push({ at, glyph: randomOf(pool, random) })
59 const isClump = random() < 0.3
60 at += isClump ? 0.015 + random() * 0.02 : (0.05 + random() * 0.14) / density
61 }
62
63 return props
64}
65
66export function createFrameLayout(random: () => number = Math.random): FrameLayout {
67 const top = scatter(TOP_POOL, 0.8, random)
68 const moonSpot = top[Math.floor(random() * top.length)]
69
70 if (moonSpot !== undefined) {
71 moonSpot.glyph = '🌙'
72 }
73
74 return {
75 seed: Math.floor(random() * 1_000_000),
76 top,
77 bottom: scatter(BOTTOM_POOL, 1.1, random),
78 corners: {
79 topLeft: randomOf(['🦉', '💀', '🔮'], random),
80 topRight: randomOf(['🦉', '🔮', '💀'], random),
81 bottomLeft: LANTERN,
82 bottomRight: randomOf([LANTERN, '🍄', '🦴'], random),
83 },
84 sides: Array.from({ length: 12 }, () => randomOf(SIDE_POOL, random)),
85 }
86}
87
88function emptyRule(columns: number, color: string): Cell[] {
89 return Array.from({ length: columns }, () => ({ glyph: RULE, color }))
90}
91
92/**
93 * Lays the props on the rule between the corners, skipping one that would
94 * touch another, and answers the columns they landed on.
95 */
96function placeProps(cells: Cell[], props: readonly Prop[], corners: [string, string]): Map<number, string> {
97 const columns = cells.length
98 const placed = new Map<number, string>([
99 [0, corners[0]],
100 [columns - 2, corners[1]],
101 ])
102 const inner = columns - 8
103
104 for (const prop of props) {
105 const column = 3 + Math.floor(prop.at * inner)
106 const isCrowded = [-2, -1, 0, 1, 2].some(offset => placed.has(column + offset))
107
108 if (!isCrowded && column + 1 < columns - 3) {
109 placed.set(column, prop.glyph)
110 }
111 }
112
113 for (const [column, glyph] of placed) {
114 cells[column] = { glyph }
115 cells[column + 1] = null
116 }
117
118 return placed
119}
120
121/** True for a cell still showing the bare rule, which glow and trails may tint. */
122const isBareRule = (cell: Cell | undefined): boolean =>
123 cell != null && (cell.glyph === RULE || cell.glyph === VINE_CURL)
124
125/**
126 * How far a lantern's glow reaches this tick: mostly its full three cells,
127 * guttering to two or one now and then, like a candle in a draught.
128 */
129function glowReach(seed: number, lantern: number, tick: number): number {
130 const flicker = noise(seed, lantern, tick)
131
132 return flicker < 0.2 ? 1 : flicker < 0.55 ? 2 : 3
133}
134
135/** The moss vine under the prompt box, its lanterns flickering. */
136export function bottomRuleCells(layout: FrameLayout, columns: number, tick: number): Cell[] {
137 const cells = emptyRule(columns, PALETTE.moss)
138
139 cells.forEach((cell, column) => {
140 if (cell !== null && noise(layout.seed, column, 7) < 0.07) {
141 cell.glyph = VINE_CURL
142 }
143 })
144
145 const placed = placeProps(cells, layout.bottom, [layout.corners.bottomLeft, layout.corners.bottomRight])
146 let lantern = 0
147
148 for (const [column, glyph] of placed) {
149 if (glyph !== LANTERN) {
150 continue
151 }
152
153 const reach = glowReach(layout.seed, lantern++, tick)
154
155 for (let distance = 1; distance <= reach; distance++) {
156 const color = PALETTE.ember[distance - 1]
157 const glyphAt = distance === 1 ? HOT_RULE : RULE
158
159 for (const target of [column - distance, column + 1 + distance]) {
160 if (isBareRule(cells[target])) {
161 cells[target] = { glyph: glyphAt, color }
162 }
163 }
164 }
165 }
166
167 return cells
168}
169
170/**
171 * The violet night edge over the prompt box, with a ghost drifting along it
172 * every so often and a fading trail of ectoplasm behind; no ghost while the
173 * scene sleeps.
174 */
175export function topRuleCells(layout: FrameLayout, columns: number, tick: number, hasGhost = true): Cell[] {
176 const cells = emptyRule(columns, PALETTE.dusk)
177 placeProps(cells, layout.top, [layout.corners.topLeft, layout.corners.topRight])
178
179 const ghost = (tick % (columns + GHOST_PAUSE_TICKS)) - 2
180
181 if (hasGhost && ghost >= 2 && ghost < columns - 4) {
182 PALETTE.ecto.forEach((color, index) => {
183 const target = ghost - 1 - index
184
185 if (isBareRule(cells[target])) {
186 cells[target] = { glyph: RULE, color }
187 }
188 })
189
190 if (cells[ghost] === null && ghost > 0) {
191 cells[ghost - 1] = { glyph: RULE, color: PALETTE.dusk }
192 }
193
194 if (cells[ghost + 2] === null) {
195 cells[ghost + 2] = { glyph: RULE, color: PALETTE.dusk }
196 }
197
198 cells[ghost] = { glyph: GHOST }
199 cells[ghost + 1] = null
200 }
201
202 return cells
203}
204
205/** What sits at the right end of the prompt box's draft row `index`. */
206export function sideGlyph(layout: FrameLayout, index: number): string {
207 return layout.sides[index % layout.sides.length] ?? '💀'
208}
209hooks/selection.ts 66 lines1/**
2 * Telling a selection the person made from one the flock changed.
3 *
4 * The engine reads a highlighted selection off the screen, so a bat flying
5 * over it changes its text: the bat stands where the characters under it
6 * were, two cells wide, one or two characters (none where the line's trailing
7 * spaces were trimmed).
8 */
9
10/** Characters of a line a bat can stand over. */
11const MOST_UNDER_BAT = 2
12
13/** Where `part` ends when read from each of `starts`, for the starts it matches at. */
14function pastPart(text: readonly string[], part: readonly string[], starts: ReadonlySet<number>): Set<number> {
15 const ends = new Set<number>()
16
17 for (const start of starts) {
18 if (part.every((character, index) => text[start + index] === character)) {
19 ends.add(start + part.length)
20 }
21 }
22
23 return ends
24}
25
26/** Where a bat standing at each of `starts` ends: over none, one or two characters of a line. */
27function pastBat(text: readonly string[], starts: ReadonlySet<number>): Set<number> {
28 const ends = new Set<number>()
29
30 for (const start of starts) {
31 ends.add(start)
32
33 for (let end = start; end < start + MOST_UNDER_BAT && end < text.length && text[end] !== '\n'; end++) {
34 ends.add(end + 1)
35 }
36 }
37
38 return ends
39}
40
41/**
42 * Whether `current` is `settled` with bats over some of it, and nothing else
43 * changed. Reads both once, character by character, keeping every place the
44 * text read so far can end, so a selection full of bats costs no more than
45 * its length.
46 */
47export function isUnderBats(settled: string, current: string, bat: string): boolean {
48 if (!current.includes(bat)) {
49 return false
50 }
51
52 const text = Array.from(settled)
53 const [first = [], ...rest] = current.split(bat).map(part => Array.from(part))
54 let ends = pastPart(text, first, new Set([0]))
55
56 for (const part of rest) {
57 if (ends.size === 0) {
58 return false
59 }
60
61 ends = pastPart(text, part, pastBat(text, ends))
62 }
63
64 return ends.has(text.length)
65}
66hooks/sounds.ts 74 lines1/**
2 * The theme's sounds: as the person types, a bat squeaks or, now and then,
3 * lightning cracks; when they send a prompt, a laugh, a ghost, a pipe organ
4 * or a thunderclap answers.
5 *
6 * The clips are the plugin's own files under sounds/, synthesized from
7 * oscillators and noise by the repository's scripts/render-sounds.ts.
8 */
9
10export const BAT_CLIPS = ['sounds/bat-1.wav', 'sounds/bat-2.wav', 'sounds/bat-3.wav', 'sounds/bat-4.wav'] as const
11export const CRACK_CLIPS = ['sounds/crack-1.wav', 'sounds/crack-2.wav'] as const
12export const ENTER_CLIPS = [
13 'sounds/laugh-villain.wav',
14 'sounds/laugh-witch.wav',
15 'sounds/laugh-demon.wav',
16 'sounds/ghost.wav',
17 'sounds/organ.wav',
18 'sounds/thunder.wav',
19] as const
20
21/** Fewest milliseconds between two keystroke sounds, so fast typing or a held key never drones. */
22export const KEYSTROKE_GAP_MS = 70
23
24/** The share of keystrokes that crack lightning instead of a bat squeak. */
25const CRACK_SHARE = 0.2
26
27/** One edit of the prompt box, as `prompt.edit` carries it. */
28type Edit = { start: number; end: number; inputText: string }
29
30const randomOf = <T>(list: readonly T[], random: () => number): T =>
31 list[Math.floor(random() * list.length)] as T
32
33/**
34 * The clip an edit plays: none for a bare cursor move or within
35 * KEYSTROKE_GAP_MS of the last one, else a bat or, now and then, a crack.
36 */
37export function keystrokeClip(edit: Edit, msSinceLast: number, random: () => number = Math.random): string | undefined {
38 const isChange = edit.inputText !== '' || edit.end > edit.start
39
40 if (!isChange || msSinceLast < KEYSTROKE_GAP_MS) {
41 return undefined
42 }
43
44 return randomOf(random() < CRACK_SHARE ? CRACK_CLIPS : BAT_CLIPS, random)
45}
46
47/**
48 * Deals the Enter clips: each once in a shuffled round before any comes
49 * again, and never one twice in a row where two rounds meet.
50 */
51export function createEnterDeck(random: () => number = Math.random): () => string {
52 let round: string[] = []
53 let last: string | undefined
54
55 return () => {
56 if (round.length === 0) {
57 round = [...ENTER_CLIPS]
58
59 for (let index = round.length - 1; index > 0; index--) {
60 const other = Math.floor(random() * (index + 1))
61 ;[round[index], round[other]] = [round[other] as string, round[index] as string]
62 }
63
64 if (round[round.length - 1] === last) {
65 ;[round[0], round[round.length - 1]] = [round[round.length - 1] as string, round[0] as string]
66 }
67 }
68
69 last = round.pop() as string
70
71 return last
72 }
73}
74hooks/noise.ts 12 lines1/** A stable pseudo-random value in [0, 1) for a few integers. */
2export function noise(...values: number[]): number {
3 let hash = 2166136261
4
5 for (const value of values) {
6 hash = Math.imul(hash ^ (value | 0), 16777619)
7 hash ^= hash >>> 13
8 }
9
10 return ((hash >>> 0) % 10007) / 10007
11}
12types/index.d.ts 17 lines1/** Where one bat of the flock is, in cells of the region it crosses. */
2export type BatSpot = { x: number; y: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 halloween: {
7 isEnabled: boolean
8 hasSounds: boolean
9 draftRows: number
10 frameTick: number
11 flightTick: number
12 isAwake: boolean
13 isSelecting: boolean
14 }
15 }
16}
17