SLOPSHOPPER

GAME MODE · Casting

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

newspinnerguard
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-casting
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-casting
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

GAME MODE · Casting

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)
  • what runs now: the call running (BASH npm test, EDIT auth.ts, SUMMON Find callers), else the spinner's own state (생각하는 중, 답을 쓰는 중)
  • 행동 n: calls the main loop made this turn
  • G +x: the session's cost now minus at the turn's start, as the engine reports it (₩ by default)

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.

Install

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

Settings

intensity (casual)off · casual · hardcore (adds CASTING ×n when several calls run at once)
currency (krw) · krwPerUsd (1400)how G is shown

Known limits

  • The engine reports cost after each response, so G +x moves in steps.

한국어

작업 중 스피너를 CASTING · 지금 실행 중인 행동 · 행동 n · G +이번 턴 비용으로 바꿉니다. 경과 시간과 토큰은 엔진 표시 그대로 뒤에 남습니다.

Source 3 files
hooks/register.tsx 126 lines
1import { 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}
126
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 27 lines
1/** 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