할 일 진행 상황을 입력창 위에 보여주고, 대화가 압축돼도 작업을 이어가도록 메모를 남깁니다 (/work, /handoff)

Claude Code mod 모음입니다. mod는 함수 훅으로 만든 플러그인입니다.
| mod | 하는 일 | 명령 |
|---|---|---|
token-usage | 입력창 위 한 줄에 컨텍스트(대화 기억) 사용량, 5시간·7일 사용 한도, 메인 모델과 effort를 보여줍니다. 실행 중인 서브에이전트는 ①②③ 번호와 색으로 구분해 한 줄씩 나오고, 각 줄에는 모델, 걸린 시간(⏱), ctx, 지금 보는 파일이 붙습니다. 이름을 누르면 그 서브에이전트의 작업 내용이 창으로 열립니다(👁 표시, Esc로 닫기). | /token-usage 펼치기/접기, /token-usage theme |
work-alerts | 오래 걸린 작업이 끝났을 때, 테스트·검사가 실패했을 때, 한도가 찼을 때 알림 창과 소리로 알려줍니다. | /task-alert 소리 켜기/끄기, /alerts 현재 설정, /alerts test |
compact-handoff | 할 일이 있을 때 진행 상황(▶ 2/5 …)을 보여줍니다. 대화가 압축돼도 작업을 이어가도록 메모도 남깁니다. | /work, /handoff |
/config세 mod의 설정은 모두 Claude Code의 /config 메뉴에 항목으로 나옵니다. 메뉴에서 바꾸면 그 자리에서 바로 적용됩니다.
/task-alert, /work band off 같은 명령도 같은 항목을 바꿉니다. 그래서 메뉴와 명령의 설정이 어긋나지 않습니다.
Claude Code 안에서 하나씩 설치합니다.
/plugin install token-usage --marketplace parkyountaek/claude-mods
/plugin install work-alerts --marketplace parkyountaek/claude-mods
/plugin install compact-handoff --marketplace parkyountaek/claude-mods
폴더를 직접 받아 쓰고 고치고 싶다면 다음처럼 합니다.
git clone https://github.com/parkyountaek/claude-mods ~/.claude/mods
그다음 ~/.claude/settings.json의 env에 경로를 등록합니다.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/token-usage:~/.claude/mods/work-alerts:~/.claude/mods/compact-handoff" } }
sudo sh ~/.claude/mods/install-root.sh
theme.json 하나를 함께 씁니다.claude plugin validate <mod>
claude plugin test <mod>hooks/register.tsx 437 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Handoff, Summary } from '../types'
5
6import { applyThemeArgs, palette, parseTheme, PRESETS, themePath } from './theme'
7import type { ThemeFile } from './theme'
8
9export type { Handoff }
10
11const TAG = '[compact-handoff]'
12const KEEP_PROMPTS = 4
13const KEEP_FILES = 15
14
15// What the summarizer is asked to keep, on top of the engine's own prompt.
16export const INSTRUCTIONS = [
17 'Keep for the continuation, precisely:',
18 "1. The user's original request and every later change to it, in their own words.",
19 '2. The task list with each item done / in progress / not started.',
20 '3. Decisions already made and constraints the user set (do not re-ask them).',
21 '4. Files created or edited and why; commands that failed and the cause.',
22 '5. The exact next step that was about to happen.',
23].join('\n')
24
25const clip = (s: string, n: number) => {
26 const t = s.trim().replace(/\s+\n/g, '\n')
27 return t.length > n ? `${t.slice(0, n)}…` : t
28}
29
30// The engine's compaction summary opens the continued conversation as a user message.
31const SUMMARY_START = /^(This session is being continued|<compact|Summary of the conversation)/i
32
33const isTypedPrompt = (m: SessionMessage) =>
34 m.role === 'user' &&
35 m.text.trim() !== '' &&
36 !(m.toolResults?.length) &&
37 !m.text.startsWith(TAG) &&
38 !SUMMARY_START.test(m.text.trimStart()) &&
39 !m.text.trimStart().startsWith('<')
40
41const STATUS_OF: Record<string, string> = { '[x]': 'completed', '[>]': 'in_progress', '[ ]': 'pending' }
42
43// Reads a note `render` wrote back into its parts, so a second compaction keeps the first one's.
44export const parseNote = (note: string): Handoff => {
45 const hand: Handoff = { prompts: [], tasks: [], files: [], failures: [], lastStep: '' }
46 let section = ''
47 const last: string[] = []
48 for (const line of note.split('\n')) {
49 if (line.startsWith('## ')) {
50 section = line.slice(3)
51 continue
52 }
53 if (section.startsWith("User's requests")) {
54 const m = /^\d+\. (.*)$/.exec(line)
55 if (m?.[1]) hand.prompts.push(m[1])
56 } else if (section === 'Task list') {
57 const m = /^(\[[x> ]\]) (.*)$/.exec(line)
58 if (m?.[1] && m[2]) hand.tasks.push({ subject: m[2], status: STATUS_OF[m[1]] ?? 'pending' })
59 } else if (section === 'Files touched' && line.startsWith('- ')) hand.files.push(line.slice(2))
60 else if (section === 'Recent failed commands' && line.startsWith('- ')) hand.failures.push(line.slice(2))
61 else if (section.startsWith('Last assistant message') && !line.startsWith('Next: ')) last.push(line)
62 }
63 hand.lastStep = last.join('\n').trim()
64 return hand
65}
66
67// What the earlier note held comes first; what happened since is laid over it.
68export const mergeHandoff = (before: Handoff, now: Handoff): Handoff => {
69 const prompts = [...before.prompts, ...now.prompts.filter(p => !before.prompts.includes(p))]
70 const bySubject = new Map(before.tasks.map(t => [t.subject, t]))
71 for (const t of now.tasks) bySubject.set(t.subject, t)
72 return {
73 prompts: prompts.length > KEEP_PROMPTS ? [prompts[0] ?? '', ...prompts.slice(-(KEEP_PROMPTS - 1))] : prompts,
74 tasks: [...bySubject.values()],
75 files: [...new Set([...before.files, ...now.files])].slice(-KEEP_FILES),
76 failures: (now.failures.length ? now.failures : before.failures).slice(-3),
77 lastStep: now.lastStep || before.lastStep,
78 }
79}
80
81// Everything here is read from the transcript itself, so it survives reloads.
82export const collect = (messages: readonly SessionMessage[]): Handoff => {
83 const earlier = [...messages].reverse().find(m => m.role === 'user' && m.text.startsWith(TAG))
84 const fresh = collectFresh(messages)
85 return earlier ? mergeHandoff(parseNote(earlier.text), fresh) : fresh
86}
87
88const collectFresh = (messages: readonly SessionMessage[]): Handoff => {
89 const typed = messages.filter(isTypedPrompt).map(m => clip(m.text, 300))
90 const prompts = typed.length > KEEP_PROMPTS ? [typed[0] ?? '', ...typed.slice(-(KEEP_PROMPTS - 1))] : typed
91
92 const tasks = new Map<string, { subject: string; status: string }>()
93 let todos: { subject: string; status: string }[] | null = null
94 const files = new Set<string>()
95 const failures: string[] = []
96
97 for (const m of messages) {
98 for (const use of m.toolUses) {
99 const input = use.input
100 if (use.tool === 'TaskCreate') {
101 const task = (use.result as { task?: { id?: string } } | undefined)?.task
102 tasks.set(task?.id ?? use.tool_use_id, { subject: String(input.subject ?? ''), status: 'pending' })
103 } else if (use.tool === 'TaskUpdate') {
104 const found = tasks.get(String(input.taskId))
105 if (found) {
106 if (typeof input.status === 'string') found.status = input.status
107 if (typeof input.subject === 'string') found.subject = input.subject
108 }
109 } else if (use.tool === 'TodoWrite' && Array.isArray(input.todos)) {
110 todos = (input.todos as { content?: string; status?: string }[]).map(t => ({
111 subject: String(t.content ?? ''),
112 status: String(t.status ?? 'pending'),
113 }))
114 } else if (['Edit', 'Write', 'NotebookEdit'].includes(use.tool)) {
115 const path = input.file_path ?? input.notebook_path
116 if (typeof path === 'string') files.add(path)
117 } else if (use.tool === 'Bash' && use.isError) {
118 failures.push(`${clip(String(input.command ?? ''), 160)} → ${clip(use.text ?? '', 200)}`)
119 }
120 }
121 }
122
123 const last = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')
124 return {
125 prompts,
126 tasks: (todos ?? [...tasks.values()]).filter(t => t.status !== 'deleted'),
127 files: [...files].slice(-KEEP_FILES),
128 failures: failures.slice(-3),
129 lastStep: last ? clip(last.text, 400) : '',
130 }
131}
132
133const MARK: Record<string, string> = { completed: '[x]', in_progress: '[>]', pending: '[ ]' }
134
135export const render = (hand: Handoff, cwd: string): string => {
136 const out = [
137 `${TAG} Handoff note written just before compaction, from the transcript itself.`,
138 'Use it with the summary above to continue the same task without asking the user to repeat anything.',
139 ]
140 if (cwd) out.push(`Working directory: ${cwd}`)
141 if (hand.prompts.length) {
142 out.push('', "## User's requests (verbatim, oldest first)")
143 hand.prompts.forEach((p, i) => out.push(`${i + 1}. ${p}`))
144 }
145 if (hand.tasks.length) {
146 out.push('', '## Task list')
147 hand.tasks.forEach(t => out.push(`${MARK[t.status] ?? '[ ]'} ${t.subject}`))
148 }
149 if (hand.files.length) out.push('', '## Files touched', ...hand.files.map(f => `- ${f}`))
150 if (hand.failures.length) out.push('', '## Recent failed commands', ...hand.failures.map(f => `- ${f}`))
151 if (hand.lastStep) out.push('', '## Last assistant message before compaction', hand.lastStep)
152 out.push('', 'Next: pick up from the first unfinished task (or the last message) and continue.')
153 return out.join('\n')
154}
155
156const TRACKED = ['TaskCreate', 'TaskUpdate', 'TodoWrite', 'Edit', 'Write', 'NotebookEdit', 'Agent']
157
158const EMPTY: Summary = {
159 handoff: { prompts: [], tasks: [], files: [], failures: [], lastStep: '' },
160 agents: [],
161 updatedAt: 0,
162 isBandHidden: false,
163 cwd: '',
164 isExpanded: false,
165 theme: { preset: 'default', overrides: {} },
166}
167
168const summary = atom({ plugin: 'compact-handoff', key: 'summary' } as const, EMPTY)
169
170export const progress = (tasks: Handoff['tasks']) => {
171 const live = tasks.filter(t => t.status !== 'deleted')
172 return { done: live.filter(t => t.status === 'completed').length, total: live.length, current: live.find(t => t.status === 'in_progress') }
173}
174
175export const bar = (done: number, total: number, width: number): string => {
176 const n = total ? Math.round((done / total) * width) : 0
177 return '█'.repeat(n) + '·'.repeat(width - n)
178}
179
180export const shortPath = (path: string, cwd: string): string =>
181 cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path.replace(/^\/Users\/[^/]+/, '~')
182
183const oneLine = (s: string, n: number) => {
184 const t = s.replace(/\s+/g, ' ').trim()
185 return t.length > n ? `${t.slice(0, n - 1)}…` : t
186}
187
188async function loadTheme($: EngineInterface): Promise<ThemeFile> {
189 return parseTheme(await $.fs.read(themePath(await $.env.get('HOME'), await $.env.get('CLAUDE_MODS_THEME_FILE'))).catch(() => ''))
190}
191
192const THEME_HELP: Record<string, string> = {
193 default: '터미널 테마 색을 그대로 사용',
194 colorblind: '색각 이상에도 구분되는 색 (Okabe-Ito)',
195 ocean: '청록·파랑 계열, 어두운 배경에 맞춤',
196 vivid: '선명한 원색 계열',
197}
198
199// `theme` alone opens a keyboard picker; otherwise the words go to applyThemeArgs.
200async function themeCommand($: EngineInterface, words: string[]): Promise<string> {
201 if (words[0] === 'theme' && !words[1]) {
202 const names = Object.keys(PRESETS)
203 const picked = await $.ui
204 .ask('어떤 색상 테마를 쓸까요?', { header: '테마', options: names.map(n => `${n} — ${THEME_HELP[n] ?? ''}`) })
205 .catch(() => '')
206 const name = names.find(n => picked.startsWith(n))
207 if (!name) return '테마를 바꾸지 않았습니다.'
208 words = ['theme', name]
209 }
210 const r = applyThemeArgs(await loadTheme($), words)
211 if (r.next) {
212 const theme = r.next
213 await $.fs.write(themePath(await $.env.get('HOME'), await $.env.get('CLAUDE_MODS_THEME_FILE')), `${JSON.stringify(theme, null, 2)}\n`).catch(() => undefined)
214 await update($, summary, s => ({ ...s, theme }))
215 }
216 return r.text
217}
218
219async function refreshSummary($: EngineInterface) {
220 const [messages, roster, cwd, theme] = await Promise.all([
221 $.session.messages(),
222 $.agent.list().catch(() => []),
223 $.session.cwd().catch(() => ''),
224 loadTheme($),
225 ])
226 const now = await $.clock.now()
227 await update($, summary, s => ({
228 ...s,
229 handoff: collect(messages),
230 agents: roster
231 .filter(a => ['pending', 'running', 'waiting'].includes(a.status))
232 .map(a => ({ type: a.type, description: a.description, status: a.status })),
233 updatedAt: now,
234 cwd,
235 theme,
236 }))
237}
238
239async function setOption($: EngineInterface, field: string, value: boolean): Promise<boolean> {
240 const r = await $.config.set({ key: `compact-handoff.${field}`, value }).catch(() => ({ deny: 'error' }))
241 return !('deny' in r && r.deny !== undefined)
242}
243
244// Settings come from /config (userConfig); a change there reloads the module.
245export const register: Register = (on, options) => {
246 const isBandOn = options.band !== false
247 let isHandoffOn = options.handoff !== false
248
249 on('session.start', async ($, e, next) => {
250 await $.command.register({
251 name: 'handoff',
252 description: '대화 압축 때 남긴 이어가기 메모 보기 (preview: 지금 압축하면 남길 메모, on|off: 켜기/끄기)',
253 argumentHint: '[preview|on|off]',
254 })
255 await $.command.register({
256 name: 'work',
257 description: '입력창 위 할 일 요약 펼치기/접기 (할 일, 요청, 파일, 실패한 검사)',
258 argumentHint: '[open|close|band on|off|theme [name]|color <slot> <#hex>]',
259 })
260 const started = await next(e)
261 await refreshSummary($)
262 return started
263 })
264
265 on('command.run', { command: 'handoff' }, async ($, e) => {
266 const arg = e.args.trim()
267 if (arg === 'on' || arg === 'off') {
268 if (!(await setOption($, 'handoff', arg === 'on'))) isHandoffOn = arg === 'on'
269 return { text: arg === 'off' ? '이어가기 메모 끔: 압축은 기본 방식대로 진행됩니다.' : '이어가기 메모 켬.' }
270 }
271 if (arg === 'preview') {
272 const cwd = await $.session.cwd().catch(() => '')
273 return { text: render(collect(await $.session.messages()), cwd) }
274 }
275 const saved = (await $.store.get('last')) as { at: string; note: string } | undefined
276 return { text: saved ? `마지막 이어가기 메모 (${saved.at}):\n\n${saved.note}` : '아직 압축된 적이 없습니다. /handoff preview 로 미리 볼 수 있습니다.' }
277 })
278
279 on('command.run', { command: 'work' }, async ($, e) => {
280 const arg = e.args.trim()
281 const words = arg.split(/\s+/)
282 if (words[0] === 'theme' || words[0] === 'color') return { text: await themeCommand($, words) }
283 if (arg === 'band off' || arg === 'band on') {
284 if (!(await setOption($, 'band', arg === 'band on'))) await update($, summary, s => ({ ...s, isBandHidden: arg === 'band off' }))
285 return { text: arg === 'band off' ? '할 일 진행 줄을 숨겼습니다.' : '할 일 진행 줄을 보여줍니다.' }
286 }
287 if (arg === 'close' || arg === 'open' || arg === '') {
288 await refreshSummary($)
289 const isExpanded = arg === 'open' || (arg === '' && !(await read($, summary)).isExpanded)
290 await update($, summary, s => ({ ...s, isExpanded, isBandHidden: false }))
291 return { text: isExpanded ? '할 일 요약을 펼쳤습니다. /work 를 한 번 더 입력하면 접힙니다.' : '할 일 요약을 접었습니다.' }
292 }
293 return { text: '사용법: /work (펼치기/접기), /work band on|off, /work theme' }
294 })
295
296 on('turn.complete', async ($, e, next) => {
297 const done = await next(e)
298 if (e.agentId === undefined) await refreshSummary($)
299 return done
300 })
301
302 on('tool.call', async ($, e, next) => {
303 const ran = await next(e)
304 if (TRACKED.includes(e.tool)) await refreshSummary($)
305 return ran
306 }).catch(($, e, next) => next(e))
307
308 // ------------------------------------------------------------ band
309
310 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
311 const s = await read($, summary)
312 const hand = s.handoff
313 const below = await next(e)
314 const { done, total, current } = progress(hand.tasks)
315 const latest = hand.prompts.at(-1)
316 // Collapsed, the band shows task progress only; the last request is in /work.
317 if (e.props.hasSurvey || s.isBandHidden || (!isBandOn && !s.isExpanded) || (!total && (!latest || !s.isExpanded))) return below
318
319 const { Box, Text } = $.ui.resolve(e)
320 const P = palette(s.theme as ThemeFile)
321 const cols = e.viewport?.columns ?? 80
322 const inner = Math.max(20, cols - 6)
323 const isAllDone = total > 0 && done === total
324 const next1 = hand.tasks.find(t => t.status === 'pending')
325
326 const summaryRow = total ? (
327 <Text>
328 <Text color={current ? P.warn : isAllDone ? P.ok : P.cache}>{current ? '▶' : isAllDone ? '✓' : '○'} </Text>
329 <Text dimColor>{done}/{total} </Text>
330 <Text>{oneLine(current?.subject ?? next1?.subject ?? '모든 할 일 완료', Math.max(20, cols - 20))}</Text>
331 {hand.failures.length > 0 && <Text color={P.danger}> ✗{hand.failures.length}</Text>}
332 </Text>
333 ) : (
334 <Text>
335 <Text color={P.accent}>💬 </Text>
336 <Text>{oneLine(latest ?? '', Math.max(20, cols - 10))}</Text>
337 </Text>
338 )
339
340 if (!s.isExpanded) {
341 return (
342 <Box flexDirection="column">
343 {summaryRow}
344 {below}
345 </Box>
346 )
347 }
348
349 const shown = hand.tasks.length > 8 ? [...hand.tasks.filter(t => t.status !== 'completed'), ...hand.tasks.filter(t => t.status === 'completed').slice(-2)].slice(0, 8) : hand.tasks
350 const files = hand.files.slice(-3).reverse()
351
352 return (
353 <Box flexDirection="column">
354 {summaryRow}
355 <Box borderStyle="round" borderColor={P.cache} paddingX={1} flexDirection="column">
356 {total > 0 && (
357 <Text bold color={P.accent}>
358 ✅ 할 일 {done}/{total} <Text color={P.ok}>{bar(done, total, 16)}</Text>
359 </Text>
360 )}
361 {shown.map(t =>
362 t.status === 'completed' ? (
363 <Text dimColor>
364 <Text color={P.ok}>✔</Text> {oneLine(t.subject, inner - 2)}
365 </Text>
366 ) : t.status === 'in_progress' ? (
367 <Text bold>
368 <Text color={P.warn}>▶</Text> {oneLine(t.subject, inner - 2)}
369 </Text>
370 ) : (
371 <Text>
372 <Text color={P.cache}>○</Text> {oneLine(t.subject, inner - 2)}
373 </Text>
374 ),
375 )}
376 {hand.tasks.length > shown.length && <Text dimColor> +{hand.tasks.length - shown.length}개 더</Text>}
377 {latest && (
378 <Text>
379 <Text color={P.accent}>💬 </Text>
380 <Text dimColor>최근 요청 </Text>
381 {oneLine(latest, inner - 12)}
382 </Text>
383 )}
384 {files.length > 0 && (
385 <Text>
386 <Text color={P.input}>📝 </Text>
387 <Text dimColor>파일 {hand.files.length} </Text>
388 {oneLine(files.map(f => shortPath(f, s.cwd)).join(' · '), inner - 12)}
389 </Text>
390 )}
391 {hand.failures.map(f => (
392 <Text>
393 <Text color={P.danger}>✗ </Text>
394 {oneLine(f, inner - 3)}
395 </Text>
396 ))}
397 {s.agents.map(a => (
398 <Text>
399 <Text color={P.warn}>◐ </Text>
400 <Text bold>{a.type}</Text>
401 <Text dimColor> · {oneLine(a.description, Math.max(10, inner - 20))}</Text>
402 </Text>
403 ))}
404 </Box>
405 {below}
406 </Box>
407 )
408 })
409
410 on('session.compact', async ($, e, next) => {
411 // Main conversation only; a precompute is kept for later and would go stale.
412 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
413 if (!isHandoffOn) return next(e)
414
415 const handoff = collect(e.messages)
416 const cwd = await $.session.cwd().catch(() => '')
417 const note = render(handoff, cwd)
418 const instructions = e.instructions ? `${e.instructions}\n\n${INSTRUCTIONS}` : INSTRUCTIONS
419
420 const done = await next({ ...e, instructions })
421 if (done.skip !== undefined) return done
422
423 // Pin the note right after the summary, ahead of the kept messages.
424 const pinned: SessionMessage = { role: 'user', text: note, toolUses: [] }
425 const [summary, ...kept] = done.messages
426 const messages = summary ? [summary, pinned, ...kept] : [pinned]
427
428 const at = await $.clock.now().catch(() => 0)
429 await $.store.set('last', { at: at ? new Date(at).toISOString() : '', note })
430 $.ui.toast(
431 `📌 compact 뒤 이어가기 메모 고정: 요청 ${handoff.prompts.length} · 할 일 ${handoff.tasks.length} · 파일 ${handoff.files.length} (/handoff)`,
432 { timeoutMs: 6000 },
433 )
434 return { ...done, messages }
435 }).catch(($, e, next) => next(e))
436}
437hooks/theme.ts 100 lines1// One color theme shared by the token-usage and compact-handoff mods.
2// Kept in ~/.claude/mods/theme.json so either mod's command changes both.
3
4export type Slot = 'ok' | 'warn' | 'danger' | 'input' | 'output' | 'cache' | 'accent' | 'chipText'
5export type Palette = Record<Slot, string>
6export type ThemeFile = { preset: string; overrides: Partial<Palette> }
7
8export const SLOTS: readonly Slot[] = ['ok', 'warn', 'danger', 'input', 'output', 'cache', 'accent', 'chipText']
9
10export const PRESETS: Record<string, Palette> = {
11 // The terminal theme's own colors: follows /theme light or dark.
12 default: {
13 ok: 'success',
14 warn: 'warning',
15 danger: 'error',
16 input: 'suggestion',
17 output: 'claude',
18 cache: 'inactive',
19 accent: 'permission',
20 chipText: 'inverseText',
21 },
22 // Okabe-Ito: distinguishable with common color-vision deficiencies.
23 colorblind: {
24 ok: '#009E73',
25 warn: '#E69F00',
26 danger: '#D55E00',
27 input: '#56B4E9',
28 output: '#CC79A7',
29 cache: '#999999',
30 accent: '#0072B2',
31 chipText: '#000000',
32 },
33 ocean: {
34 ok: '#2DD4BF',
35 warn: '#FBBF24',
36 danger: '#F87171',
37 input: '#60A5FA',
38 output: '#C084FC',
39 cache: '#64748B',
40 accent: '#38BDF8',
41 chipText: '#0B1220',
42 },
43 vivid: {
44 ok: '#22C55E',
45 warn: '#F59E0B',
46 danger: '#EF4444',
47 input: '#3B82F6',
48 output: '#F97316',
49 cache: '#6B7280',
50 accent: '#A855F7',
51 chipText: '#000000',
52 },
53}
54
55export const palette = (t: ThemeFile): Palette => {
56 const base = PRESETS[t.preset] ?? PRESETS.default
57 return { ...(base as Palette), ...t.overrides }
58}
59
60const isColor = (v: string) => /^#[0-9a-fA-F]{6}$/.test(v) || /^[a-zA-Z]+$/.test(v)
61
62export const DEFAULT_THEME: ThemeFile = { preset: 'default', overrides: {} }
63
64// CLAUDE_MODS_THEME_FILE lets another account (root) share this user's theme file.
65export const themePath = (home: string | undefined, file?: string) => file || `${home ?? '~'}/.claude/mods/theme.json`
66
67export const parseTheme = (text: string): ThemeFile => {
68 try {
69 const raw = JSON.parse(text) as Partial<ThemeFile>
70 return { preset: raw.preset ?? 'default', overrides: raw.overrides ?? {} }
71 } catch {
72 return DEFAULT_THEME
73 }
74}
75
76const show = (t: ThemeFile) =>
77 `theme: ${t.preset} (${Object.keys(PRESETS).join(' | ')})\n` +
78 SLOTS.map(s => ` ${s.padEnd(8)} ${palette(t)[s]}${t.overrides[s] ? ' (override)' : ''}`).join('\n')
79
80// `theme`, `theme <preset>`, `color <slot> <#hex|theme-key>`, `color reset`.
81// Returns the text to show and, when it changed, the theme to save.
82export const applyThemeArgs = (now: ThemeFile, args: readonly string[]): { text: string; next?: ThemeFile } => {
83 const [verb, a, b] = args
84 if (verb === 'theme') {
85 if (!a) return { text: show(now) }
86 if (!PRESETS[a]) return { text: `"${a}" 테마는 없습니다. 고를 수 있는 테마: ${Object.keys(PRESETS).join(', ')}` }
87 const next = { preset: a, overrides: {} }
88 return { text: show(next), next }
89 }
90 if (a === 'reset') {
91 const next = { ...now, overrides: {} }
92 return { text: show(next), next }
93 }
94 if (!a || !b || !SLOTS.includes(a as Slot) || !isColor(b)) {
95 return { text: `사용법: color <${SLOTS.join('|')}> <#RRGGBB 색 코드>, 되돌리기: color reset` }
96 }
97 const next = { ...now, overrides: { ...now.overrides, [a]: b } }
98 return { text: show(next), next }
99}
100types/index.d.ts 30 lines1export type Task = { subject: string; status: string }
2
3export type Handoff = {
4 prompts: string[]
5 tasks: Task[]
6 files: string[]
7 failures: string[]
8 lastStep: string
9}
10
11export type AgentLine = { type: string; description: string; status: string }
12
13export type Summary = {
14 handoff: Handoff
15 agents: AgentLine[]
16 updatedAt: number
17 isBandHidden: boolean
18 cwd: string
19 isExpanded: boolean
20 theme: { preset: string; overrides: Record<string, string> }
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'compact-handoff': {
26 summary: Summary
27 }
28 }
29}
30