The permission mode as a colored badge at the right of the prompt footer: 기본, 자동 승인, 계획, 자동 판정, 우회, 묻지 않음; follows shift+tab

The permission mode as a badge at the right of the prompt footer, in its own color, so a session left in 자동 승인 or 우회 does not go unnoticed:
⏵⏵ accept edits on (shift+tab to cycle) · ← for agents STANCE 자동 승인
| Mode | Badge | Color (nes) |
|---|---|---|
| default | 기본 | green |
| acceptEdits | 자동 승인 | yellow |
| plan | 계획 | blue |
| auto | 자동 판정 | orange |
| bypassPermissions | 우회 | red |
| dontAsk | 묻지 않음 | grey |
Where the mode comes from: the hook input of each prompt and each tool result (permission_mode, exact); the settings' permissions.defaultMode at the start; and, between prompts, the footer line. No event reports a shift+tab, and the line handed to a mod names a mode only in the one drawing made as the mode changes, naming the mode it leaves. The badge then moves to the next mode in shift+tab's order (기본 → 자동 승인 → 계획 → [우회] → 자동 판정 → 기본), and the next prompt or tool result confirms it. A change made while Claude works (leaving plan mode) waits for that tool result.
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-stance@bitgame-mods
intensity (casual) | off leaves the footer to the engine · casual the badge · hardcore adds what the mode lets Claude do (파일 수정은 묻지 않는다) |
palette (nes) | nes · gameboy · amber · theme |
프롬프트 아래 오른쪽에 지금 권한 모드를 색 배지로 보여줍니다(기본·자동 승인·계획·자동 판정·우회·묻지 않음). shift+tab으로 바꾸면 따라 바뀌고, 다음 프롬프트나 도구 결과에서 정확한 모드로 다시 맞춥니다.
hooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StanceMode } from '../types'
5import { intensityOf, paletteOf } from './palette'
6import type { Palette } from './palette'
7
8// GAME MODE · STANCE
9//
10// The permission mode as a badge at the right of the prompt footer, in its own
11// color, so a session left in 자동 승인 or 우회 does not go unnoticed:
12// STANCE 자동 승인
13//
14// Where the mode comes from:
15// - the hook input of each prompt and tool result (`permission_mode`), exact;
16// - the settings' `permissions.defaultMode` when the session starts;
17// - shift+tab between prompts: the footer's line, as handed to a PromptHint hook, names
18// the mode only in the one drawing made as the mode changes, and then names the mode
19// it is leaving. The badge moves to the mode after it in shift+tab's order
20// (기본 → 자동 승인 → 계획 → [우회] → 자동 판정 → 기본). Steady lines tell 기본
21// (`? for shortcuts`) from the rest (`shift+tab to cycle`). The next prompt or
22// tool result confirms or corrects it.
23
24type Stance = { label: string; meaning: string; tone: keyof Palette }
25
26export const STANCES: Record<StanceMode, Stance> = {
27 default: { label: '기본', meaning: '위험한 행동은 묻는다', tone: 'ok' },
28 acceptEdits: { label: '자동 승인', meaning: '파일 수정은 묻지 않는다', tone: 'title' },
29 plan: { label: '계획', meaning: '읽고 계획만 세운다', tone: 'info' },
30 auto: { label: '자동 판정', meaning: '분류기가 허용 여부를 정한다', tone: 'warn' },
31 bypassPermissions: { label: '우회', meaning: '아무것도 묻지 않는다', tone: 'bad' },
32 dontAsk: { label: '묻지 않음', meaning: '허용 목록 밖은 거절한다', tone: 'dim' },
33}
34
35const mode = atom({ plugin: 'game-stance', key: 'mode' } as const, 'default' as StanceMode)
36
37export const register: Register = (on, options) => {
38 const intensity = intensityOf(options.intensity)
39 const pal = paletteOf(options.palette)
40 // 우회 joins shift+tab's order only in a session that allows it.
41 let hasBypass = false
42
43 on('session.start', async ($, e, next) => {
44 const result = await next(e)
45 const settings = await $.settings.read().catch(() => undefined)
46 const configured = modeOfSetting((settings as { permissions?: { defaultMode?: unknown } } | undefined)?.permissions?.defaultMode)
47 if (configured !== undefined) {
48 if (configured === 'bypassPermissions') hasBypass = true
49 await update($, mode, () => configured)
50 }
51 return result
52 })
53
54 // What the classic hooks are told: the mode at each prompt and after each tool call.
55 on('classic.UserPromptSubmit', async ($, e, next) => {
56 const told = modeOfSetting(e.permission_mode)
57 if (told === 'bypassPermissions') hasBypass = true
58 await settle($, told)
59 return next(e)
60 }).catch(($, e, next) => next(e))
61
62 on('classic.PostToolUse', async ($, e, next) => {
63 const told = e.agent_id === undefined ? modeOfSetting(e.permission_mode) : undefined
64 if (told === 'bypassPermissions') hasBypass = true
65 await settle($, told)
66 return next(e)
67 }).catch(($, e, next) => next(e))
68
69 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
70 // A change while Claude works is the engine's (leaving plan mode): the next tool result says it.
71 if (intensity === 'off' || e.props.isWorking) return next(e)
72 const now = await read($, mode)
73 const left = modeOfHint(e.props.hint)
74 let target: StanceMode | undefined
75 if (left !== undefined) {
76 if (left === 'bypassPermissions') hasBypass = true
77 target = nextMode(left, hasBypass)
78 } else if (!e.props.isDraft && e.props.hint.includes('? for shortcuts') && !e.props.hint.includes('shift+tab')) {
79 target = 'default'
80 }
81 if (target !== undefined && target !== now) {
82 const to = target
83 // A drawing cannot write state: write on the next tick, then draw the badge again.
84 $.clock.after(0, () => {
85 void update($, mode, () => to).then(() => $.ui.invalidate('ui.render'))
86 })
87 }
88 return next(e)
89 })
90
91 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
92 if (intensity === 'off') return next(e)
93 const stance = STANCES[await read($, mode)]
94 const { Box, Text } = $.ui.resolve(e)
95 return (
96 <Box flexDirection="row" gap={1}>
97 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
98 <Text color={pal.dim}>STANCE</Text>
99 <Text color={pal[stance.tone]} inverse bold>
100 {` ${stance.label} `}
101 </Text>
102 {intensity === 'hardcore' && <Text color={pal.dim}>{stance.meaning}</Text>}
103 </Box>
104 )
105 })
106}
107
108/** Sets the mode from an exact source and draws the badge again when it changed. */
109async function settle($: EngineInterface, told: StanceMode | undefined): Promise<void> {
110 if (told === undefined || told === (await read($, mode))) return
111 await update($, mode, () => told)
112 $.ui.invalidate('ui.render')
113}
114
115/** The mode shift+tab moves to from `from`. */
116export function nextMode(from: StanceMode, hasBypass: boolean): StanceMode {
117 switch (from) {
118 case 'default':
119 return 'acceptEdits'
120 case 'acceptEdits':
121 return 'plan'
122 case 'plan':
123 return hasBypass ? 'bypassPermissions' : 'auto'
124 case 'bypassPermissions':
125 return 'auto'
126 default:
127 return 'default'
128 }
129}
130
131/** The mode a settings name stands for (`acceptEdits`, ...); undefined for anything else. */
132export function modeOfSetting(value: unknown): StanceMode | undefined {
133 return typeof value === 'string' && value in STANCES ? (value as StanceMode) : undefined
134}
135
136/** The mode the footer's line names (`⏵⏵ accept edits on`, `⏸ plan mode on`, ...). */
137export function modeOfHint(hint: string): StanceMode | undefined {
138 const h = hint.toLowerCase()
139 if (h.includes('accept edits on')) return 'acceptEdits'
140 if (h.includes('plan mode on')) return 'plan'
141 if (h.includes('auto mode on')) return 'auto'
142 if (h.includes('bypass permissions on')) return 'bypassPermissions'
143 if (/don['’]?t ask(?: mode)? on/.test(h)) return 'dontAsk'
144 if (h.includes('manual mode on') || h.includes('default mode on')) return 'default'
145 return undefined
146}
147hooks/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 12 lines1/** The permission modes Claude Code runs in, by the names its settings and hooks use. */
2export type StanceMode = 'default' | 'acceptEdits' | 'plan' | 'auto' | 'bypassPermissions' | 'dontAsk'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'game-stance': {
7 /** The mode now: from the footer's own line (shift+tab) and from each prompt's hook input. */
8 mode: StanceMode
9 }
10 }
11}
12