When Claude asks a question it asked before in this project, the option you chose last time is marked ★ 지난번 선택

When Claude asks a question it asked before in this project, the option you chose last time is marked in the dialog:
☐ 형식
문서 형식을 어떻게 할까요?
❯ 1. 마크다운 표
표로 정리
2. 목록
★ 지난번 선택 · 글머리표로 정리
Only the dialog's drawing changes: the options, their order and the answer stay as asked. Questions match through spacing, case and a trailing question mark. Answers are kept per project (the last 200 questions); a multi-select answer marks each choice.
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-answer-memory@bitgame-mods
intensity (casual) | off · casual · hardcore (says when: ★ 지난번 선택 (3시간 전)) |
Claude가 같은 질문을 다시 하면, 지난번에 고른 선택지에 ★ 지난번 선택을 표시합니다. 프로젝트별로 최근 200개 질문을 기억합니다.
hooks/register.tsx 107 lines1import type { Register } from 'claude-code'
2
3import { intensityOf } from './palette'
4
5// GAME MODE · ANSWER MEMORY
6//
7// When Claude asks a question it asked before in this project, the option you chose
8// last time is marked in the dialog:
9// 어떤 방식으로 고칠까요?
10// ❯ 테스트 기대값을 바꾼다 ★ 지난번 선택 · 기대값을 초 단위로 맞춘다
11// 구현을 바꾼다 …
12// Only the dialog's drawing changes: the options, their order and the answer stay as asked.
13// Answers are kept per project (the last 200 questions).
14
15const KEEP = 200
16
17type Kept = Record<string, { answer: string; at: number }>
18
19export const register: Register = (on, options) => {
20 const intensity = intensityOf(options.intensity)
21 let key = 'answers'
22 let kept: Kept = {}
23
24 on('session.start', async ($, e, next) => {
25 key = `answers:${await $.session.root()}`
26 kept = keptOf(await $.store.get(key))
27 return next(e)
28 })
29
30 // The answers a dialog brought back, by question.
31 on('tool.call', async ($, e, next) => {
32 const ran = await next(e)
33 if (intensity === 'off' || e.tool !== 'AskUserQuestion' || ran.deny !== undefined || ran.isError === true) return ran
34 const answers = answersOf(ran.result)
35 if (answers.length === 0) return ran
36 const at = await $.clock.now()
37 for (const [question, answer] of answers) kept[keyOf(question)] = { answer, at }
38 const newest = Object.entries(kept)
39 .sort((a, b) => b[1].at - a[1].at)
40 .slice(0, KEEP)
41 kept = Object.fromEntries(newest)
42 await $.store.set(key, kept)
43 return ran
44 }).catch(($, e, next) => next(e))
45
46 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
47 if (intensity === 'off') return next(e)
48 const now = await $.clock.now()
49 let isMarked = false
50 const questions = e.props.questions.map(q => {
51 const marked = markLast(q, kept, intensity === 'hardcore' ? now : null)
52 if (marked !== q) isMarked = true
53 return marked
54 })
55 return isMarked ? next({ ...e, props: { ...e.props, questions } }) : next(e)
56 })
57}
58
59/** The question as it is matched: case, spacing and a trailing question mark ignored. */
60export function keyOf(question: string): string {
61 return question.trim().toLowerCase().replace(/\s+/g, ' ').replace(/[??]+$/, '')
62}
63
64/** The question with the option chosen last time marked in its description; the question itself when none was. */
65export function markLast(question: unknown, kept: Kept, now: number | null): unknown {
66 if (question === null || typeof question !== 'object') return question
67 const q = question as { question?: unknown; options?: unknown }
68 if (typeof q.question !== 'string' || !Array.isArray(q.options)) return question
69 const last = kept[keyOf(q.question)]
70 if (last === undefined) return question
71 const chosen = new Set(last.answer.split(',').map(a => a.trim()))
72 let isMarked = false
73 const optionsOut = q.options.map(o => {
74 if (o === null || typeof o !== 'object') return o
75 const opt = o as { label?: unknown; description?: unknown }
76 if (typeof opt.label !== 'string' || !chosen.has(opt.label)) return o
77 isMarked = true
78 const when = now === null ? '' : ` (${ago(now - last.at)})`
79 const description = typeof opt.description === 'string' && opt.description !== '' ? ` · ${opt.description}` : ''
80 return { ...opt, description: `★ 지난번 선택${when}${description}` }
81 })
82 return isMarked ? { ...q, options: optionsOut } : question
83}
84
85function answersOf(result: unknown): [string, string][] {
86 const answers = result !== null && typeof result === 'object' ? (result as { answers?: unknown }).answers : undefined
87 if (answers === null || typeof answers !== 'object') return []
88 return Object.entries(answers as Record<string, unknown>).filter((x): x is [string, string] => typeof x[1] === 'string' && x[1] !== '')
89}
90
91function keptOf(value: unknown): Kept {
92 if (value === null || typeof value !== 'object') return {}
93 const out: Kept = {}
94 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
95 const o = v as { answer?: unknown; at?: unknown } | null
96 if (o !== null && typeof o === 'object' && typeof o.answer === 'string' && typeof o.at === 'number') out[k] = { answer: o.answer, at: o.at }
97 }
98 return out
99}
100
101function ago(ms: number): string {
102 const minutes = Math.floor(ms / 60_000)
103 if (minutes < 60) return `${Math.max(1, minutes)}분 전`
104 const hours = Math.floor(minutes / 60)
105 return hours < 24 ? `${hours}시간 전` : `${Math.floor(hours / 24)}일 전`
106}
107hooks/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