A status line with the branch and the play time: MAP <branch> · 플레이 h:mm, with ⚠ on a protected branch such as main

A status line with where you are and how long you have played:
MAP feat/login · 플레이 0:42
MAP main ⚠ 보호 · 플레이 1:05
The branch comes from git (@abc1234 when detached; 저장소 밖 outside a repository), read when the session starts, after each turn and once a minute. A protected branch is marked ⚠ 보호: work there goes straight to it. /map prints the branch, the play time and the number of changed files.
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-map@bitgame-mods
intensity (casual) | off · casual · hardcore (adds the changed files) |
protectedBranches (main,master,production,release/*) | names or patterns, * within one path part |
상태 줄에 지금 브랜치와 플레이 시간을 보여줍니다. main 같은 보호 브랜치에는 ⚠ 보호를 붙입니다.
hooks/register.tsx 110 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { intensityOf } from './palette'
4
5// GAME MODE · MAP
6//
7// A status line under the prompt with where you are and how long you have played:
8// MAP feat/login · 플레이 0:42
9// MAP main ⚠ 보호 · 플레이 1:05 (a protected branch: work there goes straight to main)
10// Read from git when the session starts, after each turn and once a minute.
11
12const EVERY_MS = 60_000
13
14type Setup = { isHardcore: boolean; protectedBranches: readonly RegExp[] }
15
16export const register: Register = (on, options) => {
17 const intensity = intensityOf(options.intensity)
18 const setup: Setup = {
19 isHardcore: intensity === 'hardcore',
20 protectedBranches: patternsOf(typeof options.protectedBranches === 'string' ? options.protectedBranches : 'main,master,production,release/*'),
21 }
22
23 on('session.start', async ($, e, next) => {
24 const result = await next(e)
25 if (intensity === 'off') return result
26 await $.command.register({ name: 'map', description: 'GAME MODE map: the branch, the play time and the changed files' })
27 await refresh($, setup)
28 $.clock.every(EVERY_MS, () => {
29 void refresh($, setup).catch(() => undefined)
30 })
31 return result
32 })
33
34 on('turn.complete', async ($, e, next) => {
35 const result = await next(e)
36 if (intensity !== 'off' && e.agentId === undefined) await refresh($, setup).catch(() => undefined)
37 return result
38 }).catch(($, e, next) => next(e))
39
40 on('command.run', { command: 'map' }, async ($) => {
41 const place = await placeOf($)
42 const usage = await $.session.usage()
43 const played = playTime((await $.clock.now()) - usage.startedAt)
44 if (place.branch === null) return { text: `MAP · 저장소 밖 · 플레이 ${played}` }
45 const isProtected = matches(place.branch, setup.protectedBranches)
46 return {
47 text: [
48 `MAP ${place.branch}${isProtected ? ' ⚠ 보호 브랜치' : ''}`,
49 `플레이 ${played}`,
50 `변경 파일 ${place.changed}`,
51 ...(isProtected ? ['보호 브랜치에서 일하는 중: 새 브랜치에서 작업하려면 git switch -c <이름>'] : []),
52 ].join('\n'),
53 }
54 })
55}
56
57type Place = { branch: string | null; changed: number }
58
59/** The branch (`@abc1234` when detached) and how many files differ from it; branch null outside a repository. */
60async function placeOf($: EngineInterface): Promise<Place> {
61 const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 })
62 if (head.exitCode !== 0) return { branch: null, changed: 0 }
63 let branch = head.stdout.trim()
64 if (branch === 'HEAD') {
65 const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: 3000 })
66 branch = `@${sha.stdout.trim()}`
67 }
68 const status = await $.process.run(['git', 'status', '--porcelain'], { timeoutMs: 5000 })
69 const changed = status.exitCode === 0 ? status.stdout.split('\n').filter(l => l.trim() !== '').length : 0
70 return { branch, changed }
71}
72
73async function refresh($: EngineInterface, setup: Setup): Promise<void> {
74 const usage = await $.session.usage()
75 const played = playTime((await $.clock.now()) - usage.startedAt)
76 let place: Place = { branch: null, changed: 0 }
77 try {
78 place = await placeOf($)
79 } catch {
80 // No git, or no process runner on this surface: the play time alone.
81 }
82 $.ui.status(mapLine(place, played, setup))
83}
84
85export function mapLine(place: Place, played: string, setup: Setup): string {
86 if (place.branch === null) return `MAP 저장소 밖 · 플레이 ${played}`
87 const mark = matches(place.branch, setup.protectedBranches) ? ' ⚠ 보호' : ''
88 const extra = setup.isHardcore ? ` · 변경 ${place.changed}` : ''
89 return `MAP ${place.branch}${mark} · 플레이 ${played}${extra}`
90}
91
92/** `h:mm` */
93export function playTime(ms: number): string {
94 const minutes = Math.max(0, Math.floor(ms / 60_000))
95 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`
96}
97
98/** `main,release/*` → patterns; `*` matches within one path part. */
99export function patternsOf(list: string): RegExp[] {
100 return list
101 .split(',')
102 .map(p => p.trim())
103 .filter(p => p !== '')
104 .map(p => new RegExp(`^${p.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`))
105}
106
107function matches(branch: string, patterns: readonly RegExp[]): boolean {
108 return patterns.some(p => p.test(branch))
109}
110hooks/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}
124