Snake in a pane while Claude works; it pauses when Claude is done. /snake turns it on or off.

Gra w węża w panelu obok rozmowy, na czas czekania na Claude'a. Gra się, gdy Claude pracuje, i staje, kiedy skończy.
/snake włącza i wyłącza grę (albo /snake on, /snake off).Esc oddaje klawiaturę.Panel potrzebuje miejsca: najlepiej szerokie okno terminala albo aplikacja Desktop.
Na podstawie moda Snake z hamzafer/claude-code-mods, licencja MIT (plik LICENSE).
Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.
hooks/register.tsx 96 lines1// Snake: play in a pane while Claude works; it pauses when Claude is done.
2// Nothing opens until /snake; /snake again (or /snake off) turns it off.
3// From hamzafer/claude-code-mods (MIT, see LICENSE). Its state lives in this module
4// instead of the atom helpers, which Claude Code 2.1.288 refuses to load.
5import type { Register } from 'claude-code'
6
7const PANE = 'snake'
8const BEST_KEY = 'best'
9const DEFAULT_ROWS = 18 // when a surface reports no body rows
10
11export const register: Register = on => {
12 let isOn = false // /snake turned it on
13 let isWorking = false // a main-loop turn is running, whether or not Snake is on
14 let score = 0
15 let best = 0
16
17 const isPlaying = () => isOn && isWorking
18
19 on('session.start', async ($, e, next) => {
20 const r = await next(e)
21 await $.command
22 .register({ name: 'snake', description: 'Turn Snake on or off: it plays in a pane while Claude works', argumentHint: '[on|off]' })
23 .catch(() => {})
24 const saved = Number(await $.store.get(BEST_KEY).catch(() => 0)) || 0
25 best = Math.max(best, saved)
26 return r
27 })
28
29 on('turn.start', async ($, e, next) => {
30 isWorking = true
31 if (!isOn) return next(e) // opt-in: nothing until /snake
32 $.ui.invalidate('ui.render')
33 void $.ui.open({ id: PANE, title: 'Snake' }).catch(() => {}) // seats from 144 columns when opened unasked
34 return next(e)
35 })
36
37 on('turn.complete', async ($, e, next) => {
38 const r = await next(e)
39 if (!e.agentId) {
40 isWorking = false
41 $.ui.invalidate('ui.render')
42 }
43 return r
44 })
45
46 on('command.run', { command: 'snake' }, async ($, e) => {
47 const arg = e.args.trim().toLowerCase()
48 if (!['', 'on', 'off', 'stop'].includes(arg)) return { text: 'usage: /snake toggles it, /snake on or /snake off says which' }
49 // A bare /snake toggles; `on`, `off` and `stop` say which.
50 const isOff = arg === 'stop' || arg === 'off' || (arg !== 'on' && isOn)
51 if (isOff) {
52 isOn = false
53 $.ui.invalidate('ui.render')
54 await $.ui.close({ id: PANE })
55 return { text: 'Snake is off. /snake (or /snake on) turns it back on.' }
56 }
57 isOn = true // turned on mid-turn: it plays now, not next turn
58 $.ui.invalidate('ui.render')
59 await $.ui.open({ id: PANE, title: 'Snake', focus: true })
60 return { text: 'Snake is on. It plays while Claude works. /snake again turns it off.' }
61 })
62
63 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
64 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
65 const { Text } = $.ui.resolve(e)
66 return <Text dimColor>Snake plays in the terminal or desktop app.</Text>
67 }
68 const { Client } = $.ui.resolve(e)
69 // An exact size, and the same numbers handed to the game: a percentage height has
70 // nothing to stretch against inside a pane, and the desktop fits a region to its content.
71 const width = Math.max(1, e.props.bodyColumns)
72 const height = e.props.scroll.bodyRows > 1 ? e.props.scroll.bodyRows - 1 : DEFAULT_ROWS
73 const props = { isPlaying: isPlaying(), best, columns: width, rows: height }
74 return <Client key="snake" module="./game.tsx" props={props} width={width} height={height} />
75 })
76
77 // The game posts its score whenever it eats or crashes.
78 on('ui.message', async ($, e) => {
79 const data = e.data as { score?: unknown }
80 if (e.element !== 'snake' || typeof data?.score !== 'number') return {}
81 score = data.score
82 if (score > best) {
83 best = score
84 await $.store.set(BEST_KEY, best)
85 }
86 $.ui.invalidate('ui.render')
87 return {}
88 })
89
90 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
91 if (!isPlaying() || score === 0) return next(e)
92 const text = e.props.message ?? e.props.word
93 return next({ ...e, props: { ...e.props, message: `${text} · 🐍 ${score} (best ${best})` } })
94 })
95}
96hooks/game.tsx 95 lines1// The game itself: runs on the drawing thread with its own clock and keys.
2import type { ClientModule } from 'claude-code'
3
4import { CELL, CHROME_COLS, COLOR, MIN_COLS, MIN_ROWS, gridSize, paint, runs } from './board'
5import { newGame, step, turn } from './snake'
6import type { Dir, Game } from './snake'
7
8// `columns` and `rows`: the room the pane gives, from the hook. The board sizes from these,
9// not from the region's own layout: the desktop fits a region to what it draws, so a
10// region measured while showing one line of text stays one line tall.
11export type GameProps = { isPlaying: boolean; best: number; columns: number; rows: number }
12type Local = { game: Game | null; live: { props: GameProps }; flash: number }
13
14const TICK_MS = 120
15const FLASH_TICKS = 6 // a crash shows for about three quarters of a second
16const KEYS: Record<string, Dir> = { up: 'up', down: 'down', left: 'left', right: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }
17
18const SnakeGame: ClientModule<GameProps, Local> = (props, surface) => {
19 const { Box, Text } = surface.elements
20 const room = (p: GameProps) => ({ columns: p.columns > 0 ? p.columns : surface.columns, rows: p.rows > 0 ? p.rows : surface.rows })
21 const { cols, rows } = gridSize(room(props).columns, room(props).rows)
22
23 if (surface.state === undefined) {
24 const local: Local = { game: null, live: { props }, flash: 0 }
25 surface.every(TICK_MS, () => {
26 const now = surface.state
27 if (!now) return
28 const r0 = room(now.live.props)
29 const size = gridSize(r0.columns, r0.rows)
30 if (size.cols < MIN_COLS || size.rows < MIN_ROWS) return
31 if (!now.game || now.game.cols !== size.cols || now.game.rows !== size.rows) {
32 surface.setState({ ...now, game: newGame(size.cols, size.rows, Date.now() % 100000) }) // a new or resized pane
33 return
34 }
35 if (now.flash > 0) {
36 surface.setState({ ...now, flash: now.flash - 1 }) // a crash holds the board still for a moment
37 return
38 }
39 if (!now.live.props.isPlaying) return
40 const r = step(now.game)
41 if (r.ate || r.crashed) surface.post({ score: r.game.score })
42 surface.setState({ ...now, game: r.game, flash: r.crashed ? FLASH_TICKS : 0 })
43 })
44 surface.onKey(e => {
45 const now = surface.state
46 const dir = KEYS[e.key.toLowerCase()]
47 if (!now?.game || !dir || !now.live.props.isPlaying || now.flash > 0) return // paused: keys do nothing
48 surface.setState({ ...now, game: turn(now.game, dir) })
49 })
50 surface.setState(local)
51 return <Text dimColor>Loading Snake…</Text>
52 }
53
54 const local = surface.state
55 local.live.props = props // the clock and keys read the latest props
56
57 if (cols < MIN_COLS || rows < MIN_ROWS) return <Text dimColor>{`Make the pane bigger to play (${room(props).columns}×${room(props).rows})`}</Text>
58
59 const game = local.game
60 if (!game || game.cols !== cols || game.rows !== rows) return <Text dimColor>Setting up the board…</Text>
61
62 const isPaused = !props.isPlaying
63 const isCrash = local.flash > 0
64 const best = Math.max(props.best, game.score)
65 const blink = Math.floor(Date.now() / 400) % 2
66 const cells = paint(game, blink, isPaused)
67 const frame = isCrash ? COLOR.frameCrash : isPaused ? COLOR.frameOff : COLOR.frame
68 const width = game.cols * CELL + CHROME_COLS
69
70 return (
71 <Box flexDirection="column" alignItems="center" width={room(props).columns}>
72 <Box width={width} justifyContent="space-between">
73 <Text bold color={isPaused ? undefined : COLOR.frame} dimColor={isPaused}>SNAKE</Text>
74 <Text>
75 <Text>{`score ${game.score}`}</Text>
76 <Text dimColor>{` · best ${best}`}</Text>
77 </Text>
78 </Box>
79 <Box key="board" flexDirection="column" borderStyle="round" borderColor={frame}>
80 {Array.from({ length: game.rows }, (_, y) => (
81 <Box key={`row-${y}`} flexDirection="row" height={1}>
82 {runs(game.cols, y, cells).map((r, i) => (
83 <Box key={`run-${y}-${i}`} width={r.n * CELL} height={1} backgroundColor={r.color} />
84 ))}
85 </Box>
86 ))}
87 </Box>
88 {isCrash ? <Text color={COLOR.frameCrash}>crashed</Text> : isPaused ? <Text color="yellow">{"⏸ Claude's done, your turn"}</Text> : <Text dimColor>arrows or WASD</Text>}
89 <Text dimColor>{isPaused ? 'Paused until Claude works again' : 'Click to play · Esc gives the keys back'}</Text>
90 </Box>
91 )
92}
93
94export default SnakeGame
95hooks/board.ts 67 lines1// Snake's board: pure functions over plain values (sizes, colors, runs), no $ and no timers.
2import type { Game } from './snake'
3
4export const MIN_COLS = 10
5export const MIN_ROWS = 6
6const MAX_COLS = 36 // a board past this on a wide pane is mostly empty: it is centered instead
7const MAX_ROWS = 24
8export const CELL = 2 // columns a cell is wide: two of a terminal's make a square
9const CHROME_ROWS = 4 // header, footer and the two rows of the frame
10export const CHROME_COLS = 2 // the frame's sides
11
12// Every cell is a box with a background and no glyph: a glyph (the desktop's `█` is wider than
13// its cell) wraps a row into two and shears the grid, a box is exactly as many cells as it says.
14export const COLOR = {
15 bandA: '#161b22',
16 bandB: '#1a2029',
17 head: '#c4ff7a',
18 tailFrom: '#52d65c', // the neck
19 tailTo: '#1f6f33', // the tip
20 food: ['#ff5f5f', '#ffa3a3'] as const, // it blinks between the two
21 frame: '#3fb950',
22 frameOff: '#444c56',
23 frameCrash: '#f85149',
24 rest: '#22272e', // what a paused board fades toward
25}
26
27// Cells across and down for the room given: a cell is two columns wide, the frame takes the
28// sides, a header and a footer line sit around the board.
29export function gridSize(columns: number, rows: number) {
30 return { cols: Math.min(MAX_COLS, Math.floor((columns - CHROME_COLS) / CELL)), rows: Math.min(MAX_ROWS, rows - CHROME_ROWS) }
31}
32
33// The color of every cell that is not empty: a gradient from neck to tip, a blinking food.
34// A paused board fades toward the rest color.
35export function paint(game: Game, blink: number, isPaused: boolean) {
36 const fade = (c: string) => (isPaused ? mix(c, COLOR.rest, 0.6) : c)
37 const cells = new Map<string, string>()
38 cells.set(`${game.food.x},${game.food.y}`, fade(COLOR.food[blink]!))
39 const tail = Math.max(1, game.snake.length - 2)
40 game.snake.forEach((c, i) => cells.set(`${c.x},${c.y}`, fade(i === 0 ? COLOR.head : mix(COLOR.tailFrom, COLOR.tailTo, (i - 1) / tail))))
41 return cells
42}
43
44// One row as runs of same-colored cells, so a row is a few boxes, not one per cell. An empty
45// cell takes a band color by its row: the faint stripes make the grid readable.
46export function runs(cols: number, y: number, cells: Map<string, string>) {
47 const band = y % 2 === 0 ? COLOR.bandA : COLOR.bandB
48 const out: { color: string; n: number }[] = []
49 for (let x = 0; x < cols; x++) {
50 const color = cells.get(`${x},${y}`) ?? band
51 const last = out[out.length - 1]
52 if (last && last.color === color) last.n += 1
53 else out.push({ color, n: 1 })
54 }
55 return out
56}
57
58// A blend of two `#rrggbb` colors, `t` of the way from `a` to `b`.
59export function mix(a: string, b: string, t: number) {
60 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
61 const to = (i: number) =>
62 Math.round(channel(a, i) + (channel(b, i) - channel(a, i)) * Math.min(1, Math.max(0, t)))
63 .toString(16)
64 .padStart(2, '0')
65 return `#${to(0)}${to(1)}${to(2)}`
66}
67hooks/snake.ts 65 lines1// Snake's rules: pure functions over a plain game value, no $ and no timers.
2
3export type Dir = 'up' | 'down' | 'left' | 'right'
4export type Cell = { x: number; y: number }
5export type Game = {
6 cols: number
7 rows: number
8 snake: Cell[] // head first
9 dir: Dir
10 nextDir: Dir
11 food: Cell
12 score: number
13 seed: number
14}
15
16const START_LENGTH = 3
17const MOVES: Record<Dir, Cell> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }
18const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
19
20export function newGame(cols: number, rows: number, seed = 1): Game {
21 const y = Math.floor(rows / 2)
22 const x = Math.floor(cols / 2)
23 const snake = Array.from({ length: START_LENGTH }, (_, i) => ({ x: x - i, y }))
24 const placed = placeFood(cols, rows, snake, seed)
25 return { cols, rows, snake, dir: 'right', nextDir: 'right', food: placed.food, score: 0, seed: placed.seed }
26}
27
28// Queues a turn for the next step; a 180° turn is ignored.
29export function turn(game: Game, dir: Dir): Game {
30 if (dir === OPPOSITE[game.dir] || dir === game.nextDir) return game
31 return { ...game, nextDir: dir }
32}
33
34export function step(game: Game): { game: Game; ate: boolean; crashed: boolean } {
35 const dir = game.nextDir
36 const head = game.snake[0]!
37 const next = { x: head.x + MOVES[dir].x, y: head.y + MOVES[dir].y }
38 const ate = next.x === game.food.x && next.y === game.food.y
39 const body = ate ? game.snake : game.snake.slice(0, -1)
40
41 const hitWall = next.x < 0 || next.y < 0 || next.x >= game.cols || next.y >= game.rows
42 const hitSelf = body.some(c => c.x === next.x && c.y === next.y)
43 if (hitWall || hitSelf) {
44 return { game: newGame(game.cols, game.rows, game.seed), ate: false, crashed: true }
45 }
46
47 const snake = [next, ...body]
48 if (!ate) return { game: { ...game, snake, dir }, ate, crashed: false }
49 const placed = placeFood(game.cols, game.rows, snake, game.seed)
50 return { game: { ...game, snake, dir, food: placed.food, seed: placed.seed, score: game.score + 1 }, ate, crashed: false }
51}
52
53// A free cell for the food, from a small seeded random generator.
54function placeFood(cols: number, rows: number, snake: Cell[], seed: number) {
55 let s = seed
56 for (let tries = 0; tries < 1000; tries++) {
57 s = (s * 1103515245 + 12345) % 2147483648
58 const x = s % cols
59 s = (s * 1103515245 + 12345) % 2147483648
60 const y = s % rows
61 if (!snake.some(c => c.x === x && c.y === y)) return { food: { x, y }, seed: s }
62 }
63 return { food: { x: 0, y: 0 }, seed: s }
64}
65