SLOPSHOPPER

GAME MODE · Item Get

A file Claude creates shows ITEM GET! <path>; /inventory lists the files created and changed this session with their line counts

newrowsguardcommandtoast
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-item-get
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-item-get
› 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 › /inventory ╔════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ◆ INVENTORY 새 아이템 0 · 강화 3 ║ ║ ✎ UP src/audit.ts +0 ║ ║ ✎ UP src/auth.ts +0 ║ ║ ✎ UP src/cache.ts +0 ║ ╚════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╔══════════════════════════════════════════════════════════════════════════════════════════════════╗ ║ ◆ INVENTORY 새 아이템 0 · 강화 3 ║ ║ ✎ UP src/audit.ts +0 ║ ║ ✎ UP src/auth.ts +0 ║ ║ ✎ UP src/cache.ts +0 ║ ╚══════════════════════════════════════════════════════════════════════════════════════════════════╝
README

GAME MODE · Item Get

A file Claude creates is an item it picked up. Its row in the transcript says so, above the engine's own result, and a toast says it too:

▸ WRITE docs/auth-notes.md                    +23 −0 CRIT
✦ ITEM GET! docs/auth-notes.md · 새 파일 23줄
  ⎿  Wrote 23 lines to docs/auth-notes.md

/inventory lists what this session made and changed:

◆ INVENTORY  새 아이템 1 · 강화 1
★ NEW docs/auth-notes.md   +23
✎ UP  src/auth.js          +4 −4 ×2

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-item-get@bitgame-mods

Settings

intensity (casual)off · casual · hardcore (a toast for each changed file too)
palette (nes)nes · gameboy · amber · theme

한국어

Claude가 새 파일을 만들면 그 줄에 ITEM GET!을 표시하고, /inventory로 이번 세션에서 만들고 고친 파일을 아이템처럼 보여줍니다(추가·삭제 줄 수 포함).

