SLOPSHOPPER

GAME MODE · Stance

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

newspinnertimer
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-stance
A shopper browsing a rack in a slop shop
README

GAME MODE · Stance

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  자동 승인
ModeBadgeColor (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.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-stance@bitgame-mods

Settings

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으로 바꾸면 따라 바뀌고, 다음 프롬프트나 도구 결과에서 정확한 모드로 다시 맞춥니다.

Source 3 files
hooks/register.tsx 147 lines
1import { 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}
147
hooks/palette.ts 124 lines
1// 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
types/index.d.ts 12 lines
1/** 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