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…

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.
▶ 18-second video (a scripted turn drawn by the mod's own code)
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.+0.4). Blue while it's calm, purple from 50 %, flickering red when it gets unstable (70 % of the context, 80 % of the quota).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 ?.
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.
There's one command, with keywords:
| Command | What it does | ||
|---|---|---|---|
/battle | Turns the battle on or off | ||
/battle on · /battle off | Sets 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 status | Shows 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.
/config)| Option | Default | |
|---|---|---|
| Language | auto | auto follows LC_ALL/LANG; otherwise en or fr |
| Music file | (empty) | Absolute path or ~/…. Empty means ~/.claude/galactic-battle/music.mp3 |
| Volume | 40 | 0–100, for both the blaster and the music |
| Info shown | tool | tool shows tool names only. full also shows file names and shell commands, so switch back before you record your screen |
blaster.wav in ~/.claude/galactic-battle/.~/.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.
| Battle | Blaster | Music | |
|---|---|---|---|
| Linux / WSL2 (WSLg) | ✅ tested | ✅ paplay (or ffplay) | ✅ ffplay |
| macOS | ⚠️ untested | ⚠️ afplay (untested) | ⚠️ ffplay or an afplay loop (untested) |
| Windows (native terminal) | ⚠️ untested | ❌ | ❌ |
| Claude desktop / IDE | the 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.
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.A mod isn't sandboxed, so here is what this one does. claude plugin validate . lists the same calls.
HOME, LANG and LC_ALL, and whether the music and blaster files exist.sh to play a sound (paplay, afplay or ffplay) and to stop the music it started, and nothing else.info full, file names and shell commands appear on screen. They aren't stored or sent anywhere.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.
/plugin uninstall galactic-battle
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.
MIT © 2026 Dark Velours
hooks/register.tsx 526 lines1import { 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}
526locales/index.ts 20 lines1import 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}
20hooks/battle.tsx 604 lines1import 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
604locales/en.ts 70 lines1import 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}
70locales/fr.ts 70 lines1import 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}
70types/index.d.ts 68 lines1export 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