파일을 바꾼 턴이 끝나면 오른쪽 패널에 바꾼 파일·한 일·배울 점을 짧게 정리합니다

Claude Code mod. 파일을 바꾼 턴이 끝나면 오른쪽 패널에 바꾼 파일 · 한 일 · 배울 점을 짧게 정리합니다.
1: 바꾼 파일 2: 한 일 3: 배울 점
▸ 홈 버튼 고쳐줘
• 홈 버튼 색을 design-guide 토큰으로 교체
터미널의 Claude Code 프롬프트에서:
/plugin marketplace add timinguniq/claude-work-summary
/plugin install work-summary@claude-work-summary
또는 셸에서:
claude plugin marketplace add timinguniq/claude-work-summary
claude plugin install work-summary@claude-work-summary
user 범위로 설치되어 이후 새 세션에서 켜집니다. 이미 열려 있는 세션에서는 /reload-plugins로 불러옵니다.
업데이트: claude plugin update work-summary 후 /reload-plugins.
/work-summary로 직접 열면 폭과 상관없이 뜹니다.| 키 | 동작 |
|---|---|
ctrl+x 다음 tab | 패널로 키보드를 옮깁니다 |
1 2 3 | 바꾼 파일 · 한 일 · 배울 점 탭으로 바로 바꿉니다 (패널에 키보드가 있을 때) |
Tab | 탭(바꾼 파일 · 한 일 · 배울 점) 사이를 옮깁니다 |
Enter | 고른 탭을 엽니다 |
↑ ↓ | 패널을 스크롤합니다 |
Esc | 프롬프트로 돌아갑니다 |
ctrl+x 다음 x | 패널을 닫습니다 (/work-summary로 다시 엽니다) |
claude plugin validate .
claude plugin test .hooks/register.tsx 180 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { SummaryTab, WorkSummary } from '../types'
5
6const PANE = 'work-summary'
7const TITLE = '작업 요약'
8const OPEN = { id: PANE, title: TITLE, columns: 40 }
9const KEEP = 20
10const SHOWN_FILES = 8
11const ANSWER_CHARS = 3000
12const TABS: { key: SummaryTab; label: string; hotkey: string }[] = [
13 { key: 'files', label: '바꾼 파일', hotkey: '1' },
14 { key: 'did', label: '한 일', hotkey: '2' },
15 { key: 'learn', label: '배울 점', hotkey: '3' },
16]
17
18const entries = atom({ plugin: 'work-summary', key: 'entries' } as const, [])
19const tab = atom({ plugin: 'work-summary', key: 'tab' } as const, 'files')
20
21let turn = { id: '', prompt: '' }
22let changed = new Set<string>()
23
24export const register: Register = on => {
25 on('session.start', async ($, e, next) => {
26 await $.command.register({
27 name: 'work-summary',
28 description: '바꾼 파일·한 일·배울 점 패널을 엽니다',
29 })
30 void $.ui.open(OPEN)
31
32 return next(e)
33 })
34
35 on('command.run', { command: 'work-summary' }, async $ => {
36 await $.ui.open(OPEN)
37
38 return { text: '작업 요약 패널을 열었습니다.' }
39 })
40
41 on('turn.start', ($, e, next) => {
42 turn = { id: e.turnId, prompt: e.text }
43 changed = new Set()
44
45 return next(e)
46 })
47
48 on('tool.call', async ($, e, next) => {
49 const path = editedPath(e)
50 if (path === undefined) return next(e)
51 const ran = await next(e)
52 if (ran.deny === undefined && ran.isError !== true) changed.add(path)
53
54 return ran
55 })
56
57 on('turn.complete', async ($, e, next) => {
58 const done = await next(e)
59 if (e.agentId !== undefined || e.turnId !== turn.id || changed.size === 0) return done
60
61 const root = await $.session.root()
62 const entry: WorkSummary = {
63 turnId: e.turnId,
64 prompt: firstLine(turn.prompt),
65 files: [...changed].map(path => relative(root, path)),
66 status: 'pending',
67 did: [],
68 learn: [],
69 error: '',
70 }
71 changed = new Set()
72 await update($, entries, list => [entry, ...list].slice(0, KEEP))
73 $.clock.after(10, () => void summarize($, entry, e.answer))
74
75 return done
76 })
77
78 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79 const { Box, Button, Text } = $.ui.resolve(e)
80 const list = await read($, entries)
81 if (list.length === 0) {
82 return <Text dimColor>파일을 바꾼 턴이 끝나면 여기에 요약이 쌓입니다.</Text>
83 }
84 const shown = await read($, tab)
85
86 return (
87 <Box flexDirection="column">
88 <Box columnGap={2} marginBottom={1}>
89 {TABS.map(one => (
90 <Button
91 key={one.key}
92 label={one.label}
93 hotkey={one.hotkey}
94 plain
95 dimColor={one.key !== shown}
96 onPress={() => void update($, tab, () => one.key)}
97 />
98 ))}
99 </Box>
100 {list.map(entry => (
101 <Box flexDirection="column" marginBottom={1}>
102 <Text bold wrap="truncate-end">▸ {entry.prompt}</Text>
103 {shown === 'files' && entry.files.slice(0, SHOWN_FILES).map(file => (
104 <Text wrap="truncate-start"> {file}</Text>
105 ))}
106 {shown === 'files' && entry.files.length > SHOWN_FILES && (
107 <Text dimColor> 외 {entry.files.length - SHOWN_FILES}개</Text>
108 )}
109 {shown !== 'files' && entry.status === 'pending' && <Text dimColor> 요약 중…</Text>}
110 {shown !== 'files' && entry.status === 'failed' && (
111 <Text dimColor> 요약 실패: {entry.error}</Text>
112 )}
113 {shown !== 'files' && entry.status === 'done' && entry[shown].length === 0 && (
114 <Text dimColor> 없음</Text>
115 )}
116 {shown !== 'files' && entry[shown].map(line => <Text> • {line}</Text>)}
117 </Box>
118 ))}
119 </Box>
120 )
121 })
122}
123
124function editedPath(e: ToolCallInput): string | undefined {
125 if (e.tool === 'Edit' || e.tool === 'Write') return e.file_path
126 if (e.tool === 'NotebookEdit') return e.notebook_path
127
128 return undefined
129}
130
131async function summarize($: EngineInterface, entry: WorkSummary, answer: string) {
132 const reply = await $.model.fork({ prompt: summaryPrompt(entry.files, answer) })
133 const patch = reply.isAnswered
134 ? parseSummary(reply.text)
135 : { status: 'failed' as const, error: reply.reason }
136 await update($, entries, list =>
137 list.map(one => (one.turnId === entry.turnId ? { ...one, ...patch } : one)),
138 )
139}
140
141function summaryPrompt(files: string[], answer: string): string {
142 return [
143 '[작업 요약 패널용 요청 — 대화에는 남지 않는다]',
144 '방금 끝난 턴에서 너는 아래 파일을 바꿨고, 마지막에 아래처럼 답했다.',
145 `바꾼 파일: ${files.join(', ')}`,
146 `마지막 답변: ${answer.slice(0, ANSWER_CHARS)}`,
147 '',
148 '도구를 쓰지 말고, 아래 형식 그대로 한국어로만 답해. 형식 밖의 말은 붙이지 마.',
149 '한 일:',
150 '- 무엇을 왜 바꿨는지 (1~3줄, 줄마다 70자 이내)',
151 '배울 점:',
152 '- 이번 작업에서 드러난, 다음에도 쓸 만한 구체적인 교훈 (1~2줄, 줄마다 70자 이내, 일반론 금지)',
153 ].join('\n')
154}
155
156export function parseSummary(text: string): Pick<WorkSummary, 'status' | 'did' | 'learn'> {
157 const did: string[] = []
158 const learn: string[] = []
159 let into: string[] | undefined
160 for (const raw of text.split('\n')) {
161 const line = raw.trim()
162 if (line.startsWith('한 일')) into = did
163 else if (line.startsWith('배울 점')) into = learn
164 else if (line.startsWith('-') && into !== undefined) into.push(line.replace(/^-\s*/, ''))
165 }
166 if (did.length + learn.length === 0) did.push(text.trim())
167
168 return { status: 'done', did, learn }
169}
170
171function firstLine(prompt: string): string {
172 const line = prompt.trim().split('\n')[0] ?? ''
173
174 return line === '' ? '(이어서 한 작업)' : line
175}
176
177function relative(root: string, path: string): string {
178 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
179}
180types/index.d.ts 18 lines1export type WorkSummary = {
2 turnId: string
3 prompt: string
4 files: string[]
5 status: 'pending' | 'done' | 'failed'
6 did: string[]
7 learn: string[]
8 error: string
9}
10
11export type SummaryTab = 'files' | 'did' | 'learn'
12
13declare module 'claude-code' {
14 interface PluginState {
15 'work-summary': { entries: WorkSummary[]; tab: SummaryTab }
16 }
17}
18