The spinner while a turn runs: CASTING · what runs now · 행동 n · G +the turn's cost so far, with the engine's own time and tokens after it

The spinner says what the turn is doing and what it has cost so far:
✻ CASTING · BASH npm test… · 행동 3 · G +₩120 (12s · ↓ 300 tokens)
BASH npm test, EDIT auth.ts, SUMMON Find callers), else the spinner's own state (생각하는 중, 답을 쓰는 중)Only the words are rewritten, so the engine's own time and tokens stay after them. A state the engine names itself (compacting, a retry) keeps its words.
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-casting@bitgame-mods
intensity (casual) | off · casual · hardcore (adds CASTING ×n when several calls run at once) |
currency (krw) · krwPerUsd (1400) | how G is shown |
G +x moves in steps.작업 중 스피너를 CASTING · 지금 실행 중인 행동 · 행동 n · G +이번 턴 비용으로 바꿉니다. 경과 시간과 토큰은 엔진 표시 그대로 뒤에 남습니다.
hooks/register.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Cast } from '../types'
5import { intensityOf, money } from './palette'
6
7// GAME MODE · CASTING
8//
9// The spinner says what the turn is doing and what it has cost so far:
10// ✻ CASTING · BASH npm test… · 행동 3 · G +₩120 (12s · ↓ 300 tokens)
11// Only the words are rewritten, so the engine's own time and tokens stay after them.
12
13const START: Cast = { actions: 0, startUsd: null, usd: null, running: [], seen: [] }
14const cast = atom({ plugin: 'game-casting', key: 'cast' } as const, START)
15
16// What the turn is doing while no call runs, by the spinner's mode.
17const DOING: Record<string, string> = {
18 requesting: '요청 중',
19 thinking: '생각하는 중',
20 responding: '답을 쓰는 중',
21 'tool-input': '행동을 고르는 중',
22 'tool-use': '행동하는 중',
23}
24
25const LABELS: Record<string, string> = {
26 Bash: 'BASH',
27 Read: 'READ',
28 Edit: 'EDIT',
29 MultiEdit: 'EDIT',
30 Write: 'WRITE',
31 NotebookEdit: 'NOTE',
32 Grep: 'GREP',
33 Glob: 'GLOB',
34 WebFetch: 'FETCH',
35 WebSearch: 'SEARCH',
36 Agent: 'SUMMON',
37 Task: 'SUMMON',
38 TodoWrite: 'QUEST',
39 AskUserQuestion: 'ASK',
40}
41
42export const register: Register = (on, options) => {
43 const intensity = intensityOf(options.intensity)
44 const currency = options.currency === 'usd' ? 'usd' : 'krw'
45 const krwPerUsd = Number(options.krwPerUsd ?? 1400) || 1400
46
47 on('turn.start', async ($, e, next) => {
48 const usage = await $.session.usage().catch(() => undefined)
49 const usd = usage?.cost?.usd ?? null
50 await update($, cast, c => ({ actions: 0, startUsd: usd ?? c.usd, usd: usd ?? c.usd, running: [], seen: [] }))
51 return next(e)
52 }).catch(($, e, next) => next(e))
53
54 on('session.measure', async ($, e, next) => {
55 const usd = e.cost?.usd
56 if (usd !== undefined) await update($, cast, c => ({ ...c, usd, startUsd: c.startUsd ?? usd }))
57 return next(e)
58 }).catch(($, e, next) => next(e))
59
60 // A call counts once, from the model's tool_use row (where a refused call shows too, whichever
61 // plugin refused it) or from tool.call, whichever comes first.
62 on('session.append', async ($, e, next) => {
63 const stored = await next(e)
64 if (e.agentId === undefined && e.door === 'response') {
65 const ids = usesOf(e.message.content)
66 if (ids.length > 0) await update($, cast, c => counted(c, ids)).catch(() => undefined)
67 }
68 return stored
69 }).catch(($, e, next) => next(e))
70
71 on('tool.call', async ($, e, next) => {
72 if (e.agentId !== undefined) return next(e)
73 const id = e.tool_use_id
74 const text = castOf(String(e.tool), e)
75 await update($, cast, c => ({ ...counted(c, [id]), running: [...c.running, { id, text }] }))
76 try {
77 return await next(e)
78 } finally {
79 await update($, cast, c => ({ ...c, running: c.running.filter(r => r.id !== id) }))
80 }
81 }).catch(($, e, next) => next(e))
82
83 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
84 // A state the engine names itself (compacting, a retry) keeps its words.
85 if (intensity === 'off' || e.props.message !== null) return next(e)
86 const c = await read($, cast)
87 return next({ ...e, props: { ...e.props, message: castLine(c, e.props.mode, intensity === 'hardcore', currency, krwPerUsd), suffix: '' } })
88 })
89}
90
91function counted(c: Cast, ids: readonly string[]): Cast {
92 const seen = c.seen ?? []
93 const fresh = ids.filter(id => !seen.includes(id))
94 return fresh.length === 0 ? c : { ...c, actions: c.actions + fresh.length, seen: [...seen, ...fresh].slice(-500) }
95}
96
97function usesOf(content: readonly unknown[]): string[] {
98 return content.flatMap(b => (b !== null && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_use' && typeof (b as { id?: unknown }).id === 'string' ? [(b as { id: string }).id] : []))
99}
100
101/** `CASTING · BASH npm test… · 행동 3 · G +₩120` */
102export function castLine(c: Cast, mode: string, isHardcore: boolean, currency: 'usd' | 'krw', krwPerUsd: number): string {
103 const latest = c.running[c.running.length - 1]
104 const head = isHardcore && c.running.length > 1 ? `CASTING ×${c.running.length}` : 'CASTING'
105 const parts = [`${head} · ${latest?.text ?? DOING[mode] ?? '행동하는 중'}…`, `행동 ${c.actions}`]
106 if (c.usd !== null && c.startUsd !== null) parts.push(`G +${money(Math.max(0, c.usd - c.startUsd), currency, krwPerUsd)}`)
107 return parts.join(' · ')
108}
109
110/** A call by its label and what it works on: `BASH npm test`, `EDIT auth.ts`. */
111export function castOf(tool: string, input: unknown): string {
112 const label = LABELS[tool] ?? (tool.startsWith('mcp__') ? tool.split('__')[2]?.toUpperCase() ?? 'MCP' : tool.toUpperCase())
113 const o = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
114 const str = (key: string) => (typeof o[key] === 'string' ? (o[key] as string) : '')
115 let what = ''
116 if (tool === 'Bash') what = str('command').trim().split('\n')[0] ?? ''
117 else if (tool === 'Grep' || tool === 'Glob') what = str('pattern')
118 else if (tool === 'WebFetch') what = str('url').replace(/^https?:\/\//, '')
119 else if (tool === 'WebSearch') what = str('query')
120 else if (tool === 'Agent' || tool === 'Task') what = str('description')
121 else what = (str('file_path') || str('notebook_path') || str('path')).split(/[\\/]/).pop() ?? ''
122 what = what.replace(/\s+/g, ' ')
123 if (what.length > 36) what = what.slice(0, 35) + '…'
124 return what === '' ? label : `${label} ${what}`
125}
126hooks/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 27 lines1/** A call of the main loop that is running now. */
2export type Running = {
3 id: string
4 /** What the spinner names it by: `BASH npm test`, `EDIT auth.ts`. */
5 text: string
6}
7
8/** The turn as the spinner tells it. */
9export type Cast = {
10 /** Calls the main loop made this turn. */
11 actions: number
12 /** The session's cost when the turn started, in US dollars; null before the first reading. */
13 startUsd: number | null
14 /** The session's cost now, in US dollars. */
15 usd: number | null
16 /** Calls running now, oldest first. */
17 running: Running[]
18 /** The calls counted this turn, by tool_use_id: each is counted once, however it was seen. */
19 seen: string[]
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'game-casting': { cast: Cast }
25 }
26}
27