SLOPSHOPPER

Galactic Battle

Draws an animated space battle above the prompt while Claude works, driven by what Claude does (tool results, helpers), with a double-bladed lightsaber gauge…

newbandspinnerguardcommandprocess
★ 1v1.1.0MITupdated 2026-10-09DarkVelours/claude-code-galactic-battle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · galactic-battle
› 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 › /battle ⎿ galactic-battle: Retreat to the ice planet: battle off. ✻ Calibrating the lightsaber… ⟨Claude Code's own drawing⟩ ▣ client module ./battle.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./battle.tsx
README

Galactic Battle for Claude Code

English · Français

While Claude works, a space battle fills the band right above the prompt: starfighters trading lasers over a starfield, explosions, a running score, and the odd fast freighter. There are blaster sounds too, and you can add music.

Galactic Battle demo

▶ 18-second video (a scripted turn drawn by the mod's own code)

  • A starfield on two scrolling layers, a desert planet with twin suns, and a battle station drifting by.
  • The fight follows your session. Every tool Claude runs successfully is a rebel shot that downs an imperial fighter; every tool that fails is an imperial shot that downs a rebel one. The score is the real count for the current turn, and starts again at 0 on the next one: REBELS 24 vs 1 EMPIRE means 24 tool calls worked and 1 failed. Only what the main thread runs counts. A helper's own tool calls don't, and neither does a call you refused at a permission prompt.
  • Each helper (subagent) is one more rebel fighter, listed with its model and how long it has been running.
  • A double-bladed lightsaber gauge. The left blade is your context window. The right blade is your 5-hour usage limit, with what the current turn has cost so far (+0.4). Blue while it's calm, purple from 50 %, flickering red when it gets unstable (70 % of the context, 80 % of the quota).
  • A live line with the tool Claude is running now, and how many tools and files it has used so far.
  • The spinner keeps its line, and its words become Jumping to hyperspace, *Negotiating with the Hutts*… The elapsed time and token count stay.
  • A blaster sound on explosions, one every 2.5 s at most, and optional music that loops during a turn.
  • English and French.
  • No model calls and no tokens spent. The animation runs on the terminal's own frame clock, about 14 fps.

What it can't show

Claude Code doesn't give mods the tokens or cost of each helper, so there are no per-helper numbers: only its model and running time. The quota blade needs an account that reports a 5-hour limit; without one it stays retracted and says ?.

Install

In a Claude Code terminal session:

/plugin marketplace add DarkVelours/claude-code-galactic-battle
/plugin install galactic-battle@galactic-battle

Pick the user scope, then answer the four options (language, music file, volume, info shown). You can change them later in /config. Run /reload-plugins if the battle doesn't show on your next prompt.

Requires a Claude Code version with mods (function hooks). Tested on Claude Code 2.1.296. Mods are early access, so their API can change between releases. If the battle stops showing after an update, please open an issue.

Commands

There's one command, with keywords:

CommandWhat it does
/battleTurns the battle on or off
/battle on · /battle offSets it explicitly
`/battle sound [on\off]`Blaster sound on explosions
`/battle music [on\off]`Music during a turn (needs a music file, see below)
`/battle lang [auto\en\fr]`Language (writes the same setting as /config)
`/battle info [tool\full]`What the live line shows: tool names only, or file names and commands too
/battle statusShows the current settings

A keyword with no on/off toggles. French keywords work too (son, musique, langue, statut). The command also runs mid-turn, so /battle music off cuts the music right away. Your settings are kept across sessions.

Settings (/config)

OptionDefault
Languageautoauto follows LC_ALL/LANG; otherwise en or fr
Music file(empty)Absolute path or ~/…. Empty means ~/.claude/galactic-battle/music.mp3
Volume400–100, for both the blaster and the music
Info showntooltool shows tool names only. full also shows file names and shell commands, so switch back before you record your screen

Sounds

  • Blaster: a synthesized sound ships with the plugin, MIT like the code. To use your own, put a blaster.wav in ~/.claude/galactic-battle/.
  • Music: none ships with the plugin, because good space-battle music is rarely free to redistribute. Put an mp3 at ~/.claude/galactic-battle/music.mp3 (or set Music file), then run /battle music on. Royalty-free sites like Pixabay Music or Freesound have plenty of "space battle" tracks. Check the license of whatever you pick.

The music starts with the turn, loops, and stops at the end of the turn, when you interrupt it (Esc), when you run /battle music off, or when the session ends. In case a stop is ever missed, the player also has a hard 30-minute cap.

Platform support

BattleBlasterMusic
Linux / WSL2 (WSLg)✅ tested✅ paplay (or ffplay)✅ ffplay
macOS⚠️ untested⚠️ afplay (untested)⚠️ ffplay or an afplay loop (untested)
Windows (native terminal)⚠️ untested❌❌
Claude desktop / IDEthe normal spinner, no battle––

The engine's own audio player ($.audio.play) only plays on macOS, so the plugin calls shell players itself. On Linux, install pulseaudio-utils (paplay) and/or ffmpeg (ffplay). If you've tested on macOS, a report is welcome.

How it works

  • hooks/register.tsx hooks ui.render twice. On Spinner it rewrites the words. On AbovePrompt it draws the Client under the other bands, so the battle is the nearest thing to the input box, and only while Claude works. It also watches tool.call to count results, errors and running helpers, reads $.session.usage() for the two gauges, answers /battle, and starts and stops sounds on turn.start, turn.complete and ui.message.
  • hooks/battle.tsx is the Client surface module. The fight and the drawing run on the terminal's frame clock (surface.every), so no hook is dispatched per frame. It reads the numbers the hooks module sends as props, and posts { boom: true } back for the blaster.
  • locales/*.ts hold every text, one file per language.

What it touches

A mod isn't sandboxed, so here is what this one does. claude plugin validate . lists the same calls.

  • It hooks every tool call, but only to count it: it never changes a call or its result, and if the hook fails the call goes through. It reads no file contents and makes no network request.
  • It reads your context and usage numbers, HOME, LANG and LC_ALL, and whether the music and blaster files exist.
  • It runs sh to play a sound (paplay, afplay or ffplay) and to stop the music it started, and nothing else.
  • With info full, file names and shell commands appear on screen. They aren't stored or sent anywhere.

Develop

claude plugin validate .        # manifest, marketplace, hooks module
claude plugin test .            # tests/*.test.ts
npx tsx docs/make-demo.tsx docs/demo.svg   # redraws the demo from the real code
npm i --no-save playwright-core && CHROME=/path/to/chrome node docs/make-video.mjs   # re-encodes the MP4 into the current folder (needs ffmpeg; attach it to a release, it is not kept in the repo)
claude --plugin-dir .           # a session running this folder, reloaded on edit
npx -p typescript tsc -p .      # type-check (after one load writes .claude-plugin/types/)

To add a language, see CONTRIBUTING.md.

Uninstall

/plugin uninstall galactic-battle

Disclaimer

A fan-made, non-commercial parody inspired by a certain galaxy far, far away. It isn't affiliated with, endorsed by or sponsored by Lucasfilm Ltd. or The Walt Disney Company. All trademarks belong to their owners. No film footage, logo, sound or music is included.

License

MIT © 2026 Dark Velours

Source 6 files
hooks/register.tsx 526 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { LANGUAGES, LOCALES, pickLanguage } from '../locales'
5import type { Intel, Locale, Pid } from '../types'
6
7const PLUGIN = 'galactic-battle'
8
9const isOn = atom({ plugin: 'galactic-battle', key: 'isOn' } as const, true)
10const isSoundOn = atom({ plugin: 'galactic-battle', key: 'isSoundOn' } as const, true)
11const isMusicOn = atom({ plugin: 'galactic-battle', key: 'isMusicOn' } as const, false)
12// The music player's pid lives in session state so a module reload can still stop it.
13const musicPid = atom({ plugin: 'galactic-battle', key: 'musicPid' } as const, null as Pid)
14
15const STORE_KEY = 'galactic-battle.isOn'
16const SOUND_KEY = 'galactic-battle.isSoundOn'
17const MUSIC_KEY = 'galactic-battle.isMusicOn'
18
19const BLASTER = 'sounds/blaster.wav'
20// Where a person drops their own sounds; overridden by the "Music file" option.
21const USER_DIR = '.claude/galactic-battle'
22// One blast every 2.5 s at most: a busy fight would otherwise be a machine gun.
23const SOUND_GAP_MS = 2500
24// How often the context and quota are read while a turn runs.
25const TICK_MS = 800
26
27const FILE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
28
29// Plays one short clip with the first player found: paplay (Linux, WSLg),
30// afplay (macOS), ffplay; exits 9 when there is none.
31const PLAY_ONCE = [
32  'f="$1"; pa="$2"; af="$3"; ff="$4"',
33  'if command -v paplay >/dev/null && paplay --volume="$pa" "$f" 2>/dev/null; then exit 0; fi',
34  'if command -v afplay >/dev/null; then exec afplay -v "$af" "$f"; fi',
35  'if command -v ffplay >/dev/null; then exec ffplay -nodisp -autoexit -loglevel quiet -volume "$ff" "$f"; fi',
36  'exit 9',
37].join('\n')
38
39// Starts the looping music detached (30 min hard cap, so a missed stop cannot
40// leave it playing forever) and prints its pid; exits 9 with no player.
41const START_MUSIC = [
42  'f="$1"; af="$2"; ff="$3"',
43  'if command -v ffplay >/dev/null && command -v timeout >/dev/null; then',
44  '  nohup timeout 1800 ffplay -nodisp -loglevel quiet -loop 0 -volume "$ff" "$f" >/dev/null 2>&1 </dev/null &',
45  'elif command -v afplay >/dev/null; then',
46  '  nohup sh -c \'end=$(( $(date +%s) + 1800 )); while [ "$(date +%s)" -lt "$end" ]; do afplay -v "$1" "$0" || exit; done\' "$f" "$af" >/dev/null 2>&1 </dev/null &',
47  'else exit 9; fi',
48  'echo $!',
49].join('\n')
50
51// Stops the pid only while it is still one of ours (no pid-reuse accident),
52// with its children (afplay under the macOS loop).
53const STOP_MUSIC = [
54  'case "$(ps -o comm= -p "$1" 2>/dev/null)" in',
55  '  *timeout*|*sh|*ffplay*) pkill -P "$1" 2>/dev/null; kill "$1" 2>/dev/null ;;',
56  'esac',
57  'exit 0',
58].join('\n')
59
60const SOUND_WORDS = ['sound', 'son', 'blaster']
61const MUSIC_WORDS = ['music', 'musique', 'zik']
62const LANGUAGE_WORDS = ['lang', 'language', 'langue']
63const INFO_WORDS = ['info', 'infos', 'details', 'detail']
64const STATUS_WORDS = ['status', 'statut', 'help', 'aide']
65
66const hash = (value: string) => {
67  let h = 2166136261
68  for (let i = 0; i < value.length; i++) {
69    h = Math.imul(h ^ value.charCodeAt(i), 16777619)
70  }
71
72  return h >>> 0
73}
74
75// `on`/`off` sets the value, nothing toggles it; any other word is refused.
76const nextValue = (word: string | undefined, current: boolean) =>
77  word === 'on' ? true : word === 'off' ? false : word === undefined ? !current : undefined
78
79const volumes = (volume: number) => {
80  const v = Math.min(100, Math.max(0, volume))
81
82  return { pa: String(Math.round(v * 655.36)), af: (v / 100).toFixed(2), ff: String(Math.round(v)) }
83}
84
85// What the battle is told about the turn. It lives in the module: a reload
86// starts a fresh one, and the next turn fills it again.
87type LiveHelper = { label: string; model: string; since: number }
88type Live = {
89  isTurn: boolean
90  ok: number
91  err: number
92  files: Set<string>
93  action: string
94  helpers: Map<string, LiveHelper>
95  ctx: number | null
96  quota: number | null
97  quotaBase: number | null
98  mode: string
99}
100
101const fresh = (): Live => ({
102  isTurn: false,
103  ok: 0,
104  err: 0,
105  files: new Set(),
106  action: '',
107  helpers: new Map(),
108  ctx: null,
109  quota: null,
110  quotaBase: null,
111  mode: '',
112})
113
114let live = fresh()
115
116const str = (value: unknown) => (typeof value === 'string' ? value : '')
117
118const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
119
120// The shell command without its `cd "<dir>" &&` lead-in, on one short line.
121const shortCommand = (command: string) =>
122  command
123    .replace(/^\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(&&|;)\s*/, '')
124    .replace(/\s+/g, ' ')
125    .trim()
126    .slice(0, 36)
127
128// What a tool call is doing, for the battle's line. Only the tool's name unless
129// the person asked for the details: file names and commands are on screen.
130function describe(tool: string, args: Record<string, unknown>, isFull: boolean) {
131  if (!isFull) return tool
132
133  const path = str(args.file_path) || str(args.notebook_path)
134  const detail =
135    path !== ''
136      ? baseName(path)
137      : tool === 'Bash'
138        ? shortCommand(str(args.command))
139        : tool === 'Grep' || tool === 'Glob'
140          ? str(args.pattern).slice(0, 30)
141          : tool === 'WebFetch'
142            ? (str(args.url).replace(/^https?:\/\//, '').split('/')[0] ?? '')
143            : tool === 'Agent'
144              ? str(args.description).slice(0, 30)
145              : ''
146
147  return detail === '' ? tool : `${tool} ${detail}`
148}
149
150const helperLabel = (args: Record<string, unknown>, isFull: boolean) =>
151  isFull
152    ? str(args.name) || str(args.description).slice(0, 22) || str(args.subagent_type) || 'agent'
153    : str(args.subagent_type) || 'agent'
154
155// Reads the context window and the 5-hour quota, then asks for a redraw.
156async function refresh($: EngineInterface) {
157  if (!live.isTurn) return
158
159  const usage = await $.session.usage()
160  live.ctx = usage.context.percent ?? null
161  const fiveHour = usage.rateLimits.find(limit => limit.kind === 'five_hour')
162  live.quota = fiveHour ? fiveHour.percentUsed : null
163
164  if (live.quotaBase === null) live.quotaBase = live.quota
165
166  $.ui.invalidate('ui.render')
167}
168
169async function detectLanguage($: EngineInterface, setting: unknown) {
170  return pickLanguage(setting, await $.env.get('LC_ALL'), await $.env.get('LANG'))
171}
172
173// Read once per module load (an option change reloads the module).
174let language: string | null = null
175
176async function resolveLanguage($: EngineInterface, setting: unknown) {
177  language ??= await detectLanguage($, setting)
178}
179
180async function homePath($: EngineInterface, path: string) {
181  const home = (await $.env.get('HOME')) ?? ''
182
183  return path.startsWith('~/') ? `${home}/${path.slice(2)}` : path
184}
185
186async function musicPath($: EngineInterface, setting: unknown) {
187  const configured = typeof setting === 'string' ? setting.trim() : ''
188
189  return homePath($, configured || `~/${USER_DIR}/music.mp3`)
190}
191
192async function exists($: EngineInterface, path: string) {
193  try {
194    await $.fs.stat(path)
195
196    return true
197  } catch {
198    return false
199  }
200}
201
202// A blaster.wav of the person's own beats the bundled one.
203async function blasterPath($: EngineInterface) {
204  const own = await homePath($, `~/${USER_DIR}/blaster.wav`)
205
206  return (await exists($, own)) ? own : `${$.plugin.root}/${BLASTER}`
207}
208
209async function startMusic($: EngineInterface, path: string, volume: number) {
210  if ((await read($, musicPid)) !== null) return
211
212  const v = volumes(volume)
213
214  try {
215    const r = await $.process.run(['sh', '-c', START_MUSIC, PLUGIN, path, v.af, v.ff], { timeoutMs: 5000 })
216    const pid = Number(r.stdout.trim())
217
218    if (r.exitCode === 0 && pid > 0) {
219      await update($, musicPid, () => pid)
220    }
221  } catch {
222    // no shell player: nothing plays
223  }
224}
225
226async function stopMusic($: EngineInterface) {
227  const pid = await read($, musicPid)
228
229  if (pid === null) return
230  await update($, musicPid, () => null)
231  await $.process.run(['sh', '-c', STOP_MUSIC, PLUGIN, String(pid)], { timeoutMs: 5000 }).catch(() => undefined)
232}
233
234export const register: Register = (on, options) => {
235  language = null
236  live = fresh()
237  let lastSoundAt = Number.NEGATIVE_INFINITY
238  const volume = typeof options.volume === 'number' ? options.volume : 40
239  const isFull = options.details === 'full'
240  const details = isFull ? 'full' : 'tool'
241  const t = (): Locale => LOCALES[language ?? 'en'] ?? (LOCALES.en as Locale)
242
243  on('session.start', async ($, e, next) => {
244    await resolveLanguage($, options.language)
245
246    await $.command.register({
247      name: 'battle',
248      description: 'Galactic battle: animation, live gauges, blaster sound, music, language',
249      argumentHint: '[on|off|sound|music|lang|info|status]',
250      immediate: true,
251    })
252
253    if ((await $.store.get(STORE_KEY)) === false) await update($, isOn, () => false)
254    if ((await $.store.get(SOUND_KEY)) === false) await update($, isSoundOn, () => false)
255    if ((await $.store.get(MUSIC_KEY)) === true) await update($, isMusicOn, () => true)
256
257    // Cheap when idle: it only reads the usage while a turn runs.
258    $.clock.every(TICK_MS, () => {
259      refresh($).catch(() => undefined)
260    })
261
262    return next(e)
263  })
264
265  on('command.run', { command: 'battle' }, async ($, e) => {
266    await resolveLanguage($, options.language)
267    const [first, second] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
268    const usage = `\n${t().usage}`
269
270    if (first !== undefined && STATUS_WORDS.includes(first)) {
271      const status = t().status(
272        await read($, isOn),
273        await read($, isSoundOn),
274        await read($, isMusicOn),
275        language ?? 'en',
276        details,
277      )
278
279      return { text: status + usage }
280    }
281
282    if (first !== undefined && LANGUAGE_WORDS.includes(first)) {
283      if (second === undefined || !['auto', ...LANGUAGES].includes(second)) {
284        return { text: t().unknownOption(second ?? '') + usage }
285      }
286
287      // The option is the one source: /config shows it, and setting it reloads the module.
288      const r = await $.config.set({ key: 'galactic-battle.language', value: second })
289
290      if ('deny' in r && r.deny) return { text: t().languageRefused(String(r.deny)) }
291      language = await detectLanguage($, second)
292
293      return { text: t().languageSet(second === 'auto' ? `auto (${language})` : language) }
294    }
295
296    if (first !== undefined && INFO_WORDS.includes(first)) {
297      const value = second === undefined ? (isFull ? 'tool' : 'full') : second
298
299      if (value !== 'tool' && value !== 'full') return { text: t().unknownOption(second ?? '') + usage }
300
301      const r = await $.config.set({ key: 'galactic-battle.details', value })
302
303      if ('deny' in r && r.deny) return { text: t().languageRefused(String(r.deny)) }
304
305      return { text: t().detailsSet(value) }
306    }
307
308    if (first !== undefined && SOUND_WORDS.includes(first)) {
309      const value = nextValue(second, await read($, isSoundOn))
310      if (value === undefined) return { text: t().unknownOption(second ?? '') + usage }
311      await update($, isSoundOn, () => value)
312      await $.store.set(SOUND_KEY, value)
313
314      return { text: value ? t().soundOn : t().soundOff }
315    }
316
317    if (first !== undefined && MUSIC_WORDS.includes(first)) {
318      const value = nextValue(second, await read($, isMusicOn))
319      if (value === undefined) return { text: t().unknownOption(second ?? '') + usage }
320
321      if (!value) {
322        await update($, isMusicOn, () => false)
323        await $.store.set(MUSIC_KEY, false)
324        await stopMusic($)
325
326        return { text: t().musicOff }
327      }
328
329      const path = await musicPath($, options.musicFile)
330      if (!(await exists($, path))) return { text: t().noMusicFile(path) }
331      await update($, isMusicOn, () => true)
332      await $.store.set(MUSIC_KEY, true)
333      if (live.isTurn && (await read($, isOn))) await startMusic($, path, volume)
334
335      return { text: t().musicOn }
336    }
337
338    const value = nextValue(first, await read($, isOn))
339    if (value === undefined) return { text: t().unknownOption(first ?? '') + usage }
340    await update($, isOn, () => value)
341    await $.store.set(STORE_KEY, value)
342    if (!value) await stopMusic($)
343
344    return { text: value ? t().battleOn : t().battleOff }
345  })
346
347  // The music plays for the length of a turn, the same time the battle is drawn.
348  // A helper's own turns fire these hooks too (agentId set): they must not
349  // reset the battle or cut the music of the main turn.
350  on('turn.start', async ($, e, next) => {
351    if (e.agentId) return next(e)
352
353    live = fresh()
354    live.isTurn = true
355    await refresh($).catch(() => undefined)
356
357    if ((await read($, isOn)) && (await read($, isMusicOn))) {
358      const path = await musicPath($, options.musicFile)
359      if (await exists($, path)) await startMusic($, path, volume)
360    }
361
362    return next(e)
363  })
364
365  on('turn.complete', async ($, e, next) => {
366    if (e.agentId) return next(e)
367
368    live.isTurn = false
369    live.helpers.clear()
370    await stopMusic($)
371
372    return next(e)
373  })
374
375  on('session.end', async ($, e, next) => {
376    live.isTurn = false
377    await stopMusic($)
378
379    return next(e)
380  })
381
382  // Every tool call feeds the battle: a result is a win, an error a loss, a
383  // running Agent call is a helper. A call refused by a permission is neither,
384  // and a helper's own tool calls (agentId set) are left out: the score is
385  // what the main thread did this turn. The hook only watches: the call and
386  // its result go through untouched, and if this hook fails it is skipped.
387  on('tool.call', async ($, e, next) => {
388    if (e.agentId) return next(e)
389
390    const args = e as unknown as Record<string, unknown>
391    const id = e.tool_use_id ?? `${e.tool}-${live.ok + live.err}`
392    const isHelper = e.tool === 'Agent'
393
394    live.action = describe(e.tool, args, isFull)
395
396    if (isHelper) {
397      live.helpers.set(id, { label: helperLabel(args, isFull), model: str(args.model), since: await $.clock.now() })
398    }
399
400    let isFailed = false
401    let isDenied = false
402    let isBackground = false
403
404    try {
405      const result = await next(e)
406      // A background Agent call returns at once while the helper keeps working:
407      // it stays on the battlefield until the turn ends.
408      isBackground = isHelper && 'text' in result && /async agent launched/i.test(String(result.text))
409      isDenied = 'deny' in result && Boolean(result.deny)
410      isFailed = 'isError' in result && result.isError === true
411
412      return result
413    } catch (error) {
414      isFailed = true
415
416      throw error
417    } finally {
418      if (!isBackground) live.helpers.delete(id)
419
420      if (isDenied) {
421        // refused by a permission: not a tool failure, nobody scores
422      } else if (isFailed) {
423        live.err += 1
424      } else {
425        live.ok += 1
426        const path = str(args.file_path) || str(args.notebook_path)
427        if (FILE_TOOLS.includes(e.tool) && path !== '') live.files.add(path)
428      }
429
430      // Reads the gauges too, so they move with the work and not only on the timer.
431      await refresh($).catch(() => undefined)
432    }
433  })
434
435  // An explosion in the battle: a shell player first ($.audio.play has none
436  // on Linux or WSL), the engine's own player as the last resort.
437  on('ui.message', async ($, e, next) => {
438    const data = e.data as { boom?: unknown } | null
439
440    if (e.element !== 'battle' || data?.boom !== true || !(await read($, isSoundOn))) {
441      return next(e)
442    }
443
444    const now = await $.clock.now()
445
446    if (now - lastSoundAt >= SOUND_GAP_MS) {
447      lastSoundAt = now
448      const v = volumes(volume)
449      const path = await blasterPath($)
450      $.process
451        .run(['sh', '-c', PLAY_ONCE, PLUGIN, path, v.pa, v.af, v.ff], { timeoutMs: 5000 })
452        .then(r => (r.exitCode === 0 ? undefined : $.audio.play({ asset: BLASTER })))
453        .catch(() => $.audio.play({ asset: BLASTER }).catch(() => undefined))
454    }
455
456    return next(e)
457  })
458
459  // The spinner keeps its own line, with themed words; the battle itself is
460  // drawn in the band just above the prompt (below).
461  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
462    if (e.surface !== 'terminal' || !(await read($, isOn))) {
463      return next(e)
464    }
465
466    live.mode = e.props.mode
467    await resolveLanguage($, options.language)
468    const locale = t()
469    const word = locale.spinnerWords[hash(e.props.word) % locale.spinnerWords.length] ?? e.props.word
470
471    return next({ ...e, props: { ...e.props, word } })
472  })
473
474  // The battle sits in the AbovePrompt slot, under the other bands, so it is
475  // the nearest thing to the input box. Only while Claude works.
476  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
477    const below = await next(e)
478
479    if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, isOn))) return below
480    if (!live.isTurn && !e.props.isWorking) return below
481
482    await resolveLanguage($, options.language)
483    const locale = t()
484    const now = await $.clock.now()
485    const columns = Math.min(Math.max(e.props.bodyColumns - 2, 30), 160)
486    const battleText = {
487      quotes: locale.quotes,
488      modes: locale.modes,
489      modeDefault: locale.modeDefault,
490      rebels: locale.rebels,
491      empire: locale.empire,
492      collision: locale.collision,
493      freighter: locale.freighter,
494      labels: locale.labels,
495    }
496    const quotaDelta =
497      live.quota !== null && live.quotaBase !== null ? Math.round((live.quota - live.quotaBase) * 10) / 10 : null
498    const intel: Intel = {
499      ok: live.ok,
500      err: live.err,
501      files: live.files.size,
502      action: live.action,
503      helperCount: live.helpers.size,
504      helpers: [...live.helpers.values()].slice(0, 4).map(h => ({
505        label: h.label,
506        model: h.model,
507        seconds: Math.max(0, Math.round((now - h.since) / 1000)),
508      })),
509      ctx: live.ctx,
510      quota: live.quota,
511      quotaDelta,
512    }
513    const { Box, Client } = $.ui.resolve(e)
514    const battle = <Client key="battle" module="./battle.tsx" props={{ columns, mode: live.mode, text: battleText, intel }} />
515
516    return below ? (
517      <Box flexDirection="column">
518        {below}
519        {battle}
520      </Box>
521    ) : (
522      battle
523    )
524  })
525}
526
locales/index.ts 20 lines
1import type { Locale } from '../types'
2
3import { en } from './en'
4import { fr } from './fr'
5
6// Add a language: copy en.ts, translate it, register it here and in
7// plugin.json's `userConfig.language.options`.
8export const LOCALES: Record<string, Locale> = { en, fr }
9
10export const LANGUAGES = Object.keys(LOCALES)
11
12// `auto` follows the shell's locale (LC_ALL, then LANG): `fr_FR.UTF-8` → fr.
13export const pickLanguage = (setting: unknown, lcAll: string | undefined, lang: string | undefined) => {
14  if (typeof setting === 'string' && setting in LOCALES) return setting
15
16  const code = (lcAll || lang || '').slice(0, 2).toLowerCase()
17
18  return code in LOCALES ? code : 'en'
19}
20
hooks/battle.tsx 604 lines
1import type { ClientModule } from 'claude-code'
2
3import type { BattleText, Intel, Mode, Plural } from '../types'
4
5// A tiny space battle drawn on the terminal's frame clock: a starfield with
6// parallax, a desert planet and its twin suns, a drifting battle station,
7// starfighters trading lasers, explosions, and a rare freighter flyby.
8//
9// The fight is not random any more: it is driven by what Claude does. A tool
10// that succeeds is a rebel shot that kills an imperial fighter, a tool that
11// fails is an imperial shot that kills a rebel one, each running helper
12// (subagent) is one more rebel fighter. Under the fight, a double-bladed
13// lightsaber shows the context window (left blade) and the 5-hour quota
14// (right blade). Texts and numbers come in through props.
15
16type Props = { columns: number; mode: string; text: BattleText; intel: Intel }
17
18type Side = 'rebel' | 'empire'
19type Strike = 'ok' | 'err'
20type Ship = {
21  id: number
22  side: Side
23  kind: number
24  x: number
25  y: number
26  vx: number
27  cooldown: number
28  isHelper: boolean
29  isLeaving: boolean
30  // A ship a real shot is flying at stops dodging.
31  isLocked: boolean
32}
33// A real laser has a target and kills it; a decorative one flies through.
34type Laser = { side: Side; x: number; y: number; vx: number; targetId: number | null }
35type Boom = { x: number; y: number; age: number }
36type Falcon = { x: number; y: number }
37
38type Battle = {
39  t: number
40  seed: number
41  nextId: number
42  ships: Ship[]
43  lasers: Laser[]
44  booms: Boom[]
45  falcon: Falcon | null
46  event: { text: 'collision' | 'freighter'; until: number } | null
47  seen: { ok: number; err: number }
48  queue: Strike[]
49  strikeGap: number
50  // The animation timer is set up once, with the first props. This holder is
51  // refreshed on every call so the timer always reads the latest numbers.
52  latest: { width: number; intel: Intel }
53}
54
55const FRAME_MS = 70
56const ROWS = 5
57const LANES = [1, 2, 3]
58const SHIP_WIDTH = 3
59const MAX_QUEUE = 8
60
61const SPACE = '#05060d'
62const C = {
63  starDim: '#3a4060',
64  star: '#9aa4c8',
65  starBright: '#ffffff',
66  sun1: '#ffd23f',
67  sun2: '#ff9a3c',
68  planet: '#c98a4b',
69  planetDark: '#7a4f2a',
70  deathStar: '#8a8f99',
71  deathStarDark: '#4a4f59',
72  rebel: '#e8e8e8',
73  rebelTip: '#ff7a1f',
74  helper: '#8fe3ff',
75  helperTip: '#3bb8e8',
76  tie: '#9aa4b0',
77  tieCore: '#3d6bff',
78  laserRebel: '#ff3b3b',
79  laserEmpire: '#3bff6b',
80  laserRebelDim: '#7a2a2a',
81  laserEmpireDim: '#2a7a45',
82  falcon: '#f5e6c8',
83  scoreGood: '#3bff6b',
84  scoreBad: '#ff3b3b',
85  hud: '#ffd23f',
86  hudDim: '#778da9',
87  helperText: '#6fb8d6',
88  hilt: '#aab2c0',
89  hiltDark: '#6b7280',
90}
91
92// Rebels face right, the Empire faces left; each glyph cell has its colour.
93const SPRITES: Record<Side, { text: string; colors: string[] }[]> = {
94  rebel: [
95    { text: '=╪>', colors: [C.rebel, C.rebel, C.rebelTip] },
96    { text: '═─>', colors: [C.rebelTip, C.rebel, C.rebelTip] },
97  ],
98  empire: [
99    { text: '|o|', colors: [C.tie, C.tieCore, C.tie] },
100    { text: '<o>', colors: [C.tie, C.tieCore, C.tie] },
101  ],
102}
103const HELPER_SPRITE = { text: '=╪>', colors: [C.helper, C.helperTip, C.helperTip] }
104
105const FALCON = { text: '-=(O)>', colors: [C.falcon, C.falcon, C.falcon, C.tieCore, C.falcon, C.rebelTip] }
106
107const BOOM = [
108  { ch: '@', color: '#ffffff' },
109  { ch: '*', color: '#ffd23f' },
110  { ch: '+', color: '#ff7a1f' },
111  { ch: '·', color: '#7a3d1f' },
112]
113
114const PLANET = [' ▄███▄ ', '███████', ' ▀███▀ ']
115const DEATH_STAR = [' ▄█▄ ', '█o███', ' ▀█▀ ']
116
117// Blade colours: [lit, flicker, retracted]. Same thresholds as a weather
118// report: blue is calm, purple means keep an eye on it, red is unstable.
119const BLADE = {
120  blue: ['#3b82ff', '#9ccaff', '#14284a'],
121  purple: ['#a855f7', '#e0bcff', '#2c1650'],
122  red: ['#ff2e2e', '#ffa3a3', '#4d1212'],
123} as const
124type Level = keyof typeof BLADE
125
126const levelOf = (value: number, warn: number, danger: number): Level =>
127  value >= danger ? 'red' : value >= warn ? 'purple' : 'blue'
128
129// Park–Miller LCG kept in state so a frame is a pure function of the last one.
130const rand = (b: Battle) => {
131  b.seed = (b.seed * 48271) % 2147483647
132
133  return b.seed / 2147483647
134}
135
136const noise = (x: number, y: number) => {
137  let h = Math.imul(x * 374761393 + y * 668265263, 1274126177)
138  h = Math.imul(h ^ (h >>> 13), 1274126177)
139
140  return (h ^ (h >>> 16)) >>> 0
141}
142
143const pick = <T,>(b: Battle, items: T[]): T | undefined => items[Math.floor(rand(b) * items.length)]
144
145const plural = (pair: Plural, n: number) => (n === 1 ? pair[0] : pair[1]).replace('{n}', String(n))
146
147const fit = (text: string, n: number) => (text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text)
148
149const percent = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1))
150
151const spawn = (b: Battle, side: Side, width: number, isHelper = false): Ship => {
152  const kind = Math.floor(rand(b) * SPRITES[side].length)
153  const speed = 0.25 + rand(b) * 0.35
154
155  return {
156    id: b.nextId++,
157    side,
158    kind,
159    x: side === 'rebel' ? -SHIP_WIDTH - rand(b) * 20 : width + rand(b) * 20,
160    y: LANES[Math.floor(rand(b) * LANES.length)] ?? 1,
161    vx: side === 'rebel' ? speed : -speed,
162    cooldown: 5 + Math.floor(rand(b) * 15),
163    isHelper,
164    isLeaving: false,
165    isLocked: false,
166  }
167}
168
169const initial = (width: number, intel: Intel): Battle => {
170  const b: Battle = {
171    t: 0,
172    seed: 1 + (Date.now() % 2147483646),
173    nextId: 1,
174    ships: [],
175    lasers: [],
176    booms: [],
177    falcon: null,
178    event: null,
179    // Counters already on the board at mount are history, not new shots.
180    seen: { ok: intel.ok, err: intel.err },
181    queue: [],
182    strikeGap: 0,
183    latest: { width, intel },
184  }
185
186  b.ships.push(spawn(b, 'rebel', width))
187  b.ships[0]!.x = 4
188
189  for (let i = 0; i < Math.min(4, intel.helperCount); i++) {
190    b.ships.push(spawn(b, 'rebel', width, true))
191  }
192
193  for (let i = 0; i < (width < 60 ? 2 : 3); i++) {
194    b.ships.push(spawn(b, 'empire', width))
195  }
196
197  return b
198}
199
200const explode = (b: Battle, x: number, y: number) => {
201  b.booms.push({ x: Math.round(x), y, age: 0 })
202}
203
204// Turns the new tool results into queued shots, one every few frames.
205const intake = (b: Battle, intel: Intel) => {
206  if (intel.ok < b.seen.ok || intel.err < b.seen.err) {
207    // The counters went back to zero: a new turn.
208    b.queue = []
209  } else {
210    for (let i = b.seen.ok; i < intel.ok && b.queue.length < MAX_QUEUE; i++) b.queue.push('ok')
211    for (let i = b.seen.err; i < intel.err && b.queue.length < MAX_QUEUE; i++) b.queue.push('err')
212  }
213
214  b.seen = { ok: intel.ok, err: intel.err }
215}
216
217// One rebel fighter for the main thread, one more for each running helper.
218const syncFleet = (b: Battle, intel: Intel, width: number) => {
219  const wantHelpers = Math.min(4, intel.helperCount)
220  const active = b.ships.filter(s => s.side === 'rebel' && !s.isLeaving)
221  const helpers = active.filter(s => s.isHelper)
222
223  if (!active.some(s => !s.isHelper)) b.ships.push(spawn(b, 'rebel', width))
224
225  for (let i = helpers.length; i < wantHelpers; i++) b.ships.push(spawn(b, 'rebel', width, true))
226
227  for (const s of helpers.slice(wantHelpers)) {
228    s.isLeaving = true
229    s.vx = 2.2
230  }
231}
232
233// A finished tool call becomes a shot that is sure to land.
234const fireStrike = (b: Battle, kind: Strike, width: number) => {
235  const rebels = b.ships.filter(s => s.side === 'rebel' && !s.isLeaving)
236  const empire = b.ships.filter(s => s.side === 'empire')
237
238  if (kind === 'ok') {
239    const target = pick(b, empire.filter(s => !s.isLocked && s.x > 8 && s.x < width - 4))
240    const shooter = target && pick(b, rebels.filter(s => s.x + SHIP_WIDTH < target.x - 4))
241
242    if (target && shooter) {
243      shooter.y = target.y
244      target.isLocked = true
245      b.lasers.push({ side: 'rebel', x: shooter.x + SHIP_WIDTH, y: target.y, vx: 2.4, targetId: target.id })
246    } else {
247      explode(b, 8 + rand(b) * Math.max(1, width - 16), LANES[Math.floor(rand(b) * LANES.length)] ?? 1)
248    }
249
250    return
251  }
252
253  const target = pick(b, rebels.filter(s => !s.isLocked && s.x > 6 && s.x < width - 20))
254  const shooter = target && pick(b, empire.filter(s => s.x > target.x + SHIP_WIDTH + 8 && s.x < width))
255
256  if (target && shooter) {
257    shooter.y = target.y
258    target.isLocked = true
259    b.lasers.push({ side: 'empire', x: shooter.x - 1, y: target.y, vx: -2.4, targetId: target.id })
260  } else if (target) {
261    target.isLocked = true
262    explode(b, target.x + 1, target.y)
263    target.x = width + 10
264  }
265}
266
267const step = (prev: Battle, width: number, intel: Intel): Battle => {
268  const b: Battle = {
269    ...prev,
270    t: prev.t + 1,
271    ships: prev.ships.map(s => ({ ...s })),
272    lasers: prev.lasers.map(l => ({ ...l })),
273    booms: prev.booms.map(o => ({ ...o, age: o.age + 1 })).filter(o => o.age < 8),
274    falcon: prev.falcon ? { ...prev.falcon } : null,
275    event: prev.event && prev.event.until > prev.t ? prev.event : null,
276    seen: { ...prev.seen },
277    queue: [...prev.queue],
278  }
279
280  intake(b, intel)
281  syncFleet(b, intel, width)
282
283  b.strikeGap = Math.max(0, b.strikeGap - 1)
284
285  if (b.queue.length > 0 && b.strikeGap === 0) {
286    fireStrike(b, b.queue.shift() ?? 'ok', width)
287    b.strikeGap = 5
288  }
289
290  for (const s of b.ships) {
291    s.x += s.vx
292    s.cooldown -= 1
293
294    if (s.cooldown <= 0 && !s.isLeaving) {
295      const nose = s.side === 'rebel' ? s.x + SHIP_WIDTH : s.x - 1
296      b.lasers.push({ side: s.side, x: nose, y: s.y, vx: s.side === 'rebel' ? 1.8 : -1.8, targetId: null })
297      s.cooldown = 16 + Math.floor(rand(b) * 24)
298    }
299
300    // Dodge: sometimes change lane.
301    if (!s.isLocked && rand(b) < 0.02) {
302      s.y = Math.min(3, Math.max(1, s.y + (rand(b) < 0.5 ? -1 : 1)))
303    }
304  }
305
306  for (const l of b.lasers) {
307    l.x += l.vx
308  }
309
310  const deadShips = new Set<Ship>()
311  const spentLasers = new Set<Laser>()
312
313  for (const l of b.lasers) {
314    if (l.targetId === null) continue
315
316    const s = b.ships.find(o => o.id === l.targetId)
317
318    if (!s || deadShips.has(s)) {
319      l.targetId = null
320
321      continue
322    }
323
324    // The shot reached the target's cells (or went past them this frame).
325    const reached = l.vx > 0 ? l.x >= s.x - 0.5 : l.x <= s.x + SHIP_WIDTH - 0.5
326
327    if (reached) {
328      deadShips.add(s)
329      spentLasers.add(l)
330      explode(b, s.x + 1, s.y)
331    }
332  }
333
334  // Head-on collisions take both ships, and nobody scores.
335  for (const a of b.ships) {
336    for (const o of b.ships) {
337      if (a.side !== 'rebel' || o.side !== 'empire' || a.y !== o.y) continue
338      if (a.isLeaving || deadShips.has(a) || deadShips.has(o)) continue
339
340      if (Math.abs(a.x - o.x) < SHIP_WIDTH - 0.5) {
341        deadShips.add(a)
342        deadShips.add(o)
343        explode(b, (a.x + o.x) / 2 + 1, a.y)
344        b.event = { text: 'collision', until: b.t + 30 }
345      }
346    }
347  }
348
349  b.lasers = b.lasers.filter(l => !spentLasers.has(l) && l.x > -2 && l.x < width + 2)
350
351  b.ships = b.ships.flatMap(s => {
352    const gone = deadShips.has(s) || s.x > width + 4 || s.x < -SHIP_WIDTH - 24
353
354    if (!gone) return [s]
355    if (s.isLeaving) return []
356
357    return [spawn(b, s.side, width, s.isHelper)]
358  })
359
360  // The freighter: rare, fast, untouchable.
361  if (b.falcon) {
362    b.falcon.x += 2.4
363
364    if (b.falcon.x > width + 2) b.falcon = null
365  } else if (rand(b) < 0.004) {
366    b.falcon = { x: -FALCON.text.length, y: LANES[Math.floor(rand(b) * LANES.length)] ?? 1 }
367    b.event = { text: 'freighter', until: b.t + 45 }
368  }
369
370  return b
371}
372
373type Cell = { ch: string; color: string }
374
375const draw = (b: Battle, width: number): Cell[][] => {
376  const grid: Cell[][] = []
377
378  // Two star layers with parallax: the far one drifts slower.
379  for (let y = 0; y < ROWS; y++) {
380    const row: Cell[] = []
381
382    for (let x = 0; x < width; x++) {
383      const far = noise(x + Math.floor(b.t / 12), y)
384      const near = noise(x + Math.floor(b.t / 4), y + 101)
385      let cell: Cell = { ch: ' ', color: C.star }
386
387      if (near % 53 === 0) {
388        const twinkle = (near >>> 8) % 7 === Math.floor(b.t / 6) % 7
389        cell = { ch: twinkle ? '+' : '·', color: twinkle ? C.starBright : C.star }
390      } else if (far % 29 === 0) {
391        cell = { ch: '.', color: C.starDim }
392      }
393
394      row.push(cell)
395    }
396
397    grid.push(row)
398  }
399
400  const put = (x: number, y: number, ch: string, color: string) => {
401    const cx = Math.round(x)
402
403    if (y < 0 || y >= ROWS || cx < 0 || cx >= width || ch === ' ') return
404    grid[y]![cx] = { ch, color }
405  }
406
407  // A desert planet and its twin suns, fixed on the right.
408  const px = width - PLANET[0]!.length - 3
409  PLANET.forEach((line, dy) => {
410    for (let i = 0; i < line.length; i++) {
411      put(px + i, 2 + dy, line[i] ?? ' ', i < 3 ? C.planet : C.planetDark)
412    }
413  })
414  put(px - 6, 0, '●', C.sun1)
415  put(px - 3, 1, '●', C.sun2)
416
417  // The battle station drifts slowly right to left behind the fight.
418  const span = width + DEATH_STAR[0]!.length + 10
419  const dx = width - ((b.t / 10) % span)
420  DEATH_STAR.forEach((line, dy) => {
421    for (let i = 0; i < line.length; i++) {
422      put(dx + i, dy, line[i] ?? ' ', line[i] === 'o' ? C.deathStarDark : C.deathStar)
423    }
424  })
425
426  for (const l of b.lasers) {
427    const real = l.targetId !== null
428    const color =
429      l.side === 'rebel' ? (real ? C.laserRebel : C.laserRebelDim) : real ? C.laserEmpire : C.laserEmpireDim
430    const glyph = real ? '━' : '─'
431    put(l.x, l.y, glyph, color)
432    put(l.x - Math.sign(l.vx), l.y, glyph, color)
433  }
434
435  for (const s of b.ships) {
436    const sprite = s.side === 'rebel' && s.isHelper ? HELPER_SPRITE : SPRITES[s.side][s.kind]!
437
438    for (let i = 0; i < SHIP_WIDTH; i++) {
439      put(s.x + i, s.y, sprite.text[i] ?? ' ', sprite.colors[i] ?? C.rebel)
440    }
441  }
442
443  if (b.falcon) {
444    for (let i = 0; i < FALCON.text.length; i++) {
445      put(b.falcon.x + i, b.falcon.y, FALCON.text[i] ?? ' ', FALCON.colors[i] ?? C.falcon)
446    }
447  }
448
449  for (const o of b.booms) {
450    const f = BOOM[Math.min(BOOM.length - 1, Math.floor(o.age / 2))]!
451    put(o.x, o.y, f.ch, f.color)
452
453    if (o.age >= 1 && o.age < 6) {
454      const r = Math.ceil(o.age / 2)
455      put(o.x - r, o.y, '·', f.color)
456      put(o.x + r, o.y, '·', f.color)
457      put(o.x, o.y - 1, '\'', f.color)
458      put(o.x, o.y + 1, '.', f.color)
459    }
460  }
461
462  return grid
463}
464
465// One Text per run of a colour keeps the tree small.
466const runs = (row: Cell[]) => {
467  const out: Cell[] = []
468
469  for (const cell of row) {
470    const last = out[out.length - 1]
471
472    if (last && (last.color === cell.color || cell.ch === ' ')) {
473      last.ch += cell.ch
474    } else {
475      out.push({ ...cell })
476    }
477  }
478
479  return out
480}
481
482const bladeColor = (level: Level, t: number) => {
483  const [lit, flicker] = BLADE[level]
484  // Red is an unstable crystal: it flickers at random. The others only hum.
485  const isFlicker = level === 'red' ? noise(t, 7) % 3 === 0 : t % 9 < 2
486
487  return isFlicker ? flicker : lit
488}
489
490// Left blade: context window. Right blade: 5-hour quota. Both grow from the hilt.
491const saber = (width: number, intel: Intel, t: number, text: BattleText): Cell[] => {
492  const labelL = `${text.labels.context} ${intel.ctx === null ? '?' : Math.round(intel.ctx)}% `
493  const delta = intel.quotaDelta !== null && intel.quotaDelta > 0 ? ` (+${percent(intel.quotaDelta)})` : ''
494  const labelR = ` ${text.labels.quota} ${intel.quota === null ? '?' : Math.round(intel.quota)}%${delta}`
495  const room = width - labelL.length - labelR.length - 3 - 2
496  const length = Math.max(6, Math.min(30, Math.floor(room / 2)))
497
498  const left = intel.ctx === null ? 0 : Math.min(length, Math.round((intel.ctx / 100) * length))
499  const right = intel.quota === null ? 0 : Math.min(length, Math.round((intel.quota / 100) * length))
500  const levelL = levelOf(intel.ctx ?? 0, 50, 70)
501  const levelR = levelOf(intel.quota ?? 0, 50, 80)
502
503  return [
504    { ch: ` ${labelL}`, color: C.hudDim },
505    { ch: '·'.repeat(length - left), color: BLADE[levelL][2] },
506    { ch: '━'.repeat(left), color: bladeColor(levelL, t) },
507    { ch: '▐', color: C.hilt },
508    { ch: '█', color: C.hiltDark },
509    { ch: '▌', color: C.hilt },
510    { ch: '━'.repeat(right), color: bladeColor(levelR, t + 3) },
511    { ch: '·'.repeat(length - right), color: BLADE[levelR][2] },
512    { ch: labelR, color: C.hudDim },
513  ].filter(c => c.ch.length > 0)
514}
515
516const Battle: ClientModule<Props, Battle> = (props, surface) => {
517  const { Box, Text } = surface.elements
518  const width = Math.max(30, props.columns)
519  const { text, intel } = props
520
521  if (surface.state === undefined) {
522    surface.every(FRAME_MS, () => {
523      const prev = surface.state
524      if (!prev) return
525
526      const next = step(prev, prev.latest.width, prev.latest.intel)
527      surface.setState(next)
528
529      // Fresh explosions ask the hooks module for a blaster sound.
530      if (next.booms.some(o => o.age === 0)) {
531        surface.post({ boom: true })
532      }
533    })
534    surface.setState(initial(width, intel))
535
536    return <Box height={ROWS + 2} />
537  }
538
539  const b = surface.state
540  b.latest.width = width
541  b.latest.intel = intel
542  const grid = draw(b, width)
543  const quote = text.quotes[Math.floor(b.t / 60) % Math.max(1, text.quotes.length)] ?? ''
544  const mode = text.modes[props.mode as Mode] ?? text.modeDefault
545  const now = b.event && b.event.until > b.t ? text[b.event.text] : intel.action ? `⚙ ${intel.action}` : `${mode} · ${quote}`
546
547  // The counts drop from the right when the line is too long.
548  const counts = [
549    intel.ok + intel.err > 0 ? plural(text.labels.tools, intel.ok + intel.err) : '',
550    intel.files > 0 ? plural(text.labels.files, intel.files) : '',
551    intel.helperCount > 0 ? plural(text.labels.helpers, intel.helperCount) : '',
552  ].filter(Boolean)
553  const score = ` ✦ ${text.rebels} ${intel.ok} vs ${intel.err} ${text.empire} ⬢ `
554  let tail = counts.length > 0 ? ` · ${counts.join(' · ')}` : ''
555  while (tail.length > 0 && score.length + now.length + 2 + tail.length > width) {
556    counts.pop()
557    tail = counts.length > 0 ? ` · ${counts.join(' · ')}` : ''
558  }
559  const room = Math.max(8, width - score.length - tail.length - 2)
560
561  const helpersLine = intel.helpers
562    .map(h => `${h.label}${h.model ? `·${h.model}` : ''} ${h.seconds}s`)
563    .join('   ')
564
565  return (
566    <Box flexDirection="column">
567      {grid.map((row, y) => (
568        <Box key={`r${y}`} flexDirection="row">
569          {runs(row).map((run, i) => (
570            <Text key={`c${i}`} color={run.color} backgroundColor={SPACE}>
571              {run.ch}
572            </Text>
573          ))}
574        </Box>
575      ))}
576      <Box key="saber" flexDirection="row">
577        {saber(width, intel, b.t, text).map((run, i) => (
578          <Text key={`s${i}`} color={run.color}>
579            {run.ch}
580          </Text>
581        ))}
582      </Box>
583      <Box key="hud" flexDirection="row">
584        <Text color={C.scoreGood} bold>
585          {` ✦ ${text.rebels} ${intel.ok}`}
586        </Text>
587        <Text color={C.hudDim}>{' vs '}</Text>
588        <Text color={C.scoreBad} bold>
589          {`${intel.err} ${text.empire} ⬢ `}
590        </Text>
591        <Text color={C.hud}>{`· ${fit(now, room)}`}</Text>
592        <Text color={C.hudDim}>{tail}</Text>
593      </Box>
594      {helpersLine ? (
595        <Text key="helpers" color={C.helperText}>
596          {fit(` ⚙ ${helpersLine}`, width)}
597        </Text>
598      ) : null}
599    </Box>
600  )
601}
602
603export default Battle
604
locales/en.ts 70 lines
1import type { Locale } from '../types'
2
3const flag = (on: boolean) => (on ? 'ON' : 'OFF')
4
5export const en: Locale = {
6  spinnerWords: [
7    'Jumping to hyperspace',
8    'Calibrating the lightsaber',
9    'Negotiating with the Hutts',
10    'Fixing the hyperdrive',
11    'Consulting the old master',
12    'Targeting the exhaust port',
13    'Running the blockade',
14    'Stealing the station plans',
15    'Meditating with the Force',
16    'Podracing',
17    'Escaping the asteroid field',
18    'Interrogating the astromech',
19  ],
20  quotes: [
21    'May the Force be with your build.',
22    'Do or do not. There is no try/catch.',
23    'I have a bad feeling about this diff...',
24    'These aren’t the bugs you’re looking for.',
25    'It’s a merge conflict!',
26    'I sense a disturbance in the CI.',
27    'The dark side of the regex is strong.',
28    'I am your parent process.',
29    'Three bogeys on my tail!',
30    'Never tell me the test coverage odds.',
31  ],
32  modes: {
33    thinking: 'Meditating',
34    requesting: 'Calling the Rebellion',
35    responding: 'Holo transmission',
36    'tool-input': 'Plotting the jump',
37    'tool-use': 'Squadron attacking',
38  },
39  modeDefault: 'Hyperspace',
40  rebels: 'REBELS',
41  empire: 'EMPIRE',
42  collision: 'Head-on collision! A rebel kamikaze.',
43  freighter: 'A certain freighter flies by... in under 12 parsecs!',
44  labels: {
45    context: 'ctx',
46    quota: '5h',
47    tools: ['{n} tool', '{n} tools'],
48    files: ['{n} file', '{n} files'],
49    helpers: ['{n} helper', '{n} helpers'],
50  },
51
52  usage: '/battle [on|off] · /battle sound [on|off] · /battle music [on|off] · /battle lang [auto|en|fr] · /battle info [tool|full] · /battle status',
53  battleOn: 'May the Force be with you: battle on.',
54  battleOff: 'Retreat to the ice planet: battle off.',
55  soundOn: '🔫 Blasters armed.',
56  soundOff: '🔇 Radio silence: blasters off.',
57  musicOn: '🎵 Music ON: it plays while Claude works.',
58  musicOff: '🎵 Music off.',
59  noMusicFile: path => `🎵 No music file at ${path}. Drop an mp3 there, or set "Music file" in /config.`,
60  unknownOption: word => `Unknown option “${word}”.`,
61  status: (battle, sound, music, language, details) =>
62    `⚔ Battle ${flag(battle)} · 🔫 Sound ${flag(sound)} · 🎵 Music ${flag(music)} · 🌐 ${language} · 👁 info ${details}`,
63  languageSet: language => `🌐 Language: ${language}.`,
64  languageRefused: reason => `🌐 Language not changed: ${reason}`,
65  detailsSet: details =>
66    details === 'full'
67      ? '👁 Info: full. The battle shows file names and commands (hide it when you record your screen).'
68      : '👁 Info: tool names only.',
69}
70
locales/fr.ts 70 lines
1import type { Locale } from '../types'
2
3const flag = (on: boolean) => (on ? 'ON' : 'OFF')
4
5export const fr: Locale = {
6  spinnerWords: [
7    'Passage en hyperespace',
8    'Calibrage du sabre laser',
9    'Négociation avec les Hutts',
10    'Réparation de l’hyperpropulseur',
11    'Consultation du vieux maître',
12    'Ciblage du conduit d’aération',
13    'Contournement du blocus',
14    'Vol des plans de la station',
15    'Méditation avec la Force',
16    'Course de modules',
17    'Fuite du champ d’astéroïdes',
18    'Interrogation de l’astromech',
19  ],
20  quotes: [
21    'Que la Force soit avec ton build.',
22    'Fais-le, ou ne le fais pas. Il n’y a pas de try/catch.',
23    'J’ai un mauvais pressentiment sur ce diff...',
24    'Ce ne sont pas les bugs que vous recherchez.',
25    'C’est un conflit de merge !',
26    'Je ressens une perturbation dans la CI.',
27    'Le côté obscur de la regex est puissant.',
28    'Je suis ton process parent.',
29    'Trois chasseurs sur ma queue !',
30    'Ne me dis jamais les chances de passer la CI.',
31  ],
32  modes: {
33    thinking: 'Méditation',
34    requesting: 'Appel à la Rébellion',
35    responding: 'Transmission holo',
36    'tool-input': 'Calcul de trajectoire',
37    'tool-use': 'Escadron en attaque',
38  },
39  modeDefault: 'Hyperespace',
40  rebels: 'REBELLES',
41  empire: 'EMPIRE',
42  collision: 'Collision frontale ! Kamikaze rebelle.',
43  freighter: 'Un certain cargo passe... en moins de 12 parsecs !',
44  labels: {
45    context: 'ctx',
46    quota: '5h',
47    tools: ['{n} outil', '{n} outils'],
48    files: ['{n} fichier', '{n} fichiers'],
49    helpers: ['{n} sous-agent', '{n} sous-agents'],
50  },
51
52  usage: '/battle [on|off] · /battle son [on|off] · /battle musique [on|off] · /battle langue [auto|en|fr] · /battle info [tool|full] · /battle statut',
53  battleOn: 'Que la Force soit avec toi : bataille activée.',
54  battleOff: 'Retraite vers la planète de glace : bataille coupée.',
55  soundOn: '🔫 Blasters armés.',
56  soundOff: '🔇 Silence radio : blasters coupés.',
57  musicOn: '🎵 Musique ON : elle joue pendant que Claude bosse.',
58  musicOff: '🎵 Musique coupée.',
59  noMusicFile: path => `🎵 Pas de fichier musique à ${path}. Dépose un mp3 là, ou règle « Music file » dans /config.`,
60  unknownOption: word => `Option inconnue « ${word} ».`,
61  status: (battle, sound, music, language, details) =>
62    `⚔ Bataille ${flag(battle)} · 🔫 Son ${flag(sound)} · 🎵 Musique ${flag(music)} · 🌐 ${language} · 👁 info ${details}`,
63  languageSet: language => `🌐 Langue : ${language}.`,
64  languageRefused: reason => `🌐 Langue inchangée : ${reason}`,
65  detailsSet: details =>
66    details === 'full'
67      ? '👁 Info : complète. La bataille montre noms de fichiers et commandes (masque-la quand tu filmes ton écran).'
68      : '👁 Info : noms d’outils seulement.',
69}
70
types/index.d.ts 68 lines
1export type Toggle = boolean
2export type Pid = number | null
3export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
4
5// [singular, plural], `{n}` standing for the count.
6export type Plural = [string, string]
7
8export type Labels = {
9  context: string
10  quota: string
11  tools: Plural
12  files: Plural
13  helpers: Plural
14}
15
16// The texts the battle draws: plain data, handed to the Client module as props.
17export type BattleText = {
18  quotes: string[]
19  modes: Record<Mode, string>
20  modeDefault: string
21  rebels: string
22  empire: string
23  collision: string
24  freighter: string
25  labels: Labels
26}
27
28// One running helper (a subagent), as the battle shows it.
29export type Helper = { label: string; model: string; seconds: number }
30
31// What the hooks module knows about the turn, handed to the Client as props.
32export type Intel = {
33  ok: number
34  err: number
35  files: number
36  action: string
37  helpers: Helper[]
38  helperCount: number
39  // Context window and 5-hour quota, in percent; null while unknown.
40  ctx: number | null
41  quota: number | null
42  quotaDelta: number | null
43}
44
45// Everything one language holds.
46export type Locale = BattleText & {
47  spinnerWords: string[]
48  usage: string
49  battleOn: string
50  battleOff: string
51  soundOn: string
52  soundOff: string
53  musicOn: string
54  musicOff: string
55  noMusicFile: (path: string) => string
56  unknownOption: (word: string) => string
57  status: (battle: boolean, sound: boolean, music: boolean, language: string, details: string) => string
58  languageSet: (language: string) => string
59  languageRefused: (reason: string) => string
60  detailsSet: (details: string) => string
61}
62
63declare module 'claude-code' {
64  interface PluginState {
65    'galactic-battle': { isOn: Toggle; isSoundOn: Toggle; isMusicOn: Toggle; musicPid: Pid }
66  }
67}
68