/minimap: a grid of the project's files — edited, read, unexplored — with read counts on files read 3+ times and the most re-read file named

/minimap draws the project's files as a map, one cell per file, a row per folder:
MINIMAP 파일 42 · 편집 3 · 읽음 9 · 미탐색 30
src/ ▓▓█░░4░░▓
test/ ▓█░
(root) ░░▓░
█ 편집 ▓ 읽음 ░ 미탐색 3 = 세 번 읽음
가장 많이 다시 읽은 파일: src/auth.ts (4회)
A file read three times or more shows its count: a sign Claude keeps coming back to it. The files come from git (tracked and untracked, ignored left out, 800 at most) when the map opens, and again after each turn while it is open. Reads and edits count from every loop, subagents included.
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-minimap@bitgame-mods
intensity (casual) | off · casual · hardcore (lists the five most re-read files) |
palette (nes) | nes · gameboy · amber · theme |
cat through Bash is not counted as read.프로젝트 파일을 폴더별 칸으로 보여줍니다(편집·읽음·미탐색, 세 번 이상 읽은 파일은 횟수). 가장 많이 다시 읽은 파일도 알려줍니다.
hooks/register.tsx 199 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Visit } from '../types'
5import { intensityOf, paletteOf } from './palette'
6import type { Palette } from './palette'
7
8// GAME MODE · MINIMAP
9//
10// /minimap draws the project's files as a map, one cell per file, a row per folder:
11// MINIMAP · 파일 42 · 편집 3 · 읽음 9 · 미탐색 30
12// src/ ▓▓█░░4░░▓
13// test/ ▓█░
14// (root) ░░▓░
15// █ 편집 ▓ 읽음 ░ 미탐색 4 = 네 번 읽음
16// 가장 많이 다시 읽은 파일: src/auth.ts (4회)
17// A file read three times or more shows its count: a sign Claude keeps coming back to it.
18
19const PANE = 'minimap'
20const MAX_FILES = 800
21const READS = new Set(['Read'])
22const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
23
24const visits = atom({ plugin: 'game-minimap', key: 'visits' } as const, {} as Record<string, Visit>)
25const files = atom({ plugin: 'game-minimap', key: 'files' } as const, [] as string[])
26
27export const register: Register = (on, options) => {
28 const intensity = intensityOf(options.intensity)
29 const pal = paletteOf(options.palette)
30 let root = ''
31 let isOpen = false
32
33 on('session.start', async ($, e, next) => {
34 root = await $.session.root()
35 await $.command.register({ name: 'minimap', description: 'GAME MODE minimap: the project\'s files, edited, read and unexplored' })
36 return next(e)
37 })
38
39 // Every loop explores the same project: a subagent's reads count too.
40 on('tool.call', async ($, e, next) => {
41 const ran = await next(e)
42 const tool = String(e.tool)
43 if (ran.deny !== undefined || ran.isError === true || (!READS.has(tool) && !WRITES.has(tool))) return ran
44 const input = e as { file_path?: unknown; notebook_path?: unknown }
45 const raw = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
46 const path = relative(raw, root)
47 if (path !== undefined) {
48 await update($, visits, v => {
49 const was = v[path] ?? { reads: 0, isEdited: false }
50 return { ...v, [path]: READS.has(tool) ? { ...was, reads: was.reads + 1 } : { ...was, isEdited: true } }
51 })
52 }
53 return ran
54 }).catch(($, e, next) => next(e))
55
56 on('turn.complete', async ($, e, next) => {
57 const result = await next(e)
58 if (isOpen && e.agentId === undefined) await survey($).catch(() => undefined)
59 return result
60 }).catch(($, e, next) => next(e))
61
62 on('command.run', { command: 'minimap' }, async ($) => {
63 await survey($).catch(() => undefined)
64 if (intensity !== 'off') {
65 await $.ui.open({ id: PANE, title: 'MINIMAP' })
66 isOpen = true
67 }
68 const s = summaryOf(await read($, files), await read($, visits))
69 return { text: `MINIMAP · 파일 ${s.total} · 편집 ${s.edited} · 읽음 ${s.read} · 미탐색 ${s.unexplored}${s.top.length > 0 ? `\n가장 많이 다시 읽은 파일: ${s.top[0]?.path} (${s.top[0]?.reads}회)` : ''}` }
70 })
71
72 on('ui.close', { id: PANE }, async ($, e, next) => {
73 isOpen = false
74 return next(e)
75 }).catch(($, e, next) => next(e))
76
77 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
78 const { Box, Text } = $.ui.resolve(e)
79 const all = await read($, files)
80 const v = await read($, visits)
81 const s = summaryOf(all, v)
82 const labelWidth = 13
83 const cellsPerRow = Math.max(8, e.props.bodyColumns - labelWidth)
84 const rows: RenderElement[] = []
85 for (const [folder, paths] of groupsOf(s.paths)) {
86 for (let at = 0; at < paths.length; at += cellsPerRow) {
87 const runs = runsOf(paths.slice(at, at + cellsPerRow), v, pal)
88 rows.push(
89 <Box key={`${folder}:${at}`} flexDirection="row">
90 <Text color={pal.dim}>{(at === 0 ? folder : '').padEnd(labelWidth).slice(0, labelWidth)}</Text>
91 {runs.map((r, i) => (
92 <Text key={String(i)} color={r.color}>
93 {r.text}
94 </Text>
95 ))}
96 </Box>,
97 )
98 }
99 }
100 return (
101 <Box flexDirection="column">
102 <Box flexDirection="row" gap={2}>
103 <Text color={pal.title} bold>
104 MINIMAP
105 </Text>
106 <Text color={pal.text}>{`파일 ${s.total} · 편집 ${s.edited} · 읽음 ${s.read} · 미탐색 ${s.unexplored}`}</Text>
107 </Box>
108 {rows}
109 <Box flexDirection="row" gap={2}>
110 <Text color={pal.title}>█ 편집</Text>
111 <Text color={pal.info}>▓ 읽음</Text>
112 <Text color={pal.dim}>░ 미탐색</Text>
113 <Text color={pal.warn}>3 = 세 번 읽음</Text>
114 </Box>
115 {s.top.length > 0 && (
116 <Text color={pal.text}>{`가장 많이 다시 읽은 파일: ${s.top[0]?.path} (${s.top[0]?.reads}회)`}</Text>
117 )}
118 {intensity === 'hardcore' &&
119 s.top.slice(1, 5).map(t => (
120 <Text key={t.path} color={pal.dim}>{` ${t.path} (${t.reads}회)`}</Text>
121 ))}
122 </Box>
123 )
124 })
125}
126
127/** The project's files from git (tracked and untracked, ignored left out). */
128async function survey($: EngineInterface): Promise<void> {
129 const listed = await $.process.run(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { timeoutMs: 8000 })
130 if (listed.exitCode !== 0) return
131 const list = listed.stdout
132 .split('\n')
133 .map(l => l.trim())
134 .filter(l => l !== '')
135 .slice(0, MAX_FILES)
136 await update($, files, () => list)
137}
138
139/** The path relative to the project root with forward slashes; undefined outside it. */
140export function relative(path: string, root: string): string | undefined {
141 if (path === '') return undefined
142 const p = path.replace(/\\/g, '/')
143 const r = root.replace(/\\/g, '/').replace(/\/$/, '')
144 if (!/^(?:[A-Za-z]:)?\//.test(p)) return p.replace(/^\.\//, '')
145 const isWin = /^[A-Za-z]:\//.test(r)
146 const inside = isWin ? p.toLowerCase().startsWith(r.toLowerCase() + '/') : p.startsWith(r + '/')
147 return inside ? p.slice(r.length + 1) : undefined
148}
149
150type Summary = { paths: string[]; total: number; edited: number; read: number; unexplored: number; top: { path: string; reads: number }[] }
151
152export function summaryOf(list: readonly string[], v: Readonly<Record<string, Visit>>): Summary {
153 const paths = [...new Set([...list, ...Object.keys(v)])].sort()
154 let edited = 0
155 let read = 0
156 for (const p of paths) {
157 const x = v[p]
158 if (x?.isEdited) edited++
159 else if ((x?.reads ?? 0) > 0) read++
160 }
161 const top = Object.entries(v)
162 .filter(([, x]) => x.reads >= 2)
163 .sort((a, b) => b[1].reads - a[1].reads || a[0].localeCompare(b[0]))
164 .map(([path, x]) => ({ path, reads: x.reads }))
165 return { paths, total: paths.length, edited, read, unexplored: paths.length - edited - read, top }
166}
167
168/** Files by their top folder (`src/`), the root's own last as `(root)`. */
169export function groupsOf(paths: readonly string[]): [string, string[]][] {
170 const groups = new Map<string, string[]>()
171 for (const p of paths) {
172 const slash = p.indexOf('/')
173 const folder = slash === -1 ? '(root)' : p.slice(0, slash + 1)
174 groups.set(folder, [...(groups.get(folder) ?? []), p])
175 }
176 return [...groups].sort((a, b) => (a[0] === '(root)' ? 1 : b[0] === '(root)' ? -1 : a[0].localeCompare(b[0])))
177}
178
179/** One file's cell: its read count from three reads, else edited, read or unexplored. */
180export function cellOf(x: Visit | undefined): { glyph: string; kind: 'edited' | 'read' | 'none' } {
181 const kind = x?.isEdited ? 'edited' : (x?.reads ?? 0) > 0 ? 'read' : 'none'
182 const reads = x?.reads ?? 0
183 if (reads >= 3) return { glyph: reads >= 10 ? '+' : String(reads), kind }
184 return { glyph: kind === 'edited' ? '█' : kind === 'read' ? '▓' : '░', kind }
185}
186
187/** Neighbouring cells of one color drawn as one text. */
188function runsOf(paths: readonly string[], v: Readonly<Record<string, Visit>>, pal: Palette): { text: string; color: string }[] {
189 const runs: { text: string; color: string }[] = []
190 for (const p of paths) {
191 const cell = cellOf(v[p])
192 const color = /\d|\+/.test(cell.glyph) ? pal.warn : cell.kind === 'edited' ? pal.title : cell.kind === 'read' ? pal.info : pal.dim
193 const last = runs[runs.length - 1]
194 if (last !== undefined && last.color === color) last.text += cell.glyph
195 else runs.push({ text: cell.glyph, color })
196 }
197 return runs
198}
199hooks/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/** What this session did to one file of the project. */
2export type Visit = {
3 /** Read calls on it. */
4 reads: number
5 /** Changed by an Edit, Write or NotebookEdit that went through. */
6 isEdited: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'game-minimap': {
12 /** By the path relative to the project root, with forward slashes. */
13 visits: Record<string, Visit>
14 /** The project's files (git's list), read when the map opens and after each turn while it is open. */
15 files: string[]
16 }
17 }
18}
19