Spinner mod: an X-wing or a TIE fighter flies through a parallax starfield while Claude works, with laser volleys and a hyperspace jump every 24 s. /ship…

A Claude Code mod that replaces the spinner with a little space battle. While Claude works, an X-wing (or a TIE fighter) flies through a three-layer parallax starfield, fires laser volleys, and jumps to hyperspace every 24 seconds.
· ∙ ─ ─ · ∙
▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ━━━ · ─ ─
▀███████████████▀ ∙
▄███████▀▀▀█████▄ ━━━ ·
▀▀▀▀▀▀▀▀▀▀▀▀▀▀ · ∙
✧ Calculating the jump to hyperspace… (12s)
/plugin marketplace add alexregrets/hyperspace
/plugin install hyperspace@hyperspace
Or try it for one session from a clone: claude --plugin-dir ./hyperspace.
/ship toggles X-wing ↔ TIE. /ship tie or /ship xwing picks one. The choice persists across sessions.THINKING in hooks/register.tsx to make them yours.The scene is one Raster drawn in a ui.render hook on Spinner, repainted with $.ui.blit at 10 fps without a render pass. hooks/frame.ts is a pure function frame(t, columns, ship) that writes cells straight into a Uint32Array: stars come from a deterministic hash, so they stay put between frames, and the ship is a sprite of half-blocks (▀ ▄ █, two pixels per cell).
It stays cheap on purpose:
turn.complete;On surfaces other than the terminal, or in terminals narrower than 40 columns, you get the normal spinner with the custom phrase.
claude plugin test .
MIT
hooks/register.tsx 146 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { encode, frame, hyper, ROWS, type Ship } from './frame'
4
5const FRAME_MS = 100 // 10 кадров в секунду: глазу хватает, терминалу легче
6const EDGE = 4 // столбцы, оставленные пустыми у правого края
7const MAX_COLS = 72 // сцена не шире этого: меньше клеток на кадр на широком экране
8
9// фразы, пока модель думает (mode === 'thinking')
10const THINKING = [
11 'Варю мысль на медленном огне', 'Спорю сам с собой и проигрываю', 'Гоняю гипотезы по кругу',
12 'Делаю вид, что знал это всегда', 'Собираю мозг из запчастей', 'Ищу подвох в собственном ответе',
13 'Сверяю с картой реальности', 'Прокачиваю стат «интеллект»', 'Локнулся, не мешай',
14 'Думаю дороже, чем стою', 'Чувствую возмущение в Силе', 'Рассчитываю прыжок через гиперпространство',
15]
16
17// случайный элемент массива
18const pick = (xs: string[]) => xs[Math.floor(Math.random() * xs.length)]!
19
20// состояние спиннера на ход; переменные модуля — перезагрузка начнёт заново, это ок
21const spin = {
22 turnAt: 0, working: false, last: '', blit: true, ship: 'xwing' as Ship,
23 thought: pick(THINKING), lastMode: '',
24 mount: null as { requestId: string; columns: number } | null,
25 timer: null as { cancel: () => void } | null, busy: false,
26}
27
28// время хода в виде «12s» / «1m 05s»
29const elapsed = (ms: number) => {
30 const s = Math.floor(ms / 1000)
31 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
32}
33
34// один тик анимации: перерисовать сетку через blit, без полного рендера
35async function paint($: EngineInterface) {
36 const m = spin.mount
37 if (!m || !spin.working || spin.busy) return // прошлый кадр ещё в пути — пропускаем, не копим очередь
38 spin.busy = true
39 try {
40 await paintOnce($, m)
41 } finally {
42 spin.busy = false
43 }
44}
45
46// отрисовать один кадр в смонтированную сетку
47async function paintOnce($: EngineInterface, m: { requestId: string; columns: number }) {
48 const now = await $.clock.now()
49 const cells = encode(frame(now - spin.turnAt, m.columns, spin.ship))
50 if (cells === spin.last) return
51 spin.last = cells
52 if (!spin.blit) {
53 $.ui.invalidate('ui.render')
54 return
55 }
56 const r = await $.ui.blit({ requestId: m.requestId, key: 'sky', cells })
57 if ('deny' in r && r.deny) {
58 spin.blit = false
59 $.ui.invalidate('ui.render')
60 }
61}
62
63// завести таймер анимации, если ещё не идёт
64function startTimer($: EngineInterface) {
65 if (!spin.timer) spin.timer = $.clock.every(FRAME_MS, () => void paint($))
66}
67
68export const register: Register = on => {
69 on('session.start', async ($, e, next) => {
70 if (!e.isInteractive) return next(e)
71 try {
72 const s = await $.store.get('ship')
73 if (s === 'tie' || s === 'xwing') spin.ship = s
74 } catch {
75 // нет хранилища — летим на дефолтном
76 }
77 await $.command.register({ name: 'ship', description: 'Сменить корабль в спиннере: xwing / tie' })
78 return next(e)
79 })
80
81 // /ship — переключить, /ship tie или /ship xwing — выбрать; выбор помнится между сессиями
82 on('command.run', { command: 'ship' }, async ($, e) => {
83 const arg = String((e as { args?: string }).args ?? '').trim().toLowerCase()
84 spin.ship = arg === 'tie' || arg === 'xwing' ? arg : spin.ship === 'xwing' ? 'tie' : 'xwing'
85 try {
86 await $.store.set('ship', spin.ship)
87 } catch {
88 // запомнится только на эту сессию
89 }
90 spin.last = ''
91 return { text: spin.ship === 'xwing' ? 'X-wing на взлёте. Red Five, standing by' : 'TIE в строю. Империя одобряет' }
92 })
93
94 // начало хода: засекаем время (субагент не сбрасывает часы главного хода)
95 on('turn.start', async ($, e, next) => {
96 if (!spin.working) [spin.working, spin.turnAt] = [true, await $.clock.now()]
97 startTimer($)
98 return next(e)
99 })
100
101 // конец главного хода: анимация стоп
102 on('turn.complete', async ($, e, next) => {
103 const done = await next(e)
104 if (!e.agentId) {
105 [spin.working, spin.mount] = [false, null]
106 spin.timer?.cancel() // между ходами таймер не тикает вообще
107 spin.timer = null
108 }
109 return done
110 })
111
112 // сцена над строкой спиннера; узкий терминал или не терминал — обычный спиннер со своими фразами
113 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
114 const mode = e.props.mode
115 if (mode === 'thinking' && spin.lastMode !== 'thinking') spin.thought = pick(THINKING)
116 spin.lastMode = mode
117 const word = mode === 'thinking' ? spin.thought : e.props.word
118 const text = e.props.message ?? word
119
120 const columns = Math.min(MAX_COLS, (e.viewport?.columns ?? 0) - EDGE)
121 if (e.surface !== 'terminal' || columns < 40) {
122 spin.mount = null
123 return next({ ...e, props: { ...e.props, word } })
124 }
125 const { Raster, Box, Text } = $.ui.resolve(e)
126 const now = await $.clock.now()
127 if (!spin.working) [spin.working, spin.turnAt] = [true, now] // перезагрузка посреди хода
128 startTimer($)
129 if (spin.mount?.requestId !== e.requestId) spin.blit = true
130 spin.mount = { requestId: e.requestId, columns }
131 const t = now - spin.turnAt
132 const cells = encode(frame(t, columns, spin.ship))
133 spin.last = cells
134 const color = spin.ship === 'xwing' ? '#ff6b5a' : '#5aff7a'
135 return (
136 <Box flexDirection="column">
137 <Raster key="sky" columns={columns} rows={ROWS} cells={cells} />
138 <Text>
139 <Text color={color}>{hyper(t) > 0.3 ? '✦ ' : '✧ '}{text}{e.props.suffix}</Text>
140 <Text dimColor> ({elapsed(t)})</Text>
141 </Text>
142 </Box>
143 )
144 })
145}
146hooks/frame.ts 163 lines1// Кадр анимации: звёзды в три слоя + корабль из полублоков. Чистая функция от времени.
2
3export const ROWS = 5 // строк терминала под сцену (= 10 «пикселей» по вертикали)
4export const DEF = 0x01000000 // цвет терминала по умолчанию
5
6export type Ship = 'xwing' | 'tie'
7
8// спрайты: каждая буква — пиксель своего цвета, точка — пусто
9const SPRITES: Record<Ship, string[]> = {
10 xwing: [
11 'rrwwwwwwwwwwww==.....',
12 '.www.................',
13 '..wwww...............',
14 '..wwwwwwwwwwwwwwwwwk.',
15 'OOwwwwwwrrrwwwwwwwk..',
16 '..wwww...............',
17 '.www.................',
18 'rrwwwwwwwwwwww==.....',
19 ],
20 tie: [
21 'ggg.....ggg',
22 'ggg.....ggg',
23 'ggg.....ggg',
24 'gggwwBBwggg',
25 'gggwwBBwggg',
26 'ggg.....ggg',
27 'ggg.....ggg',
28 'ggg.....ggg',
29 ],
30}
31
32// палитра пикселей; O — движок, мерцает отдельно
33const PALETTE: Record<string, number> = {
34 w: 0xd8dde6, r: 0xd9483b, k: 0x3a3f4b, '=': 0x8a909c, g: 0x5c6370, B: 0x2b2f3a,
35}
36
37// цвет лазера: у повстанцев красный, у империи зелёный
38const LASER: Record<Ship, number> = { xwing: 0xff4d3d, tie: 0x4dff6a }
39
40// детерминированный «рандом» от целого: звёзды стоят на своих местах между кадрами
41const hash = (n: number) => {
42 let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
43 x ^= x >>> 13
44 x = Math.imul(x, 0xc2b2ae35)
45 return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff
46}
47
48// три слоя звёзд: далёкие медленные и тусклые, близкие быстрые, яркие, со шлейфом
49const LAYERS = [
50 { count: 0.10, speed: 6, ch: '·', fg: 0x4a5060, trail: 0 },
51 { count: 0.06, speed: 16, ch: '∙', fg: 0x8a92a6, trail: 0 },
52 { count: 0.03, speed: 40, ch: '─', fg: 0xe6ecff, trail: 2 },
53]
54
55const HYPER_EVERY = 24_000 // раз в 24 с прыжок в гиперпространство
56const HYPER_LEN = 1_800 // прыжок длится 1.8 с
57
58// сила прыжка 0..1..0 по времени хода: разгон, полёт, выход
59export const hyper = (t: number) => {
60 const p = t % HYPER_EVERY
61 const s = HYPER_EVERY - HYPER_LEN
62 if (p < s) return 0
63 return Math.sin(((p - s) / HYPER_LEN) * Math.PI)
64}
65
66// приглушить цвет к чёрному: a — сколько яркости оставить (0..1)
67function dim(c: number, a: number) {
68 const r = Math.round(((c >> 16) & 255) * a)
69 const g = Math.round(((c >> 8) & 255) * a)
70 const b = Math.round((c & 255) * a)
71 return (r << 16) | (g << 8) | b
72}
73
74// звёзды слоя для данной ширины: строка и базовая x считаются один раз, а не каждый кадр
75const starCache = new Map<number, { row: number; base: number }[][]>()
76function starsFor(columns: number) {
77 let s = starCache.get(columns)
78 if (!s) {
79 s = LAYERS.map((L, li) => {
80 const n = Math.max(1, Math.round(columns * ROWS * L.count))
81 return Array.from({ length: n }, (_, i) => {
82 const seed = li * 10_000 + i
83 return { row: Math.floor(hash(seed) * ROWS), base: hash(seed + 7777) * columns }
84 })
85 })
86 starCache.set(columns, s)
87 }
88 return s
89}
90
91const CP = (ch: string) => ch.codePointAt(0)!
92const DASH = CP('─'), HEAVY = CP('━')
93const LAYER_CP = LAYERS.map(L => CP(L.ch))
94
95// собрать кадр: columns × ROWS клеток, каждая — тройка [символ, цвет текста, цвет фона]
96// пишет прямо в Uint32Array, без промежуточных массивов: меньше мусора на каждый кадр
97export function frame(t: number, columns: number, ship: Ship): Uint32Array {
98 const out = new Uint32Array(columns * ROWS * 3)
99 for (let i = 0; i < out.length; i += 3) [out[i], out[i + 1], out[i + 2]] = [0x20, DEF, DEF]
100 // записать клетку по индексу
101 const set = (i: number, cp: number, fg: number, bg: number) => {
102 out[i * 3] = cp
103 out[i * 3 + 1] = fg
104 out[i * 3 + 2] = bg
105 }
106 // поставить символ в клетку, если она в кадре
107 const put = (x: number, row: number, cp: number, fg: number) => {
108 if (x < 0 || x >= columns || row < 0 || row >= ROWS) return
109 set(row * columns + x, cp, fg, DEF)
110 }
111 const h = hyper(t)
112 const stars = starsFor(columns)
113
114 // звёзды: позиция = база − скорость·время, по кругу; в прыжке скорость и шлейф растут
115 LAYERS.forEach((L, li) => {
116 const speed = L.speed * (1 + h * 12)
117 const trail = Math.round(L.trail + h * (1 + li * 5))
118 const cp = trail > 0 ? (h > 0.3 ? HEAVY : DASH) : LAYER_CP[li]!
119 const shift = (speed * t) / 1000
120 for (const st of stars[li]!) {
121 const x = Math.floor((((st.base - shift) % columns) + columns) % columns)
122 put(x, st.row, cp, L.fg)
123 for (let k = 1; k <= trail; k++) put(x + k, st.row, cp, dim(L.fg, 1 - k / (trail + 1)))
124 }
125 })
126
127 // корабль: два пикселя на клетку (▀ ▄ █), покачивается синусом на пиксель, в прыжке рвётся вперёд
128 const sprite = SPRITES[ship]
129 const w = sprite[0]!.length
130 const x0 = Math.floor(columns * 0.18) + Math.round(h * 4)
131 const y0 = Math.floor((ROWS * 2 - sprite.length) / 2) + Math.round(Math.sin(t / 650))
132 const engine = [0xff8a3d, 0xffa64d, 0xffc46b][Math.floor(t / 90) % 3]! // мерцание движка
133 // цвет пикселя спрайта в точке (x, y) кадра
134 const px = (x: number, y: number) => {
135 const c = sprite[y - y0]?.[x - x0]
136 if (!c || c === '.') return DEF
137 return c === 'O' ? engine : PALETTE[c] ?? DEF
138 }
139 for (let row = 0; row < ROWS; row++) {
140 for (let x = Math.max(0, x0); x < Math.min(columns, x0 + w); x++) {
141 const top = px(x, row * 2)
142 const bot = px(x, row * 2 + 1)
143 if (top === DEF && bot === DEF) continue
144 const i = row * columns + x
145 if (top === bot) set(i, 0x2588, top, DEF)
146 else if (top === DEF) set(i, 0x2584, bot, DEF)
147 else if (bot === DEF) set(i, 0x2580, top, DEF)
148 else set(i, 0x2580, top, bot)
149 }
150 }
151
152 // лазеры: залп каждые 1.6 с, болт летит вправо от носа; в прыжке не стреляем
153 if (h === 0) {
154 const bx = x0 + w + Math.floor((t % 1600) * 0.05)
155 const ys = ship === 'xwing' ? [y0, y0 + 7] : [y0 + 3]
156 for (const y of ys) for (let k = 0; k < 3; k++) put(bx + k, Math.floor(y / 2), HEAVY, LASER[ship])
157 }
158 return out
159}
160
161// клетки в base64, как просит Raster
162export const encode = (cells: Uint32Array): string => new Uint8Array(cells.buffer).toBase64()
163