The model as equipment: EQUIP 모델 <model> 장착 · 교체는 /model at the start, in place of the model notice, and again when the model changes

The model is the session's equipment. At the start, and whenever it changes (/model, a fallback), the transcript says which one is on:
● game-equip: EQUIP 모델 Sonnet 5.5 장착 · 교체는 /model
● game-equip: EQUIP 모델 Opus 5.5 장착 · Sonnet 5.5에서 교체
Where the engine shows a model notice under the logo, that notice reads the same.
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-equip@bitgame-mods
intensity (casual) | off · casual · hardcore (a toast on each change) |
palette (nes) | nes · gameboy · amber · theme |
세션을 시작할 때와 모델이 바뀔 때 EQUIP 모델 … 장착 · 교체는 /model 줄로 지금 모델을 알려줍니다.
hooks/register.tsx 78 lines1import type { Register } from 'claude-code'
2
3import { intensityOf, paletteOf } from './palette'
4
5// GAME MODE · EQUIP
6//
7// The model is the session's equipment. At the start, and whenever it changes (/model,
8// a fallback), the transcript says which one is on:
9// EQUIP 모델 Sonnet 5.5 장착 · 교체는 /model
10// EQUIP 모델 Opus 5.5 장착 · Sonnet 5.5에서 교체
11// Where the engine shows a model notice under the logo, that notice reads the same.
12
13const MODEL_NOTICE = /\b(?:opus|sonnet|haiku|fable|mythos|claude-[a-z0-9.-]+)\b|\bmodel\b/i
14
15export const register: Register = (on, options) => {
16 const intensity = intensityOf(options.intensity)
17 const pal = paletteOf(options.palette)
18 let current = ''
19
20 on('session.start', async ($, e, next) => {
21 const result = await next(e)
22 if (intensity === 'off') return result
23 current = await $.session.model().catch(() => '')
24 if (current !== '') $.ui.log(equipLine(current))
25 return result
26 })
27
28 // A change the session made between turns (/model, a fallback): told at the next turn.
29 on('turn.start', async ($, e, next) => {
30 if (intensity !== 'off') {
31 const now = await $.session.model().catch(() => '')
32 if (now !== '' && current !== '' && now !== current) {
33 $.ui.log(equipLine(now, current))
34 if (intensity === 'hardcore') $.ui.toast(`EQUIP ${nameOf(now)}`)
35 }
36 if (now !== '') current = now
37 }
38 return next(e)
39 }).catch(($, e, next) => next(e))
40
41 on('classic.PostModelSwitch', async ($, e, next) => {
42 if (intensity !== 'off' && e.to_model !== current) {
43 $.ui.log(equipLine(e.to_model, e.from_model))
44 if (intensity === 'hardcore') $.ui.toast(`EQUIP ${nameOf(e.to_model)}`)
45 current = e.to_model
46 }
47 return next(e)
48 }).catch(($, e, next) => next(e))
49
50 // The engine's own model notice, where it shows one.
51 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
52 const isModel = e.props.command === 'model' || e.props.command === '/model' || MODEL_NOTICE.test(e.props.text)
53 if (intensity === 'off' || !isModel || current === '') return next(e)
54 const { Box, Text } = $.ui.resolve(e)
55 return (
56 <Box flexDirection="row" gap={1}>
57 <Text color={pal.title} bold>
58 EQUIP
59 </Text>
60 <Text color={pal.text}>{`모델 ${nameOf(current)} 장착`}</Text>
61 <Text color={pal.dim}>· 교체는 /model</Text>
62 </Box>
63 )
64 })
65}
66
67/** `claude-sonnet-5-5` → `Sonnet 5.5`; a name already plain stays. */
68export function nameOf(model: string): string {
69 const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(?:\[.*\])?$/i.exec(model.trim())
70 if (m === null) return model
71 const family = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1)
72 return `${family} ${m[2]}${m[3] !== undefined ? `.${m[3]}` : ''}`
73}
74
75export function equipLine(model: string, from?: string): string {
76 return from === undefined ? `EQUIP 모델 ${nameOf(model)} 장착 · 교체는 /model` : `EQUIP 모델 ${nameOf(model)} 장착 · ${nameOf(from)}에서 교체`
77}
78hooks/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