SLOPSHOPPER

hyperspace

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…

newspinnercommandtimer
v0.2.0MITupdated 2026-10-05alexregrets/hyperspace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hyperspace
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /ship ⎿ hyperspace: TIE в строю. Империя одобряет ∙· ▄▄▄ ▄▄▄∙ ─── ∙ · ∙ ∙ · ███ ·───███ ───∙ ─── ·· · ─── ∙ ∙ ███████████━━━·─── · ∙ ─── · ·· · ∙ ·███ ███ ─── · ✧ Гоняю гипотезы по кругу (0s) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
∙· ▄▄▄ ▄▄▄∙ ─── ∙ · ∙ ∙ · ███ ·───███ ───∙ ─── ·· · ─── ∙ ∙ ███████████━━━·─── · ∙ ─── · ·· · ∙ ·███ ███ ─── · ✧ Гоняю гипотезы по кругу (0s)
README

hyperspace

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)

Install

/plugin marketplace add alexregrets/hyperspace
/plugin install hyperspace@hyperspace

Or try it for one session from a clone: claude --plugin-dir ./hyperspace.

Use

  • /ship toggles X-wing ↔ TIE. /ship tie or /ship xwing picks one. The choice persists across sessions.
  • The spinner line takes the faction color: red for the Rebels, green for the Empire.
  • While Claude is thinking, the spinner shows its own phrases. They are in Russian because that is who it was built for. Edit THINKING in hooks/register.tsx to make them yours.

How it works

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:

  • the animation timer runs only during a turn and is cancelled at turn.complete;
  • a frame is skipped if the previous blit is still in flight, so frames never queue up;
  • the scene is capped at 72 columns;
  • star positions are cached per width.

On surfaces other than the terminal, or in terminals narrower than 40 columns, you get the normal spinner with the custom phrase.

Test

claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 146 lines
1import 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}
146
hooks/frame.ts 163 lines
1// Кадр анимации: звёзды в три слоя + корабль из полублоков. Чистая функция от времени.
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