SLOPSHOPPER

GAME MODE · Battle Log

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…

newrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-battle-log
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ▸ EDIT src/auth.ts CRIT ⏺ 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-log ⎿ game-battle-log: battle log on · intensity casual ⎿ game-battle-log: /battle-log on · /battle-log off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
▸ EDIT src/auth.ts CRIT
README

GAME MODE · Battle Log

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 ▸ 소환수에게 맡기기 (백그라운드)
MarkWhen
⚔ BATTLE LOG · TURN nabove the first row of each turn (an engine-drawn row too, like a todo list)
HITran without an error
CRIT + +added −removedEdit / Write / NotebookEdit changed a file; the line counts come from the patch the tool kept
MISSerrored; 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 ×nthree or more clean calls in a row (across turns)
COMBO ×n → BREAKthe failure or refusal that ended such a run
SUMMONthe 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.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-battle-log@bitgame-mods

Settings and commands

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 · onhand every row back to the engine, and back; remembered

한국어

도구 실행 줄을 전투 로그로 보여줍니다. 턴의 첫 줄에 TURN n, 연속 성공 3번부터 COMBO, 그것을 끊은 실패에 BREAK, 수정에는 +추가 −삭제 줄 수, 가드 차단에는 가드별 판정(TRAP!·BARRIER!·LOOP!), 백그라운드 안내는 SUMMON. 실패하면 무엇이 실패했는지 최대 3줄을 바로 펼칩니다.

Source 3 files
hooks/register.tsx 456 lines
1import { 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}
456
hooks/palette.ts 124 lines
1// 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}
124
types/index.d.ts 35 lines
1/** /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