Source 3 files
hooks/register.tsx 165 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Item } from '../types'
5import { intensityOf, paletteOf, windowProps } from './palette'
6
7// GAME MODE · ITEM GET
8//
9// A file Claude creates is an item it picked up. Its row in the transcript says so:
10//   ✦ ITEM GET!  src/token.ts · 새 파일 42줄
11// and /inventory lists what this session made and changed:
12//   INVENTORY · 새 아이템 2 · 강화 3
13//   ★ NEW  src/token.ts          +42
14//   ✎ UP   src/auth.ts           +4 −4  ×2
15
16const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
17const items = atom({ plugin: 'game-item-get', key: 'items' } as const, [] as Item[])
18
19export const register: Register = (on, options) => {
20  const intensity = intensityOf(options.intensity)
21  const pal = paletteOf(options.palette)
22  let root = ''
23
24  on('session.start', async ($, e, next) => {
25    root = await $.session.root()
26    await $.command.register({ name: 'inventory', description: 'GAME MODE inventory: the files this session made and changed' })
27    return next(e)
28  })
29
30  on('tool.call', async ($, e, next) => {
31    const ran = await next(e)
32    const tool = String(e.tool)
33    if (intensity === 'off' || !WRITES.has(tool) || ran.deny !== undefined || ran.isError === true) return ran
34    const input = e as { file_path?: unknown; notebook_path?: unknown }
35    const raw = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
36    if (raw === '') return ran
37    const path = relative(raw, root)
38    const isNew = isCreated(ran.result)
39    const stat = linesOf(ran.result)
40    await update($, items, list => {
41      const was = list.find(i => i.path === path)
42      const item: Item = {
43        path,
44        isNew: (was?.isNew ?? false) || isNew,
45        added: (was?.added ?? 0) + stat.added,
46        removed: (was?.removed ?? 0) + stat.removed,
47        hits: (was?.hits ?? 0) + 1,
48      }
49      return [...list.filter(i => i.path !== path), item]
50    })
51    if (isNew) $.ui.toast(`✦ ITEM GET! ${path}`)
52    else if (intensity === 'hardcore') $.ui.toast(`✎ ${path} 강화 +${stat.added} −${stat.removed}`)
53    return ran
54  }).catch(($, e, next) => next(e))
55
56  // The row of a Write that made a new file: the item banner above the engine's own result.
57  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
58    if (intensity === 'off' || e.props.tool !== 'Write' || e.props.isErrored || !isCreated(e.props.output)) return next(e)
59    const below = await next(e)
60    const { Box, Text } = $.ui.resolve(e)
61    const o = e.props.output as { filePath?: unknown; content?: unknown }
62    const path = relative(typeof o.filePath === 'string' ? o.filePath : '', root)
63    const lines = typeof o.content === 'string' ? linesIn(o.content) : 0
64    return (
65      <Box flexDirection="column">
66        <Box flexDirection="row" gap={1}>
67          <Text color={pal.title} bold>
68            ✦ ITEM GET!
69          </Text>
70          <Text color={pal.text} bold>
71            {path}
72          </Text>
73          <Text color={pal.dim}>{`· 새 파일 ${lines}줄`}</Text>
74        </Box>
75        {below}
76      </Box>
77    )
78  })
79
80  on('command.run', { command: 'inventory' }, async ($) => ({ text: inventoryText(await read($, items)) }))
81
82  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
83    if (intensity === 'off' || e.props.command !== 'inventory' || e.props.isErrored) return next(e)
84    const list = sorted(await read($, items))
85    const { Box, Text } = $.ui.resolve(e)
86    const width = Math.min(48, Math.max(12, ...list.map(i => i.path.length)))
87    return (
88      <Box {...windowProps(pal)} flexDirection="column">
89        <Box flexDirection="row" gap={2}>
90          <Text color={pal.info} bold>
91            ◆ INVENTORY
92          </Text>
93          <Text color={pal.text}>{`새 아이템 ${list.filter(i => i.isNew).length} · 강화 ${list.filter(i => !i.isNew).length}`}</Text>
94        </Box>
95        {list.length === 0 && <Text color={pal.dim}>이번 세션에 만들거나 고친 파일이 아직 없습니다.</Text>}
96        {list.map(i => (
97          <Box key={i.path} flexDirection="row" gap={1}>
98            <Text color={i.isNew ? pal.title : pal.info} bold>
99              {i.isNew ? '★ NEW' : '✎ UP '}
100            </Text>
101            <Text color={pal.text}>{i.path.length > width ? '…' + i.path.slice(-(width - 1)) : i.path.padEnd(width)}</Text>
102            <Text color={pal.ok}>{`+${i.added}`}</Text>
103            {i.removed > 0 && <Text color={pal.bad}>{`−${i.removed}`}</Text>}
104            {i.hits > 1 && <Text color={pal.dim}>{`×${i.hits}`}</Text>}
105          </Box>
106        ))}
107      </Box>
108    )
109  })
110}
111
112function isCreated(output: unknown): boolean {
113  return output !== null && typeof output === 'object' && (output as { type?: unknown }).type === 'create'
114}
115
116/** Lines added and removed, from the patch the tool kept (or a new file's content). */
117export function linesOf(output: unknown): { added: number; removed: number } {
118  if (output === null || typeof output !== 'object') return { added: 0, removed: 0 }
119  const o = output as { structuredPatch?: unknown; type?: unknown; content?: unknown }
120  if (Array.isArray(o.structuredPatch) && o.structuredPatch.length > 0) {
121    let added = 0
122    let removed = 0
123    for (const hunk of o.structuredPatch) {
124      const lines = hunk !== null && typeof hunk === 'object' ? (hunk as { lines?: unknown }).lines : undefined
125      if (!Array.isArray(lines)) continue
126      for (const line of lines) {
127        if (typeof line !== 'string') continue
128        if (line.startsWith('+')) added++
129        else if (line.startsWith('-')) removed++
130      }
131    }
132    return { added, removed }
133  }
134  if (o.type === 'create' && typeof o.content === 'string') return { added: linesIn(o.content), removed: 0 }
135  return { added: 0, removed: 0 }
136}
137
138function linesIn(text: string): number {
139  const lines = text.split('\n')
140  return lines.length - (lines[lines.length - 1] === '' ? 1 : 0)
141}
142
143/** Relative to the project root with forward slashes; a path outside it stays whole. */
144export function relative(path: string, root: string): string {
145  const p = path.replace(/\\/g, '/')
146  const r = root.replace(/\\/g, '/').replace(/\/$/, '')
147  if (r === '') return p
148  const isWin = /^[A-Za-z]:\//.test(r)
149  const inside = isWin ? p.toLowerCase().startsWith(r.toLowerCase() + '/') : p.startsWith(r + '/')
150  return inside ? p.slice(r.length + 1) : p
151}
152
153function sorted(list: readonly Item[]): Item[] {
154  return [...list].sort((a, b) => Number(b.isNew) - Number(a.isNew) || a.path.localeCompare(b.path))
155}
156
157export function inventoryText(list: readonly Item[]): string {
158  const all = sorted(list)
159  if (all.length === 0) return 'INVENTORY · 이번 세션에 만들거나 고친 파일이 아직 없습니다'
160  return [
161    `INVENTORY · 새 아이템 ${all.filter(i => i.isNew).length} · 강화 ${all.filter(i => !i.isNew).length}`,
162    ...all.map(i => `${i.isNew ? '★ NEW' : '✎ UP '}  ${i.path}  +${i.added}${i.removed > 0 ? ` −${i.removed}` : ''}${i.hits > 1 ? ` ×${i.hits}` : ''}`),
163  ].join('\n')
164}
165
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 19 lines
1/** A file Claude made or changed this session. */
2export type Item = {
3  /** Relative to the project root, forward slashes. */
4  path: string
5  /** Made by this session (a Write that created it). */
6  isNew: boolean
7  /** Lines added and removed over the session. */
8  added: number
9  removed: number
10  /** Edits and writes that went through. */
11  hits: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'game-item-get': { items: Item[] }
17  }
18}
19