A turn ends with CLEAR m:ss · 행동 n · 이번 턴 ₩x · 평소보다 ±m:ss, against the median of the project's last 20 turns

The line that closes a turn says how it went, against how turns usually go in this project:
✦ CLEAR 0:42 · 행동 7 · 이번 턴 ₩312 · 평소보다 −0:13
/clear-time prints the project's usual turn, the fastest and the slowest.
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-clear-time@bitgame-mods
intensity (casual) | off · casual · hardcore (adds the median it compares against) |
palette (nes) | nes · gameboy · amber · theme |
currency (krw) · krwPerUsd (1400) | how the cost is shown |
턴이 끝나는 줄을 CLEAR 걸린 시간 · 행동 수 · 이번 턴 비용 · 평소보다 ±시간으로 바꿉니다. 평소는 이 프로젝트의 최근 20턴 중앙값입니다.
hooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ClearRecord, TurnNow } from '../types'
5import { clock, intensityOf, money, paletteOf } from './palette'
6
7// GAME MODE · CLEAR TIME
8//
9// The line that closes a turn says how it went, against how turns usually go in this project:
10// ✦ CLEAR 0:42 · 행동 7 · 이번 턴 ₩312 · 평소보다 −0:13
11// "평소" is the median of the project's last 20 finished turns, kept across sessions.
12
13const HISTORY = 20
14const KEEP = 100
15const MATCH_MS = 1500
16
17const turn = atom({ plugin: 'game-clear-time', key: 'turn' } as const, { actions: 0, startUsd: null, isOpen: false, seen: [] } as TurnNow)
18const records = atom({ plugin: 'game-clear-time', key: 'records' } as const, [] as ClearRecord[])
19
20export const register: Register = (on, options) => {
21 const intensity = intensityOf(options.intensity)
22 const pal = paletteOf(options.palette)
23 const currency = options.currency === 'usd' ? 'usd' : 'krw'
24 const krwPerUsd = Number(options.krwPerUsd ?? 1400) || 1400
25 let key = 'durations'
26
27 on('session.start', async ($, e, next) => {
28 key = `durations:${await $.session.root()}`
29 await $.command.register({ name: 'clear-time', description: 'GAME MODE clear time: this project\'s usual turn, best and worst' })
30 return next(e)
31 })
32
33 on('turn.start', async ($, e, next) => {
34 const usage = await $.session.usage().catch(() => undefined)
35 await update($, turn, () => ({ actions: 0, startUsd: usage?.cost?.usd ?? null, isOpen: true, seen: [] }))
36 return next(e)
37 }).catch(($, e, next) => next(e))
38
39 // A call counts once, from the model's tool_use row (where a refused call shows too, whichever
40 // plugin refused it) or from tool.call, whichever comes first.
41 on('session.append', async ($, e, next) => {
42 const stored = await next(e)
43 if (e.agentId === undefined && e.door === 'response') {
44 const ids = usesOf(e.message.content)
45 if (ids.length > 0) await update($, turn, t => counted(t, ids)).catch(() => undefined)
46 }
47 return stored
48 }).catch(($, e, next) => next(e))
49
50 on('tool.call', async ($, e, next) => {
51 if (e.agentId === undefined) await update($, turn, t => counted(t, [e.tool_use_id]))
52 return next(e)
53 }).catch(($, e, next) => next(e))
54
55 // A cost reading that lands after the turn closed settles that turn's cost.
56 on('session.measure', async ($, e, next) => {
57 const usd = e.cost?.usd
58 if (usd !== undefined && !(await read($, turn)).isOpen) {
59 await update($, records, list => settle(list, usd))
60 }
61 return next(e)
62 }).catch(($, e, next) => next(e))
63
64 on('turn.complete', async ($, e, next) => {
65 if (e.agentId !== undefined) return next(e)
66 const now = await read($, turn)
67 const kept = await $.store.get(key)
68 const history = Array.isArray(kept) ? kept.filter((n): n is number => typeof n === 'number') : []
69 const median = history.length >= 3 ? medianOf(history) : null
70 const usage = await $.session.usage().catch(() => undefined)
71 const usdNow = usage?.cost?.usd ?? null
72 const record: ClearRecord = {
73 durationMs: e.durationMs,
74 actions: now.actions,
75 startUsd: now.startUsd,
76 usd: usdNow !== null && now.startUsd !== null ? Math.max(0, usdNow - now.startUsd) : null,
77 deltaMs: median === null ? null : e.durationMs - median,
78 medianMs: median,
79 }
80 await update($, records, list => [...list, record].slice(-KEEP))
81 await update($, turn, t => ({ ...t, isOpen: false }))
82 // Only turns that ran to an answer say how long a turn usually takes.
83 if (e.reason === 'answer') await $.store.set(key, [...history, e.durationMs].slice(-HISTORY))
84 return next(e)
85 }).catch(($, e, next) => next(e))
86
87 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
88 if (intensity === 'off') return next(e)
89 const rec = recordFor(await read($, records), e.props.durationMs)
90 const { Box, Text } = $.ui.resolve(e)
91 const delta = rec?.deltaMs ?? null
92 return (
93 <Box flexDirection="row" gap={1}>
94 <Text color={pal.title} bold>
95 ✦ CLEAR
96 </Text>
97 <Text color={pal.text} bold>
98 {clock(e.props.durationMs)}
99 </Text>
100 {rec !== undefined && <Text color={pal.dim}>{`· 행동 ${rec.actions}`}</Text>}
101 {rec !== undefined && rec.usd !== null && <Text color={pal.dim}>{`· 이번 턴 ${money(rec.usd, currency, krwPerUsd)}`}</Text>}
102 {delta !== null && (
103 <Text color={delta <= 0 ? pal.ok : pal.warn}>{`· 평소보다 ${delta <= 0 ? '−' : '+'}${clock(Math.abs(delta))}`}</Text>
104 )}
105 {intensity === 'hardcore' && rec?.medianMs != null && <Text color={pal.dim}>{`(평소 ${clock(rec.medianMs)})`}</Text>}
106 </Box>
107 )
108 })
109
110 on('command.run', { command: 'clear-time' }, async ($) => {
111 const kept = await $.store.get(key)
112 const history = Array.isArray(kept) ? kept.filter((n): n is number => typeof n === 'number') : []
113 if (history.length === 0) return { text: 'No finished turns on record for this project yet.' }
114 const sorted = [...history].sort((a, b) => a - b)
115 return {
116 text: [
117 `CLEAR TIME · last ${history.length} turns of this project`,
118 `평소 (median) ${clock(medianOf(history))}`,
119 `최단 ${clock(sorted[0] ?? 0)} · 최장 ${clock(sorted[sorted.length - 1] ?? 0)}`,
120 ].join('\n'),
121 }
122 })
123}
124
125function counted(t: TurnNow, ids: readonly string[]): TurnNow {
126 const seen = t.seen ?? []
127 const fresh = ids.filter(id => !seen.includes(id))
128 return fresh.length === 0 ? t : { ...t, actions: t.actions + fresh.length, seen: [...seen, ...fresh].slice(-500) }
129}
130
131function usesOf(content: readonly unknown[]): string[] {
132 return content.flatMap(b => (b !== null && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_use' && typeof (b as { id?: unknown }).id === 'string' ? [(b as { id: string }).id] : []))
133}
134
135export function medianOf(values: readonly number[]): number {
136 const sorted = [...values].sort((a, b) => a - b)
137 const mid = Math.floor(sorted.length / 2)
138 return sorted.length % 2 === 1 ? (sorted[mid] ?? 0) : Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
139}
140
141/** The record a closing line belongs to: the same duration, or the nearest within 1.5 s, newest first. */
142export function recordFor(list: readonly ClearRecord[], durationMs: number): ClearRecord | undefined {
143 let best: ClearRecord | undefined
144 let bestGap = MATCH_MS + 1
145 for (let i = list.length - 1; i >= 0; i--) {
146 const r = list[i]
147 if (r === undefined) continue
148 const gap = Math.abs(r.durationMs - durationMs)
149 if (gap === 0) return r
150 if (gap < bestGap) {
151 best = r
152 bestGap = gap
153 }
154 }
155 return bestGap <= MATCH_MS ? best : undefined
156}
157
158/** The newest record's cost, from a reading taken after its turn closed. */
159function settle(list: readonly ClearRecord[], usd: number): ClearRecord[] {
160 const last = list[list.length - 1]
161 if (last === undefined || last.startUsd === null) return [...list]
162 return [...list.slice(0, -1), { ...last, usd: Math.max(0, usd - last.startUsd) }]
163}
164hooks/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 36 lines1/** The turn running now. */
2export type TurnNow = {
3 /** Calls the main loop made this turn. */
4 actions: number
5 /** The session's cost when the turn started, in US dollars. */
6 startUsd: number | null
7 /** A turn is running (its clear line is not written yet). */
8 isOpen: boolean
9 /** The calls counted this turn, by tool_use_id: each is counted once, however it was seen. */
10 seen: string[]
11}
12
13/** One finished turn, as its clear line shows it. */
14export type ClearRecord = {
15 durationMs: number
16 actions: number
17 /** What the turn cost, in US dollars; null without a cost reading. */
18 usd: number | null
19 /** The session's cost when the turn started, so a late reading can still settle `usd`. */
20 startUsd: number | null
21 /** Against the project's median turn; null until three turns are on record. */
22 deltaMs: number | null
23 /** The median it was compared with. */
24 medianMs: number | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'game-clear-time': {
30 turn: TurnNow
31 /** This session's finished turns, newest last (at most 100). */
32 records: ClearRecord[]
33 }
34 }
35}
36