8-bit HUD window above the prompt: LV/EXP, HP = context left, MP = plan usage left, G = session cost, a status row (HP LOW!, REST, SAVED, LEVEL UP!) with rest…

An 8-bit window above the prompt, drawn from figures Claude Code already keeps:
╔═════════════════════════════════════════════════════════════════════════════╗
║ LV 03 EXP █░░░░░░░░░ 10% HP ██████████ 96% MP ████████░░ 77% 5h G ₩263 ║
║ READY 다음 레벨까지 성공 행동 2 [ 휴식 /compact ] [ 세이브 /save ] ║
╚═════════════════════════════════════════════════════════════════════════════╝
| Shows | Means | Source |
|---|---|---|
| LV · EXP | successful tool calls as levels, and the way to the next one | tool.call |
| HP | context window left | session.measure → context.percent |
| MP | plan usage left in the 5-hour window (7-day or spend limit when that is all there is) | session.measure → rateLimits |
| G | what this session cost, ₩ by default | session.measure → cost.usd |
The second row says what needs you or what just happened, with a message:
| Tag | When |
|---|---|
HP LOW! | context left at lowHp (25%) or less; a toast once per dip; 휴식 is the primary button |
SAVED | a save point was just made |
REST | the conversation was just compacted |
LEVEL UP! | a new level, until your next prompt |
READY | otherwise: the successful calls to the next level |
휴식 runs /compact and 세이브 runs /save (shown only when game-save-point is installed), both as if typed. Focus the band with a click or ctrl+x tab, then r or s. Another plugin's band (the save point's title screen) is drawn above the window, never replaced.
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-hud@bitgame-mods
/plugin configure game-hud@bitgame-mods)| Field | Default | |
|---|---|---|
intensity | casual | off hides the band · casual the window · hardcore adds the turn count |
palette | nes | nes · gameboy · amber for dark terminals · theme follows your theme (use it on a light background) |
lowHp | 25 | percent of context left that shows HP LOW! |
currency | krw | usd shows the ledger as kept |
krwPerUsd | 1400 | rate for krw |
| Command | |
|---|---|
/hud | LV/EXP/HP/MP/G as text |
/hud hide · /hud show | hide the band (screen sharing) and bring it back; remembered across sessions |
-- until the first response of a session (or right after a compaction).krw is a fixed-rate conversion.프롬프트 위 창에 LV·EXP(성공한 행동), HP(남은 컨텍스트), MP(요금제 한도 남은 양), G(세션 비용, 기본 ₩)를 보여줍니다. 둘째 줄은 상태(READY·HP LOW!·SAVED·REST·LEVEL UP!)와 메시지, 휴식 /compact·세이브 /save 버튼입니다. /hud hide로 숨길 수 있습니다.
hooks/register.tsx 283 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { HudEvent, Vitals } from '../types'
5import { bar, intensityOf, money, paletteOf, windowProps } from './palette'
6import type { Palette } from './palette'
7
8// GAME MODE · HUD
9//
10// A framed band above the prompt, drawn from figures the engine already keeps:
11// LV / EXP = successful tool calls, as levels and progress to the next
12// HP = context window left (session.measure → context.percent)
13// MP = plan usage left (session.measure → rateLimits, five_hour first)
14// G = what the session cost (session.measure → cost.usd), ₩ by default
15// The second row says what needs you (HP LOW!) or what just happened (REST,
16// SAVED, LEVEL UP!), with buttons for rest (/compact) and, when
17// game-save-point is installed, save (/save). Another plugin's band is drawn
18// above this one, never replaced.
19
20const INITIAL: Vitals = {
21 contextUsed: null,
22 limitKind: null,
23 limitUsed: null,
24 usd: null,
25 turns: 0,
26 hits: 0,
27 hasSave: false,
28 isHidden: false,
29 warned: false,
30 event: null,
31}
32
33const vitals = atom({ plugin: 'game-hud', key: 'vitals' } as const, INITIAL)
34const REST: HudEvent = { kind: 'rest' }
35const SAVED: HudEvent = { kind: 'saved' }
36
37type Currency = 'usd' | 'krw'
38
39export const register: Register = (on, options) => {
40 const intensity = intensityOf(options.intensity)
41 const pal = paletteOf(options.palette)
42 const lowHp = clamp(Number(options.lowHp ?? 25), 5, 60)
43 const currency: Currency = options.currency === 'usd' ? 'usd' : 'krw'
44 const krwPerUsd = Number(options.krwPerUsd ?? 1400)
45
46 on('session.start', async ($, e, next) => {
47 const usage = await $.session.usage()
48 const turns = await $.session.turns()
49 const commands = await $.command.list()
50 const isHidden = (await $.store.get('hidden')) === true
51 await update($, vitals, v => ({
52 ...v,
53 ...fromUsage(usage),
54 turns,
55 isHidden,
56 hasSave: commands.some(c => c.name === 'save'),
57 }))
58 // game-save-point may register /save after this hook ran: look again shortly.
59 $.clock.after(1500, () => {
60 void recheckSave($)
61 })
62 await $.command.register({
63 name: 'hud',
64 description: 'GAME MODE HUD: show HP/MP/G as text, or hide/show the band',
65 argumentHint: '[hide|show]',
66 })
67 return next(e)
68 })
69
70 // A new prompt: last turn's news is old. Another plugin's /save may register late.
71 on('turn.start', async ($, e, next) => {
72 const commands = await $.command.list()
73 const hasSave = commands.some(c => c.name === 'save')
74 await update($, vitals, v => ({ ...v, hasSave, event: null }))
75 return next(e)
76 })
77
78 on('session.measure', async ($, e, next) => {
79 let warning: string | undefined
80 await update($, vitals, v => {
81 const nv: Vitals = {
82 ...v,
83 contextUsed: e.context.percent ?? v.contextUsed,
84 ...limitOf(e.rateLimits),
85 usd: e.cost?.usd ?? v.usd,
86 }
87 const left = nv.contextUsed === null ? null : 100 - nv.contextUsed
88 if (left !== null && left <= lowHp && !v.warned) {
89 nv.warned = true
90 warning = `HP LOW! 컨텍스트 ${left}% 남음 · 휴식(/compact) 또는 세이브(/save)`
91 } else if (left !== null && left > lowHp + 10) {
92 nv.warned = false
93 }
94 return nv
95 })
96 if (warning !== undefined && intensity !== 'off') $.ui.toast(warning)
97 return next(e)
98 })
99
100 on('turn.complete', async ($, e, next) => {
101 if (e.agentId === undefined) await update($, vitals, v => ({ ...v, turns: v.turns + 1 }))
102 return next(e)
103 })
104
105 // Observes only: whatever happens here, the call's own result goes on.
106 on('tool.call', async ($, e, next) => {
107 const ran = await next(e)
108 if (ran.deny === undefined && ran.isError !== true) {
109 await update($, vitals, v => {
110 const before = levelOf(v.hits).level
111 const after = levelOf(v.hits + 1).level
112 const event: HudEvent | null = after > before ? { kind: 'levelup', level: after } : v.event
113 return { ...v, hits: v.hits + 1, event }
114 })
115 }
116 return ran
117 }).catch(($, e, next) => next(e))
118
119 on('session.compact', async ($, e, next) => {
120 const done = await next(e)
121 if (e.agentId === undefined) await update($, vitals, v => ({ ...v, event: REST }))
122 return done
123 }).catch(($, e, next) => next(e))
124
125 // A save is heard from the row it leaves, whichever plugin answered /save.
126 on('session.append', async ($, e, next) => {
127 const stored = await next(e)
128 if (e.door === 'command' && isSaveRow(e.message.content)) await update($, vitals, v => ({ ...v, event: SAVED }))
129 return stored
130 }).catch(($, e, next) => next(e))
131
132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133 if (intensity === 'off' || e.props.hasSurvey) return next(e)
134 const v = await read($, vitals)
135 const below = await next(e)
136 if (v.isHidden) return below
137
138 const { Box, Text, Button } = $.ui.resolve(e)
139 const inner = e.props.bodyColumns - 4
140 const cells = inner >= 90 ? 10 : inner >= 72 ? 8 : inner >= 56 ? 5 : 0
141
142 const hpLeft = v.contextUsed === null ? null : clamp(100 - v.contextUsed, 0, 100)
143 const mpLeft = v.limitUsed === null ? null : clamp(100 - v.limitUsed, 0, 100)
144 const lv = levelOf(v.hits)
145 const expPercent = Math.round((lv.into / lv.span) * 100)
146 const status = statusOf(v, hpLeft, lowHp, lv, intensity === 'hardcore', pal)
147
148 const gauge = (label: string, left: number | null, extra = '') =>
149 left === null ? `${label} --` : `${label} ${cells > 0 ? bar(left, cells) + ' ' : ''}${left}%${extra ? ' ' + extra : ''}`
150
151 const press = (command: string) => () => {
152 void $.command.run({ command }).catch(() => $.ui.toast(`/${command}을(를) 실행하지 못했다`))
153 }
154
155 const band = (
156 <Box {...windowProps(pal)} flexDirection="column">
157 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
158 <Text color={pal.title} bold>
159 LV {String(lv.level).padStart(2, '0')}
160 </Text>
161 <Text color={pal.ok}>{gauge('EXP', expPercent)}</Text>
162 <Text color={toneOf(hpLeft, lowHp, pal)}>{gauge('HP', hpLeft)}</Text>
163 {mpLeft !== null && <Text color={pal.info}>{gauge('MP', mpLeft, shortKind(v.limitKind))}</Text>}
164 {v.usd !== null && (
165 <Text color={pal.title} bold>
166 G {money(v.usd, currency, krwPerUsd)}
167 </Text>
168 )}
169 </Box>
170 <Box flexDirection="row" columnGap={1}>
171 <Text color={status.color} bold>
172 {status.tag}
173 </Text>
174 <Box flexGrow={1} flexShrink={1}>
175 <Text color={pal.text} wrap="truncate-end">
176 {status.message}
177 </Text>
178 </Box>
179 <Button key="rest" label="휴식 /compact" hotkey="r" variant={status.tag === 'HP LOW!' ? 'primary' : 'secondary'} onPress={press('compact')} />
180 {v.hasSave && <Button key="save" label="세이브 /save" hotkey="s" onPress={press('save')} />}
181 </Box>
182 </Box>
183 )
184 return (
185 <Box flexDirection="column">
186 {below}
187 {band}
188 </Box>
189 )
190 })
191
192 on('command.run', { command: 'hud' }, async ($, e) => {
193 const sub = e.args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
194 if (sub === 'hide' || sub === 'show') {
195 const isHidden = sub === 'hide'
196 await $.store.set('hidden', isHidden)
197 await update($, vitals, v => ({ ...v, isHidden }))
198 return { text: isHidden ? 'HUD hidden. /hud show brings it back.' : 'HUD shown.' }
199 }
200 if (sub !== '') return { text: `Unknown option "${sub}". Use /hud, /hud hide or /hud show.` }
201
202 const v = await read($, vitals)
203 const lv = levelOf(v.hits)
204 const lines = [
205 `HP ${v.contextUsed === null ? '--' : `${100 - v.contextUsed}% (context used ${v.contextUsed}%)`}`,
206 `MP ${v.limitUsed === null ? '-- (no plan usage reading)' : `${100 - v.limitUsed}% (${v.limitKind} used ${v.limitUsed}%)`}`,
207 `G ${v.usd === null ? '--' : money(v.usd, currency, krwPerUsd)}`,
208 `LV ${lv.level} · EXP ${lv.into}/${lv.span} (successful tool calls ${v.hits}) · turns ${v.turns}`,
209 `intensity ${intensity} · HP LOW! at ≤ ${lowHp}%`,
210 ]
211 return { text: lines.join('\n') }
212 })
213}
214
215type Status = { tag: string; message: string; color: string }
216
217/** The second row: what needs you first, then what just happened, else the way to the next level. */
218export function statusOf(
219 v: Vitals,
220 hpLeft: number | null,
221 lowHp: number,
222 lv: { level: number; into: number; span: number },
223 isHardcore: boolean,
224 pal: Palette,
225): Status {
226 if (hpLeft !== null && hpLeft <= lowHp) {
227 return { tag: 'HP LOW!', message: `컨텍스트 ${hpLeft}% 남음 · 휴식(/compact)하면 지금까지를 요약하고 이어간다`, color: pal.bad }
228 }
229 if (v.event?.kind === 'saved') return { tag: 'SAVED', message: '세이브 완료 · 다음 세션 빈 입력창에서 Tab 하면 이어하기', color: pal.title }
230 if (v.event?.kind === 'rest') return { tag: 'REST', message: '휴식 완료 · 지금까지를 요약하고 컨텍스트를 비웠다', color: pal.ok }
231 if (v.event?.kind === 'levelup') {
232 return { tag: 'LEVEL UP!', message: `LV ${v.event.level ?? lv.level} 달성 · 성공한 행동 ${v.hits}번`, color: pal.title }
233 }
234 const turns = isHardcore ? ` · 턴 ${v.turns}` : ''
235 return { tag: 'READY', message: `다음 레벨까지 성공 행동 ${lv.span - lv.into}${turns}`, color: pal.ok }
236}
237
238async function recheckSave($: EngineInterface): Promise<void> {
239 const commands = await $.command.list()
240 if (commands.some(c => c.name === 'save')) await update($, vitals, v => (v.hasSave ? v : { ...v, hasSave: true }))
241}
242
243function isSaveRow(content: readonly unknown[]): boolean {
244 const text = content
245 .map(b => (b !== null && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
246 .join('\n')
247 return text.includes('◆ SAVE POINT · ') && text.includes('[PASSWORD]')
248}
249
250function fromUsage(u: SessionUsage): Pick<Vitals, 'contextUsed' | 'limitKind' | 'limitUsed' | 'usd'> {
251 return { contextUsed: u.context.percent ?? null, ...limitOf(u.rateLimits), usd: u.cost?.usd ?? null }
252}
253
254function limitOf(list: readonly SessionRateLimit[]): Pick<Vitals, 'limitKind' | 'limitUsed'> {
255 const w = list.find(r => r.kind === 'five_hour') ?? list[0]
256 return w ? { limitKind: w.kind, limitUsed: Math.round(w.percentUsed) } : { limitKind: null, limitUsed: null }
257}
258
259function shortKind(kind: string | null): string {
260 if (kind === 'five_hour') return '5h'
261 if (kind === 'seven_day') return '7d'
262 if (kind === 'spend_limit') return 'spend'
263 return kind ?? ''
264}
265
266function toneOf(left: number | null, low: number, pal: Palette): string {
267 if (left === null) return pal.dim
268 if (left > 50) return pal.ok
269 if (left > low) return pal.title
270 return pal.bad
271}
272
273/** Level n starts at 4·(n−1)² successful calls. */
274export function levelOf(hits: number): { level: number; into: number; span: number } {
275 const level = Math.floor(Math.sqrt(Math.max(0, hits) / 4)) + 1
276 const floor = 4 * (level - 1) ** 2
277 return { level, into: hits - floor, span: 4 * (2 * level - 1) }
278}
279
280function clamp(n: number, lo: number, hi: number): number {
281 return Number.isFinite(n) ? Math.max(lo, Math.min(hi, Math.round(n))) : lo
282}
283hooks/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 37 lines1/** Something that just happened, shown in the HUD's second row until the next prompt. */
2export type HudEvent = {
3 kind: 'rest' | 'saved' | 'levelup'
4 /** The level reached, for `levelup`. */
5 level?: number
6}
7
8/** What the HUD draws from; held in $.state for the session. */
9export type Vitals = {
10 /** Context window used, 0–100; null before the first response of the window. */
11 contextUsed: number | null
12 /** The plan-usage window shown as MP (`five_hour` first); null without a reading. */
13 limitKind: string | null
14 /** How much of that window is used, 0–100. */
15 limitUsed: number | null
16 /** What the session cost, US dollars; null where no ledger is kept. */
17 usd: number | null
18 /** Main-loop turns completed. */
19 turns: number
20 /** Tool calls that ran without an error or a deny (LV/EXP). */
21 hits: number
22 /** Whether a /save command (game-save-point) is available. */
23 hasSave: boolean
24 /** /hud hide */
25 isHidden: boolean
26 /** The low-HP toast was shown for the current dip. */
27 warned: boolean
28 /** A rest, a save or a level-up since the last prompt; null when none. */
29 event: HudEvent | null
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'game-hud': { vitals: Vitals }
35 }
36}
37