Tool rows as an 8-bit battle log: a TURN header on each turn's first row, HIT, CRIT with +added −removed lines, MISS with the lines that say what failed, COMBO…

Tool rows as an 8-bit battle log, so a failure stands out in a long transcript:
⚔ BATTLE LOG · TURN 2
▸ 탐색 ×2 READ 2 HIT
▸ WRITE src/auth.js +4 −4 CRIT COMBO ×3
▸ BASH npm test; cat .env TRAP! COMBO ×3 → BREAK
✗ game-trap-guard blocked this call: it prints a .env file …
▸ BASH npm test MISS
✗ FAIL src/auth.test.ts
… +3줄 (ctrl+o)
SUMMON ctrl+b ▸ 소환수에게 맡기기 (백그라운드)
| Mark | When |
|---|---|
⚔ BATTLE LOG · TURN n | above the first row of each turn (an engine-drawn row too, like a todo list) |
HIT | ran without an error |
CRIT + +added −removed | Edit / Write / NotebookEdit changed a file; the line counts come from the patch the tool kept |
MISS | errored; up to three lines that say what failed are shown (FAIL, not ok, error, expected, …), exit codes, TAP version and runtime warnings are skipped, and the engine's own error block steps aside |
TRAP! · BARRIER! · LOOP! | the trap guard, barrier or loop breaker refused it |
COMBO ×n | three or more clean calls in a row (across turns) |
COMBO ×n → BREAK | the failure or refusal that ended such a run |
SUMMON | the run-in-background pill (ctrl+b, or your own binding) |
ESC · … | interrupted · still running |
A folded group of reads and searches stays one line (with the turn header and the combo when they belong to it), and unfolds by itself when one of its calls failed.
Calls are counted from the rows the session keeps (session.append), where every refusal shows whichever plugin made it and in whatever order the plugins load, and from tool.call, whichever comes first. Subagents' calls are not counted.
Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-battle-log@bitgame-mods
intensity (casual) | off leaves rows to the engine · casual short verdicts · hardcore game phrasing (CLAUDE의 BASH! … MISS…) |
palette (nes) | nes · gameboy · amber · theme (light backgrounds) |
/battle-log off · on | hand every row back to the engine, and back; remembered |
도구 실행 줄을 전투 로그로 보여줍니다. 턴의 첫 줄에 TURN n, 연속 성공 3번부터 COMBO, 그것을 끊은 실패에 BREAK, 수정에는 +추가 −삭제 줄 수, 가드 차단에는 가드별 판정(TRAP!·BARRIER!·LOOP!), 백그라운드 안내는 SUMMON. 실패하면 무엇이 실패했는지 최대 3줄을 바로 펼칩니다.
hooks/register.tsx 456 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BattleRun, CallMark } from '../types'
5import { intensityOf, paletteOf } from './palette'
6import type { Palette } from './palette'
7
8// GAME MODE · BATTLE LOG
9//
10// Tool rows as a battle log, so a failure stands out in a long transcript:
11// ⚔ BATTLE LOG · TURN 3
12// ▸ BASH npm test HIT
13// ▸ EDIT src/auth.ts +3 −1 CRIT COMBO ×3
14// ▸ BASH npm test MISS COMBO ×3 → BREAK
15// ✗ 2 failed
16// ▸ BASH cat .env TRAP!
17// ✗ game-trap-guard blocked this call: …
18// The first row of each turn carries the turn's number. Three or more clean calls in a row
19// are a combo, and the failure that ends one says so. Edits show the lines they add and
20// remove. A GAME MODE guard's refusal shows that guard's verdict (TRAP!, BARRIER!, LOOP!).
21// A folded group of reads and searches stays one line (탐색 ×5 … HIT) and unfolds by itself
22// when one of its calls failed. The run-in-background pill reads SUMMON.
23//
24// Only one-line tools are redrawn (ROWS below). Rows that draw their own content (TodoWrite,
25// Agent, ExitPlanMode, AskUserQuestion, MCP tools) are left to the engine, with the turn
26// header above them when they open a turn, and successful results (diffs, output) keep the
27// engine's own drawing.
28//
29// Turns, combos and first rows are counted from the rows the conversation keeps
30// (session.append), where every call's row passes whichever plugin refused the call, and
31// from tool.call, whichever comes first.
32
33const ROWS: Record<string, string> = {
34 Bash: 'BASH',
35 Read: 'READ',
36 Edit: 'EDIT',
37 MultiEdit: 'EDIT',
38 Write: 'WRITE',
39 NotebookEdit: 'NOTE',
40 Grep: 'GREP',
41 Glob: 'GLOB',
42 LS: 'LS',
43 WebFetch: 'FETCH',
44 WebSearch: 'SEARCH',
45 BashOutput: 'OUTPUT',
46 KillShell: 'KILL',
47}
48
49const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
50const BLOCKED = /\bgame-(?:trap-guard|barrier|loop-breaker)\b/
51// Each guard's own verdict, by the name its refusal starts with.
52const GUARDS: readonly (readonly [RegExp, string])[] = [
53 [/\bgame-trap-guard\b/, 'TRAP!'],
54 [/\bgame-barrier\b/, 'BARRIER!'],
55 [/\bgame-loop-breaker\b/, 'LOOP!'],
56]
57const COMBO_MIN = 3
58
59const NONE: CallMark = { turn: 0, isFirst: false, combo: null, broke: 0 }
60const START: BattleRun = { turn: 0, isTurnOpen: false, combo: 0 }
61
62const isOn = atom({ plugin: 'game-battle-log', key: 'isOn' } as const, true)
63const run = atom({ plugin: 'game-battle-log', key: 'run' } as const, START)
64// One mark per call, by its tool_use_id (the row's requestId).
65const call = atom({ plugin: 'game-battle-log', key: 'call' } as const, NONE)
66
67type Kind = 'run' | 'hit' | 'crit' | 'miss' | 'block' | 'esc'
68
69const WORDS: Record<'casual' | 'hardcore', Record<Kind, string>> = {
70 casual: { run: '…', hit: 'HIT', crit: 'CRIT', miss: 'MISS', block: 'BLOCK', esc: 'ESC' },
71 hardcore: { run: 'CASTING…', hit: 'HIT!', crit: 'CRITICAL!', miss: 'MISS…', block: 'BLOCKED!', esc: 'ESCAPE' },
72}
73
74export const register: Register = (on, options) => {
75 const intensity = intensityOf(options.intensity)
76 const pal = paletteOf(options.palette)
77 const words = WORDS[intensity === 'hardcore' ? 'hardcore' : 'casual']
78 let root = ''
79
80 on('session.start', async ($, e, next) => {
81 root = await $.session.root()
82 const stored = await $.store.get('isOn')
83 if (stored === false) await update($, isOn, () => false)
84 await $.command.register({
85 name: 'battle-log',
86 description: 'GAME MODE battle log: turn the tool-row styling on or off',
87 argumentHint: '[on|off]',
88 })
89 return next(e)
90 })
91
92 // A turn opens: its first call carries the header.
93 on('turn.start', async ($, e, next) => {
94 const sent = await $.session.turns()
95 await update($, run, r => ({ ...r, turn: Math.max(sent, r.turn + 1), isTurnOpen: true }))
96 return next(e)
97 }).catch(($, e, next) => next(e))
98
99 // Calls of the main loop, seen twice: as the conversation keeps them (the model's tool_use
100 // block, then its result, a guard's refusal included whichever plugin refused it) and as they
101 // run. Each call is marked once, by whichever comes first.
102 on('session.append', async ($, e, next) => {
103 const stored = await next(e)
104 if (e.agentId !== undefined) return stored
105 if (e.door === 'response') {
106 for (const id of usesOf(e.message.content)) await made($, id).catch(() => undefined)
107 } else if (e.door === 'tool-result') {
108 for (const r of resultsOf(e.message.content)) await answered($, r.id, r.isError).catch(() => undefined)
109 }
110 return stored
111 }).catch(($, e, next) => next(e))
112
113 on('tool.call', async ($, e, next) => {
114 if (e.agentId !== undefined) return next(e)
115 await made($, e.tool_use_id).catch(() => undefined)
116 const ran = await next(e)
117 await answered($, e.tool_use_id, ran.deny !== undefined || ran.isError === true).catch(() => undefined)
118 return ran
119 }).catch(($, e, next) => next(e))
120
121 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
122 if (intensity === 'off' || !(await read($, isOn))) return next(e)
123 const mark = await read($, memberOf(call, { requestId: e.props.tool_use_id }))
124 const { Box, Text } = $.ui.resolve(e)
125 const header = (
126 <Text color={pal.title} bold>
127 {`⚔ BATTLE LOG · TURN ${mark.turn}`}
128 </Text>
129 )
130
131 const tool = String(e.props.tool)
132 const label = ROWS[tool]
133 if (label === undefined) {
134 if (!mark.isFirst) return next(e)
135 const below = await next(e)
136 return (
137 <Box flexDirection="column">
138 {header}
139 {below}
140 </Box>
141 )
142 }
143
144 const text = textOf(e.props.output)
145 const kind = kindOf(tool, e.props, text)
146 const shown = kind === 'miss' || kind === 'block' ? excerpt(text, 3) : undefined
147 const stat = kind === 'crit' ? diffstat(e.props.output) : undefined
148 const combo = comboOf(mark)
149
150 return (
151 <Box flexDirection="column">
152 {mark.isFirst && header}
153 <Box flexDirection="row" gap={1}>
154 <Text color={pal.title}>{intensity === 'hardcore' ? '▶' : '▸'}</Text>
155 <Text color={WRITES.has(tool) ? pal.info : pal.text} bold>
156 {intensity === 'hardcore' ? `CLAUDE의 ${label}!` : label}
157 </Text>
158 <Box flexGrow={1} flexShrink={1}>
159 <Text color={pal.dim} wrap="truncate-end">
160 {summarize(tool, e.props.input, root)}
161 </Text>
162 </Box>
163 {stat !== undefined && <Text color={pal.ok}>{`+${stat.added}`}</Text>}
164 {stat !== undefined && <Text color={pal.bad}>{`−${stat.removed}`}</Text>}
165 <Text color={toneOf(kind, pal)} bold>
166 {kind === 'block' ? (guardOf(text) ?? words.block) : words[kind]}
167 </Text>
168 {combo !== undefined && (
169 <Text color={combo.isBreak ? pal.bad : pal.title} bold>
170 {combo.text}
171 </Text>
172 )}
173 </Box>
174 {shown?.lines.map(line => (
175 <Text color={kind === 'block' ? pal.title : pal.bad} wrap="truncate-end">
176 {' ✗ ' + line}
177 </Text>
178 ))}
179 {shown !== undefined && shown.more > 0 && <Text color={pal.dim}>{` … +${shown.more}줄 (ctrl+o)`}</Text>}
180 </Box>
181 )
182 })
183
184 // The ToolUse row above already shows the failure's first lines.
185 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
186 if (intensity === 'off' || !e.props.isErrored || ROWS[String(e.props.tool)] === undefined) return next(e)
187 if (!(await read($, isOn))) return next(e)
188 const { Box } = $.ui.resolve(e)
189 return <Box />
190 })
191
192 // An unfolded group's rows are ToolUse rows: the one that opens the turn draws the header.
193 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
194 if (intensity === 'off' || e.props.isExpanded || !(await read($, isOn))) return next(e)
195 const calls = e.props.calls
196 if (calls.some(c => c.isErrored)) return next({ ...e, props: { ...e.props, isExpanded: true } })
197
198 const marks: CallMark[] = []
199 for (const c of calls) marks.push(c.tool_use_id === undefined ? NONE : await read($, memberOf(call, { requestId: c.tool_use_id })))
200 const first = marks.find(m => m.isFirst)
201 const last = [...marks].reverse().find(m => m.combo !== null)
202 const combo = last === undefined ? undefined : comboOf(last)
203
204 const { Box, Text } = $.ui.resolve(e)
205 const counts = new Map<string, number>()
206 for (const c of calls) {
207 const name = ROWS[String(c.tool)] ?? String(c.tool).toUpperCase()
208 counts.set(name, (counts.get(name) ?? 0) + 1)
209 }
210 const isRunning = e.props.isActive && calls.some(c => c.isRunning)
211 const detail = [...counts].map(([name, n]) => `${name} ${n}`).join(' · ')
212
213 return (
214 <Box flexDirection="column">
215 {first !== undefined && (
216 <Text color={pal.title} bold>
217 {`⚔ BATTLE LOG · TURN ${first.turn}`}
218 </Text>
219 )}
220 <Box flexDirection="row" gap={1}>
221 <Text color={pal.title}>{intensity === 'hardcore' ? '▶' : '▸'}</Text>
222 <Text color={pal.info} bold>
223 {`탐색 ×${calls.length}`}
224 </Text>
225 <Box flexGrow={1} flexShrink={1}>
226 <Text color={pal.dim} wrap="truncate-end">
227 {detail}
228 </Text>
229 </Box>
230 <Text color={isRunning ? pal.dim : pal.ok} bold>
231 {isRunning ? words.run : words.hit}
232 </Text>
233 {!isRunning && combo !== undefined && (
234 <Text color={combo.isBreak ? pal.bad : pal.title} bold>
235 {combo.text}
236 </Text>
237 )}
238 </Box>
239 </Box>
240 )
241 })
242
243 // The run-in-background pill: hand the call to a summon and keep going.
244 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
245 if (intensity === 'off' || e.props.kind !== 'background_hint' || !(await read($, isOn))) return next(e)
246 const { Box, Text } = $.ui.resolve(e)
247 return (
248 <Box flexDirection="row" gap={1}>
249 <Text color={pal.info} bold>
250 SUMMON
251 </Text>
252 <Text color={pal.dim}>{`${keyOf(e.props.hint)} ▸ 소환수에게 맡기기 (백그라운드)`}</Text>
253 </Box>
254 )
255 })
256
257 on('command.run', { command: 'battle-log' }, async ($, e) => {
258 const sub = e.args.trim().toLowerCase()
259 if (sub === 'on' || sub === 'off') {
260 await $.store.set('isOn', sub === 'on')
261 await update($, isOn, () => sub === 'on')
262 return { text: `battle log ${sub}` }
263 }
264 const now = await read($, isOn)
265 return { text: `battle log ${now ? 'on' : 'off'} · intensity ${intensity}\n/battle-log on · /battle-log off` }
266 })
267}
268
269type RowState = { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
270
271/** A call was made: the turn it belongs to and whether it opens that turn. Once per call. */
272async function made($: EngineInterface, id: string): Promise<void> {
273 const mark = await read($, memberOf(call, { requestId: id }))
274 if (mark.turn !== 0) return
275 const now = await read($, run)
276 if (now.isTurnOpen) await update($, run, r => ({ ...r, isTurnOpen: false }))
277 await update($, memberOf(call, { requestId: id }), m => ({ ...m, turn: now.turn, isFirst: now.isTurnOpen }))
278}
279
280/** A call was answered: the run of clean calls it extends, or the one it ends. Once per call. */
281async function answered($: EngineInterface, id: string, isError: boolean): Promise<void> {
282 const mark = await read($, memberOf(call, { requestId: id }))
283 if (mark.combo !== null) return
284 const before = (await read($, run)).combo
285 const combo = isError ? 0 : before + 1
286 await update($, run, r => ({ ...r, combo }))
287 await update($, memberOf(call, { requestId: id }), m => ({ ...m, combo, broke: isError ? before : 0 }))
288}
289
290/** The ids of the tool_use blocks in a stored row. */
291export function usesOf(content: readonly unknown[]): string[] {
292 const ids: string[] = []
293 for (const block of content) {
294 if (block === null || typeof block !== 'object') continue
295 const b = block as { type?: unknown; id?: unknown }
296 if (b.type === 'tool_use' && typeof b.id === 'string') ids.push(b.id)
297 }
298 return ids
299}
300
301/** The tool results in a stored row, in order, with whether each one failed. */
302export function resultsOf(content: readonly unknown[]): { id: string; isError: boolean }[] {
303 const out: { id: string; isError: boolean }[] = []
304 for (const block of content) {
305 if (block === null || typeof block !== 'object') continue
306 const b = block as { type?: unknown; tool_use_id?: unknown; is_error?: unknown }
307 if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') out.push({ id: b.tool_use_id, isError: b.is_error === true })
308 }
309 return out
310}
311
312/** `COMBO ×n` from three clean calls in a row; on the failure that ends one, `COMBO ×n → BREAK`. */
313export function comboOf(mark: CallMark): { text: string; isBreak: boolean } | undefined {
314 if (mark.combo === null) return undefined
315 if (mark.combo === 0) return mark.broke >= COMBO_MIN ? { text: `COMBO ×${mark.broke} → BREAK`, isBreak: true } : undefined
316 return mark.combo >= COMBO_MIN ? { text: `COMBO ×${mark.combo}`, isBreak: false } : undefined
317}
318
319/** The verdict of the GAME MODE guard that refused a call, by the name its refusal carries. */
320export function guardOf(text: string): string | undefined {
321 return GUARDS.find(([name]) => name.test(text))?.[1]
322}
323
324/** The key the background pill names (`ctrl+b`, or the person's own binding). */
325export function keyOf(hint: string): string {
326 return /\(\s*(.+?)\s+to run in background\s*\)/.exec(hint)?.[1] ?? 'ctrl+b'
327}
328
329/** Lines an edit or a write added and removed, from the patch the tool kept. */
330export function diffstat(output: unknown): { added: number; removed: number } | undefined {
331 if (output === null || typeof output !== 'object') return undefined
332 const o = output as { structuredPatch?: unknown; gitDiff?: unknown; type?: unknown; content?: unknown }
333 if (Array.isArray(o.structuredPatch) && o.structuredPatch.length > 0) {
334 let added = 0
335 let removed = 0
336 for (const hunk of o.structuredPatch) {
337 const lines = hunk !== null && typeof hunk === 'object' ? (hunk as { lines?: unknown }).lines : undefined
338 if (!Array.isArray(lines)) continue
339 for (const line of lines) {
340 if (typeof line !== 'string') continue
341 if (line.startsWith('+')) added++
342 else if (line.startsWith('-')) removed++
343 }
344 }
345 return { added, removed }
346 }
347 const git = o.gitDiff as { additions?: unknown; deletions?: unknown } | undefined
348 if (git !== undefined && typeof git.additions === 'number' && typeof git.deletions === 'number') {
349 return { added: git.additions, removed: git.deletions }
350 }
351 if (o.type === 'create' && typeof o.content === 'string') {
352 const lines = o.content.split('\n')
353 return { added: lines.length - (lines[lines.length - 1] === '' ? 1 : 0), removed: 0 }
354 }
355 return undefined
356}
357
358export function kindOf(tool: string, row: RowState, text: string): Kind {
359 if (row.isRunning) return 'run'
360 if (row.isInterrupted) return 'esc'
361 if (row.isErrored) return BLOCKED.test(text) ? 'block' : 'miss'
362 return WRITES.has(tool) ? 'crit' : 'hit'
363}
364
365function toneOf(kind: Kind, pal: Palette): string {
366 switch (kind) {
367 case 'hit':
368 return pal.ok
369 case 'crit':
370 return pal.info
371 case 'miss':
372 return pal.bad
373 case 'block':
374 return pal.title
375 default:
376 return pal.dim
377 }
378}
379
380function field(input: unknown, key: string): string | undefined {
381 if (input === null || typeof input !== 'object') return undefined
382 const value = (input as Record<string, unknown>)[key]
383 return typeof value === 'string' && value.length > 0 ? value : undefined
384}
385
386/** The path relative to the project root; on Windows, slash direction and case are ignored. */
387export function short(path: string, root: string): string {
388 if (root === '') return path
389 const p = path.replace(/\\/g, '/')
390 const r = root.replace(/\\/g, '/').replace(/\/$/, '')
391 const isWin = /^[A-Za-z]:\//.test(r)
392 const inside = isWin ? p.toLowerCase().startsWith(r.toLowerCase() + '/') : p.startsWith(r + '/')
393 return inside ? p.slice(r.length + 1) : path
394}
395
396export function summarize(tool: string, input: unknown, root: string): string {
397 switch (tool) {
398 case 'Bash': {
399 const command = field(input, 'command') ?? ''
400 const lines = command.trim().split('\n')
401 return (lines[0] ?? '').replace(/\s+/g, ' ') + (lines.length > 1 ? ' …' : '')
402 }
403 case 'Grep':
404 case 'Glob': {
405 const pattern = field(input, 'pattern') ?? ''
406 const where = field(input, 'path')
407 return `"${pattern}"` + (where ? ` in ${short(where, root)}` : '')
408 }
409 case 'WebFetch':
410 return field(input, 'url') ?? ''
411 case 'WebSearch':
412 return field(input, 'query') ?? ''
413 case 'BashOutput':
414 return field(input, 'bash_id') ?? ''
415 case 'KillShell':
416 return field(input, 'shell_id') ?? ''
417 default: {
418 const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? field(input, 'path') ?? ''
419 return short(path, root)
420 }
421 }
422}
423
424export function textOf(output: unknown): string {
425 if (typeof output === 'string') return output
426 if (output !== null && typeof output === 'object') {
427 const o = output as Record<string, unknown>
428 const parts = [o.stdout, o.stderr].filter((p): p is string => typeof p === 'string' && p.length > 0)
429 if (parts.length > 0) return parts.join('\n')
430 try {
431 return JSON.stringify(output).slice(0, 4000)
432 } catch {
433 return ''
434 }
435 }
436 return ''
437}
438
439// Lines that only say a failure happened, or say nothing about it.
440const NOISE = /^(?:Exit code \d+|TAP version \d+|.*\b(?:Warning|DeprecationWarning|ExperimentalWarning)\b.*)$/
441// Lines that say what failed.
442const SIGNAL = /\b(?:fail(?:ed|ure|ing)?|errors?|not ok|exception|cannot|can't|denied|not found|no such|expected|assert\w*|blocked|refused|panic\w*|traceback)\b|[✗✕×]/i
443
444/** The lines worth seeing first: what failed, in order; plain lines only when none says so. */
445export function excerpt(text: string, max: number): { lines: string[]; more: number } {
446 const all = text
447 .replace(/\u001b\[[0-9;]*m/g, '')
448 .split('\n')
449 .map(l => l.trimEnd().replace(/^Error: (?=\S)/, ''))
450 .filter(l => l.trim().length > 0)
451 const useful = all.filter(l => !NOISE.test(l.trim()))
452 const signal = useful.filter(l => SIGNAL.test(l))
453 const picked = (signal.length > 0 ? signal : useful.length > 0 ? useful : all).slice(0, max)
454 return { lines: picked.map(l => (l.length > 160 ? l.slice(0, 159) + '…' : l)), more: Math.max(0, all.length - picked.length) }
455}
456hooks/palette.ts 124 lines1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13 /** Headings, gold, the level. */
14 title: string
15 ok: string
16 warn: string
17 bad: string
18 info: string
19 dim: string
20 text: string
21 /** A window's border. */
22 frame: string
23 /** A window's fill; '' for none. */
24 win: string
25 /** The bright inner edge of a window, and a highlighted badge's text. */
26 edge: string
27 /** A bar's empty cells. */
28 off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32 nes: {
33 title: '#f8d830',
34 ok: '#58d854',
35 warn: '#ff8a6a',
36 bad: '#f83800',
37 info: '#3cbcfc',
38 dim: '#b8b8f0',
39 text: '#fcfcfc',
40 frame: '#5060d8',
41 win: '#1c2a8a',
42 edge: '#fcfcfc',
43 off: '#2c3a98',
44 },
45 gameboy: {
46 title: '#e0f8d0',
47 ok: '#9bbc0f',
48 warn: '#e0f8d0',
49 bad: '#f8f8a0',
50 info: '#c4e890',
51 dim: '#8bac0f',
52 text: '#e0f8d0',
53 frame: '#9bbc0f',
54 win: '#1e4a1e',
55 edge: '#e0f8d0',
56 off: '#306230',
57 },
58 amber: {
59 title: '#ffd060',
60 ok: '#ffb000',
61 warn: '#ff7040',
62 bad: '#ff5a2a',
63 info: '#ffe0a0',
64 dim: '#c89040',
65 text: '#ffd9a0',
66 frame: '#ffb000',
67 win: '#2a1700',
68 edge: '#ffe0a0',
69 off: '#4a2a00',
70 },
71 theme: {
72 title: 'claude',
73 ok: 'success',
74 warn: 'warning',
75 bad: 'error',
76 info: 'suggestion',
77 dim: 'subtle',
78 text: 'text',
79 frame: 'promptBorder',
80 win: '',
81 edge: 'text',
82 off: 'subtle',
83 },
84}
85
86export function paletteOf(name: unknown): Palette {
87 return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93 return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98 const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99 return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104 return pal.win === ''
105 ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106 : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111 const s = Math.max(0, Math.round(ms / 1000))
112 const h = Math.floor(s / 3600)
113 const m = Math.floor((s % 3600) / 60)
114 const sec = s % 60
115 const two = (n: number) => String(n).padStart(2, '0')
116 return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121 if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122 return '$' + usd.toFixed(2)
123}
124types/index.d.ts 35 lines1/** /battle-log on|off, kept in $.store across sessions. */
2export type BattleLogSwitch = boolean
3
4/** What the log knows of one tool call of the main loop, kept per tool_use_id. */
5export type CallMark = {
6 /** The turn the call was made in (`TURN n`); 0 for a call the log did not see made. */
7 turn: number
8 /** The turn's first call: its row carries the turn header. */
9 isFirst: boolean
10 /** Clean calls in a row up to this one, this one included; 0 when it failed; null while it runs. */
11 combo: number | null
12 /** On a failure: the run of clean calls it ended. */
13 broke: number
14}
15
16/** The running count the marks are made from. */
17export type BattleRun = {
18 /** The turn now running (`TURN n`). */
19 turn: number
20 /** No call made yet in this turn: the next one is its first. */
21 isTurnOpen: boolean
22 /** Clean calls in a row so far. */
23 combo: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'game-battle-log': {
29 isOn: BattleLogSwitch
30 call: StateFamily<CallMark>
31 run: BattleRun
32 }
33 }
34}
35