The hint line under the prompt adds what to press now: ESC 후퇴 while working, /compact 휴식 at low HP, 오류부터 읽기 after repeated failures, /save 세이브 when a save is…

The dim line under the prompt adds what to press now, after the engine's own words:
| When | Adds |
|---|---|
| Claude is working | ▸ ESC 후퇴 |
context left at lowHp or less | ▸ /compact 휴식 |
| two or more failed calls in a row | ▸ 오류부터 읽기 |
saveEvery turns without a save (with game-save-point) | ▸ /save 세이브 |
Two at most, most urgent first. Other plugins' additions to the line stay before these.
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-hint@bitgame-mods
intensity (casual) | off · casual · hardcore (counts the failures) |
lowHp (25) | percent of context left that suggests a rest |
saveEvery (5) | turns without a save that suggest /save |
프롬프트 아래 힌트 줄에 지금 누를 것을 덧붙입니다: 작업 중 ESC 후퇴, 컨텍스트가 적으면 /compact 휴식, 연속 실패면 오류부터 읽기, 한동안 저장하지 않았으면 /save 세이브.
hooks/register.tsx 89 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { HintCues } from '../types'
5import { intensityOf } from './palette'
6
7// GAME MODE · HINT
8//
9// The dim line under the prompt adds what to press now, after the engine's own words:
10// while Claude works ▸ ESC 후퇴
11// context running low ▸ /compact 휴식
12// the same kind of failure ▸ 오류부터 읽기 (two or more failed calls in a row)
13// several turns, no save ▸ /save 세이브 (with game-save-point)
14// Other plugins' additions to the line are kept, before these. The engine sets the tail off
15// from its own words with ` · `.
16
17const START: HintCues = { hpLeft: null, failStreak: 0, turnsSinceSave: 0, canSave: false }
18const cues = atom({ plugin: 'game-hint', key: 'cues' } as const, START)
19
20export const register: Register = (on, options) => {
21 const intensity = intensityOf(options.intensity)
22 const lowHp = Math.max(5, Math.min(60, Number(options.lowHp ?? 25)))
23 const saveEvery = Math.max(1, Number(options.saveEvery ?? 5))
24
25 on('session.start', async ($, e, next) => {
26 const result = await next(e)
27 const commands = await $.command.list().catch(() => [])
28 const canSave = commands.some(c => c.name === 'save' && c.plugin === 'game-save-point')
29 await update($, cues, c => ({ ...c, canSave }))
30 return result
31 })
32
33 on('session.measure', async ($, e, next) => {
34 const used = e.context.percent
35 if (used !== undefined && used !== null) await update($, cues, c => ({ ...c, hpLeft: Math.max(0, 100 - used) }))
36 return next(e)
37 }).catch(($, e, next) => next(e))
38
39 on('tool.call', async ($, e, next) => {
40 const ran = await next(e)
41 if (e.agentId === undefined) {
42 const failed = ran.deny !== undefined || ran.isError === true
43 await update($, cues, c => ({ ...c, failStreak: failed ? c.failStreak + 1 : 0 }))
44 }
45 return ran
46 }).catch(($, e, next) => next(e))
47
48 on('turn.complete', async ($, e, next) => {
49 if (e.agentId === undefined) await update($, cues, c => ({ ...c, turnsSinceSave: c.turnsSinceSave + 1 }))
50 return next(e)
51 }).catch(($, e, next) => next(e))
52
53 // A save point row resets the count, whoever wrote it.
54 on('session.append', async ($, e, next) => {
55 const stored = await next(e)
56 if (e.door === 'command' && isSaveRow(e.message.content)) await update($, cues, c => ({ ...c, turnsSinceSave: 0 })).catch(() => undefined)
57 return stored
58 }).catch(($, e, next) => next(e))
59
60 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
61 if (intensity === 'off') return next(e)
62 const tips = hintsOf(await read($, cues), e.props, lowHp, saveEvery, intensity === 'hardcore')
63 if (tips.length === 0) return next(e)
64 const mine = tips.map(t => `▸ ${t}`).join(' ')
65 const tail = e.props.tail === undefined || e.props.tail === '' ? mine : `${e.props.tail} · ${mine}`
66 return next({ ...e, props: { ...e.props, tail } })
67 })
68}
69
70/** What to press now, most urgent first; two at most. */
71export function hintsOf(c: HintCues, line: { isDraft: boolean; isWorking: boolean }, lowHp: number, saveEvery: number, isHardcore: boolean): string[] {
72 const tips: string[] = []
73 if (line.isWorking) tips.push('ESC 후퇴')
74 if (c.failStreak >= 2) tips.push(isHardcore ? `오류부터 읽기 (연속 실패 ${c.failStreak})` : '오류부터 읽기')
75 if (!line.isWorking) {
76 if (c.hpLeft !== null && c.hpLeft <= lowHp) tips.push('/compact 휴식')
77 else if (!line.isDraft && c.canSave && c.turnsSinceSave >= saveEvery) tips.push('/save 세이브')
78 }
79 return tips.slice(0, 2)
80}
81
82function isSaveRow(content: readonly unknown[]): boolean {
83 return content.some(block => {
84 if (block === null || typeof block !== 'object') return false
85 const b = block as { type?: unknown; text?: unknown }
86 return b.type === 'text' && typeof b.text === 'string' && b.text.includes('◆ SAVE POINT · ') && b.text.includes('[PASSWORD]')
87 })
88}
89hooks/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 18 lines1/** What the hint line reads to decide what to suggest. */
2export type HintCues = {
3 /** Context left, in percent; null before the first reading. */
4 hpLeft: number | null
5 /** Failed calls of the main loop in a row. */
6 failStreak: number
7 /** Turns since the last /save (or since the session started). */
8 turnsSinceSave: number
9 /** game-save-point is installed: /save exists. */
10 canSave: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'game-hint': { cues: HintCues }
16 }
17}
18