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

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.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-item-get@bitgame-mods
intensity (casual) | off · casual · hardcore (a toast for each changed file too) |
palette (nes) | nes · gameboy · amber · theme |
Claude가 새 파일을 만들면 그 줄에 ITEM GET!을 표시하고, /inventory로 이번 세션에서 만들고 고친 파일을 아이템처럼 보여줍니다(추가·삭제 줄 수 포함).
hooks/register.tsx 165 lines1import { 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}
165hooks/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 19 lines1/** 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