Explains what Claude changed in each turn, and why: summary, code walkthrough, flow and sequence diagrams, IntelliJ-style diff, quiz. Also explains past…

A Claude Code mod that explains what Claude changed in your code, and why, so you understand the change before you move on.
When Claude edits code, the result stays but the reasoning disappears into the conversation. Changes you never really understood pile up as knowledge debt. change-explainer records every change Claude makes, per turn, and explains it in a pane inside the terminal when you ask.
sed -i, cat >, git commit), including in other repositories and worktrees. Subagent changes go to the turn that started them./explain opens a pane for the last changed turn. Sections are generated only when you open them:| Key | Section | | :- | :- | | 1 | Summary: what changed, why, how it works, what to check | | 2 | Background: the problem before the change and its cause | | 3 | Code walkthrough: real code lines (read from the snapshots, never written by the model), what each does, why, alternatives | | 4 | Flow chart (bordered cards) | | 5 | Sequence diagram | | 6 | Before / after behavior | | 7 | Impact: callers, new functions, tests to check | | 8 | Terms: new concepts, mark the ones you already know | | 9 | Quiz: answer all correctly to mark the turn as understood |
d): side by side on wide panes, unified on narrow ones, changed words highlighted, only the code area scrolls.h/l). Esc returns the keys to the pane. Terminals do not pass horizontal wheel or trackpad movement to mods, so dragging stands in for it./explain-list): Claude Code keeps every conversation of a project. The list shows every past turn that changed something, newest first, one line each, titled by its commit message (or the files it changed). Open one to explain it. Sessions are read in the background and indexed, so the list opens at once the next time. Edit/Write changes come from the conversation record (exact), Bash changes from the commits you made in that turn's time window. Nothing to configure: no repository paths, no base branches.claude --version.claude -p and the VS Code chat panel, /explain answers with text instead of a pane.git is optional. Without it, Edit/Write changes are still recorded and explained; only changes made through Bash are missed. You do not need to manage or configure anything in git: the mod only reads it.sh, grep and awk (macOS and Linux). On Windows the past-session list stays empty; live recording works.From a shell:
claude plugin marketplace add SigLee2247/change-explainer
claude plugin install change-explainer@change-explainer
Or inside Claude Code: /plugin marketplace add SigLee2247/change-explainer, then /plugin install change-explainer@change-explainer.
To try it from a clone without installing:
git clone https://github.com/SigLee2247/change-explainer change-explainer-repo
claude --plugin-dir ./change-explainer-repo/change-explainer
After installing, run /plugin and check that change-explainer is listed as active. The install may say that 3 options are not set yet; every option has a default, so it works without setting them.
/explain.1–9 to open sections, d for the diff, t for the work list, Esc to close./explain always explains the current session. /explain-list opens the work list: this session's turns and your past work. Inside the pane, t opens the list too.
Live recording starts when the mod is loaded. Work from before that is in the list under past work, as long as Claude Code still keeps the conversation (30 days by default).
Set them with /plugin configure change-explainer@change-explainer, or pass --config KEY=VALUE to claude plugin install.
| Option | Default | Meaning | | :- | :- | :- | | language | ko | Language the explanations are written in: ko or en. The pane's own labels are Korean for now. | | theme | dark | light for light terminal themes (diff colors and text). | | model | session | Model for explaining past sessions (haiku, sonnet, opus or a model id). session uses the session's model. Turns of the current session always fork the current conversation. |
~/.claude/explanations/: per turn, the before/after snapshots of changed files, the generated sections, your questions and answers, and quiz results. Delete the folder to remove it all.~/.claude/projects/, runs git in the repositories you work in, and calls the model with your plan or API key. It sends nothing anywhere else. Review what it does with claude plugin validate ./change-explainer.r. Current-session explanations fork the conversation, so most of the prompt comes from the prompt cache.usage.json. As a reference, a summary of a past turn with 11 changed files used about 13k input and 4k output tokens on the session model; set model to sonnet or haiku to make past-session explanations cheaper.language setting.claude --plugin-dir ./change-explainer # load with hot reload
cd change-explainer && claude plugin test # 52 tests, no session or network needed
claude plugin validate ./change-explainer --strict
claude plugin validate . # the marketplace file
CHANGE_EXPLAINER_DEV=1 adds /explain-check <section> [session-id-prefix/turn], which generates one section with the real model and prints the JSON and the tokens used.SPEC.md (Korean) records the design and the decisions behind it.samples/explain-preview is a fixed-data mod for checking the pane's design; samples/try-change-explainer.sh builds a demo repository and starts Claude Code with the mod.MIT. See LICENSE.
Claude Code가 턴마다 바꾼 코드를 기록하고, 무엇을 왜 어떻게 바꿨는지 터미널 창에서 해설하는 mod입니다. 이해하지 못한 채 쌓이는 코드(지식 부채)를 줄이는 것이 목적입니다.
설치
claude plugin marketplace add SigLee2247/change-explainer
claude plugin install change-explainer@change-explainer
설치 없이 써 보려면 저장소를 받은 뒤 claude --plugin-dir ./change-explainer/change-explainer로 시작하세요. Claude Code 2.1.287 이상이 필요합니다.
사용
/explain: 그 턴의 해설 창이 열립니다.1~9: 섹션 펼치기 (펼칠 때 생성)d: difft: 작업 목록Esc: 닫기/explain은 항상 지금 세션을 해설합니다./explain-list: 이 세션의 턴과 지난 작업 목록 (해설 창에서는 t). 지난 대화에서 실제로 무언가 바꾼 턴이 최근 것부터 한 줄씩(커밋 메시지나 바꾼 파일 이름으로) 나오고, 고르면 해설 창이 열립니다. 저장소 경로나 기준 브랜치 같은 설정은 필요 없습니다.Wait, what?: 누를 때마다 다른 방식으로 다시 설명하고, 그 부분만 따로 질문할 수 있습니다.설정 (/plugin configure change-explainer@change-explainer)
language: 해설 언어 (ko/en)theme: 터미널 테마 (dark/light)model: 지난 세션 해설 모델 (비용을 줄이려면 sonnet이나 haiku)자세한 내용은 위 영어 표를 참고하세요.
데이터와 비용
~/.claude/explanations/에만 저장됩니다.hooks/register.js 1216 lines1// change-explainer: Claude가 턴마다 바꾼 파일을 기록하고, 원할 때 해설한다.
2//
3// 1단계: 변경 수집과 저장 / 2단계: diff 창 / 3단계: 해설 생성과 해설 창 / 4단계: 작업 목록(이 세션과 지난 세션의 턴)
4// - 턴에서 파일을 처음 건드리기 직전의 내용(변경 전)과 턴이 끝난 뒤의 내용(변경 후)을 남긴다
5// - 서브에이전트의 수정은 그 서브에이전트를 실행한 메인 턴에 붙인다
6// - Bash로 바꾼 파일도 잡는다: Bash가 건드리는 git 저장소의 상태를 턴에서 처음 건드릴 때 남기고,
7// 턴이 끝나면 지금 상태와 비교한다 (커밋, 수정, 새 파일, 삭제. 턴 전부터 있던 수정은 뺀다)
8// - 도구 호출은 관찰만 한다. 막거나 바꾸지 않고 next(e)의 결과를 그대로 돌려준다
9//
10// 저장 위치: ~/.claude/explanations/<저장소>-<해시>/<세션 id>/
11// session.json 세션 정보와 턴 목록
12// turns/<순번>-<turnId>/turn.json 턴 기록 (요청문, 답변, 파일별 줄 수)
13// turns/<순번>-<turnId>/before/… 변경 전 스냅숏, after/… 변경 후 스냅숏
14// turns/<순번>-<turnId>/views/… 생성한 섹션(JSON), Wait what 대화와 질문
15// 저장소 단위: ~/.claude/explanations/<저장소>-<해시>/learning.json Known 용어, 막혔던 섹션
16// ~/.claude/explanations/<저장소>-<해시>/usage.json 해설에 쓴 토큰 누적
17// 지난 세션의 턴도 같은 위치·형식으로 남는다 (대화 기록과 git에서 복원)
18
19import { commandDirs, parseNameStatus, parseStatus, projectDirName } from './git.js'
20import { isWorkPath, localDay, madeByTurn, parseLog, promptText, turnsFromTranscript } from './history.js'
21import { buildRows } from './diff.js'
22import { SECTIONS, setLanguage, answerPrompt, easyPrompt, joinBlocks, parseAt, retryPrompt, sectionPrompt, turnContext, validate } from './generate.js'
23import { applyTheme } from './views/common.js'
24import { diffCodeRows, diffModel, diffView, hunkList } from './views/diff.js'
25import { explainView } from './views/explain.js'
26import { listView } from './views/list.js'
27import {
28 EDIT_TOOLS, MAX_FILE_BYTES, absolutePath, editedPath, looksBinary, newFileEntry, newTurn, recordEdit,
29 repoFolder, storedPath, turnRecord, turnSummary,
30} from './turns.js'
31
32// 메모리에 남겨 둘 끝난 턴 수: 늦게 끝난 백그라운드 서브에이전트의 수정을 붙이기 위해
33const KEEP_FINISHED = 20
34
35// 세션 정보 (처음 필요할 때 채운다): { repoBase, base, root, cwd, sessionId, session }
36let ctx = null
37// 마지막으로 제출한 프롬프트 (turn.start의 text가 비어 있을 때 쓴다)
38let lastPrompt = ''
39// 진행 중인 메인 턴
40let current = null
41// 끝난 턴: turnId → { turn, seq, answer, isAborted, durationMs, endedAt }
42let finished = {}
43// 서브에이전트 → 그 서브에이전트를 실행한 메인 턴, 그리고 서브에이전트 종류
44let agentTurn = {}
45let agentType = {}
46// 마지막으로 저장한 변경 턴 기록
47let lastRecord = null
48
49// ── 창 상태 ──
50const PANE = 'change-explainer'
51const WANT_COLUMNS = 120
52const STEP_ROWS = 5
53const STEP_COLS = 8
54// 창에 띄운 턴: { turn: 턴 기록, files: [{ ...파일 기록, rows, hunks }] }
55let view = null
56// diff: 보고 있는 변경 블록, 코드 영역의 위쪽 줄과 가로 밀림, 휠 스크롤 한계
57let pos = 0
58let diffTop = 0
59let diffLeft = 0
60let diffMaxTop = 0
61// 화면: 'explain'(해설), 'diff', 'list'(작업 목록)
62let mode = 'explain'
63// 작업 목록 상태 (views/list.js 참고)
64let list = null
65// 플러그인 설정 (userConfig): language, theme, model
66let options = {}
67// 띄운 턴의 해설 상태: { dir, sections, open, current, threads, qa, quiz, seqLeft, understood }
68let ex = null
69// 저장소 단위 학습 기록: 이미 아는 용어, 막혔던 섹션 (다음 해설 프롬프트에 넣는다)
70let learning = { known: [], stuck: [] }
71const SECTION_TITLE = Object.fromEntries(SECTIONS.map((s) => [s.id, s.title]))
72
73const pad = (n) => String(n).padStart(4, '0')
74
75// 아직 기록이 없을 때: 무엇을 하면 되는지까지 알려 준다
76const NOTHING_YET = [
77 '이 세션에서 아직 기록된 변경이 없습니다.',
78 'Claude에게 파일을 고치게 하고, 그 턴이 끝난 뒤 /explain 을 입력하세요.',
79 '(이 mod를 불러오기 전에 한 변경은 기록되지 않습니다)',
80 '지난 작업은 /explain-list 로 볼 수 있습니다.',
81].join('\n')
82
83// 이 mod는 관찰만 하므로, 훅이 실패해도 원래 동작은 그대로 진행한다.
84// .catch 안의 next는 다시 불러도 안전하다: 이미 실행됐으면 그 결과를, 아니면 한 번만 실행한다
85const passThrough = ($, e, next) => next(e)
86
87// ── 저장소·세션 정보 ─────────────────────────────────────────
88
89async function gitTop($, cwd) {
90 try {
91 const r = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
92 if (r.exitCode === 0 && r.stdout.trim()) return r.stdout.trim()
93 } catch {}
94 return null
95}
96
97async function ensureCtx($) {
98 if (ctx) return ctx
99 const home = await $.env.get('HOME')
100 const cwd = await $.session.cwd()
101 const sessionId = await $.session.id()
102 // 워크트리에서는 워크트리의 최상위가 기준이다 (session.repo()는 메인 작업 트리를 준다)
103 const root = (await gitTop($, cwd)) || cwd
104 const repoBase = home + '/.claude/explanations/' + (await repoFolder(root))
105 const base = repoBase + '/' + sessionId
106 let session = null
107 try {
108 session = JSON.parse(await $.fs.read(base + '/session.json'))
109 } catch {}
110 if (!session) session = { sessionId, repoRoot: root, startedAt: await $.clock.now(), transcriptPath: null, turns: [] }
111 let learned = null
112 try {
113 learned = JSON.parse(await $.fs.read(repoBase + '/learning.json'))
114 } catch {}
115 learning = { known: (learned && learned.known) || [], stuck: (learned && learned.stuck) || [] }
116 ctx = { repoBase, base, root, cwd, sessionId, session }
117 return ctx
118}
119
120// ── 파일 읽기 ────────────────────────────────────────────────
121
122// 파일 내용: 없으면 null, 다룰 수 없으면 { skipped: 'too-large' | 'binary' }
123async function readSnapshot($, abs) {
124 let st
125 try {
126 st = await $.fs.stat(abs)
127 } catch {
128 return null
129 }
130 if (st.size > MAX_FILE_BYTES) return { skipped: 'too-large' }
131 try {
132 const text = await $.fs.read(abs)
133 return looksBinary(text) ? { skipped: 'binary' } : text
134 } catch {
135 return { skipped: 'unreadable' }
136 }
137}
138
139// ── 턴 저장 ─────────────────────────────────────────────────
140
141async function saveTurn($, turnId) {
142 const f = finished[turnId]
143 if (!f) return
144 const c = await ensureCtx($)
145 const entries = Object.values(f.turn.files).filter((x) => x.edits > 0)
146 const afters = {}
147 for (const x of entries) {
148 if (x.skipped) continue
149 const now = await readSnapshot($, x.abs)
150 afters[x.abs] = typeof now === 'string' ? now : null
151 }
152 const record = turnRecord(f.turn, afters, {
153 sessionId: c.sessionId,
154 answer: f.answer,
155 isAborted: f.isAborted,
156 durationMs: f.durationMs,
157 endedAt: f.endedAt,
158 })
159 if (!record.hasChanges) return
160 if (!f.seq) f.seq = c.session.turns.reduce((m, t) => Math.max(m, t.seq), 0) + 1
161 record.seq = f.seq
162 const dir = c.base + '/turns/' + pad(f.seq) + '-' + turnId
163
164 for (const x of entries) {
165 if (x.skipped) continue
166 if (typeof x.before === 'string') await $.fs.write(dir + '/before/' + x.rel, x.before)
167 if (typeof afters[x.abs] === 'string') await $.fs.write(dir + '/after/' + x.rel, afters[x.abs])
168 }
169 await $.fs.write(dir + '/turn.json', JSON.stringify(record, null, 2))
170
171 const prev = c.session.turns.find((t) => t.turnId === turnId)
172 const summary = { ...turnSummary(record), understood: !!(prev && prev.understood) }
173 const others = c.session.turns.filter((t) => t.turnId !== turnId)
174 c.session = { ...c.session, turns: [...others, summary].sort((a, b) => a.seq - b.seq) }
175 await $.fs.write(c.base + '/session.json', JSON.stringify(c.session, null, 2))
176 lastRecord = record
177}
178
179// ── Bash로 바꾼 파일 잡기 ────────────────────────────────────
180
181// 한 턴에서 처음 기록할 때 미리 읽어 둘 파일 수 (턴 전부터 수정돼 있거나 추적 안 되는 파일)
182const MAX_SNAPSHOT_FILES = 300
183// 한 저장소에서 한 턴에 기록할 변경 파일 수
184const MAX_BASH_FILES = 80
185
186// 디렉토리 → git 최상위 ('' 이면 저장소가 아님). 세션 동안 기억한다
187let topCache = {}
188
189async function topOf($, dir) {
190 if (dir in topCache) return topCache[dir]
191 let top = ''
192 try {
193 if (await $.fs.exists(dir)) {
194 const r = await git($, dir, ['rev-parse', '--show-toplevel'])
195 top = r.ok ? r.out.trim() : ''
196 }
197 } catch {}
198 topCache[dir] = top
199 return top
200}
201
202// 파일을 저장할 때 쓸 상대 경로: 세션 저장소 안이면 그대로, 다른 저장소면 "저장소이름/경로", 아니면 _outside
203function relFor(c, abs) {
204 const base = c.root.replace(/\/$/, '') + '/'
205 if (abs.startsWith(base)) return abs.slice(base.length)
206 const top = Object.values(topCache).filter((t) => t && abs.startsWith(t + '/')).sort((a, b) => b.length - a.length)[0]
207 if (top) return top.split('/').pop() + '/' + abs.slice(top.length + 1)
208 return storedPath(abs, c.root)
209}
210
211// 저장소의 지금 상태: HEAD, 그리고 이미 수정돼 있거나 추적 안 되는 파일의 내용 (턴 전부터 있던 변경을 빼려고)
212async function snapshotRepo($, top) {
213 const head = await git($, top, ['rev-parse', 'HEAD'])
214 if (!head.ok) return null
215 const st = parseStatus((await git($, top, ['status', '--porcelain'])).out)
216 const read = async (paths) => {
217 const out = {}
218 for (const p of paths.slice(0, MAX_SNAPSHOT_FILES)) out[p] = await readSnapshot($, top + '/' + p)
219 return out
220 }
221 return { top, point: head.out.trim(), dirtyBefore: await read(st.dirty), untrackedBefore: await read(st.untracked), agents: [] }
222}
223
224// Bash 명령이 건드릴 저장소들을 찾아, 이 턴에서 처음이면 상태를 남긴다. 건드린 저장소 목록을 돌려준다
225async function trackBashRepos($, turn, command, agent) {
226 const c = await ensureCtx($)
227 const home = await $.env.get('HOME')
228 const tops = []
229 for (const dir of [c.cwd, ...commandDirs(command, c.cwd, home)]) {
230 const top = await topOf($, dir)
231 if (!top || tops.includes(top)) continue
232 tops.push(top)
233 if (!(top in turn.repos)) turn.repos[top] = await snapshotRepo($, top)
234 const repo = turn.repos[top]
235 if (repo && agent && !repo.agents.some((a) => a.agentId === agent.agentId)) repo.agents.push(agent)
236 }
237 return tops
238}
239
240const asText = (v) => (typeof v === 'string' ? v : null)
241const skippedOf = (v) => (v && typeof v === 'object' ? v.skipped : null)
242
243// 턴 시작 상태와 지금을 비교해 바뀐 파일을 턴 기록에 넣는다. Edit/Write로 이미 잡은 파일은 그대로 둔다
244async function finalizeRepo($, turn, top) {
245 const repo = turn.repos[top]
246 if (!repo) return
247 const c = await ensureCtx($)
248 const changed = new Map()
249 for (const ch of parseNameStatus((await git($, top, ['diff', '--name-status', '-M', repo.point])).out)) changed.set(ch.path, ch.from)
250 for (const p of parseStatus((await git($, top, ['status', '--porcelain'])).out).untracked) if (!changed.has(p)) changed.set(p, null)
251 for (const p of [...Object.keys(repo.dirtyBefore), ...Object.keys(repo.untrackedBefore)]) if (!changed.has(p)) changed.set(p, null)
252
253 let count = 0
254 for (const [path, from] of changed) {
255 if (count >= MAX_BASH_FILES) break
256 const abs = top + '/' + path
257 const existing = turn.files[abs]
258 if (existing && !existing.fromBash) continue
259 let before
260 if (existing) before = existing.skipped ? { skipped: existing.skipped } : existing.before
261 else if (path in repo.dirtyBefore) before = repo.dirtyBefore[path]
262 else if (path in repo.untrackedBefore) before = repo.untrackedBefore[path]
263 else {
264 const show = await git($, top, ['show', repo.point + ':' + (from || path)])
265 before = show.ok ? (looksBinary(show.out) ? { skipped: 'binary' } : show.out) : null
266 }
267 const after = await readSnapshot($, abs)
268 const skipped = skippedOf(before) || skippedOf(after)
269 // 이번 턴에 바뀌지 않은 파일 (턴 전부터 수정돼 있던 것 포함)
270 if (!skipped && asText(before) === asText(after)) {
271 if (existing) delete turn.files[abs]
272 continue
273 }
274 let entry = newFileEntry(abs, relFor(c, abs), skipped ? undefined : asText(before), skipped)
275 entry.fromBash = true
276 for (const a of repo.agents.length ? repo.agents : [null]) entry = recordEdit(entry, 'Bash', a)
277 turn.files[abs] = { ...entry, fromBash: true }
278 count++
279 }
280}
281
282// ── 창에 띄울 턴 불러오기 ────────────────────────────────────
283
284const turnDir = (c, rec) => c.base + '/turns/' + pad(rec.seq) + '-' + rec.turnId
285
286async function readOrNull($, path) {
287 try { return await $.fs.read(path) } catch { return null }
288}
289
290// 마지막 변경 턴: 메모리에 없으면(mod를 다시 불러온 뒤 등) 세션 기록에서 찾는다
291async function latestRecord($) {
292 if (lastRecord) return lastRecord
293 const c = await ensureCtx($)
294 const last = c.session.turns[c.session.turns.length - 1]
295 if (!last) return null
296 const text = await readOrNull($, turnDir(c, last) + '/turn.json')
297 return text ? JSON.parse(text) : null
298}
299
300// 턴 기록과 스냅숏으로 화면 데이터를 만든다
301async function loadView($, rec, at) {
302 const c = await ensureCtx($)
303 const dir = at || turnDir(c, rec)
304 const files = []
305 for (const f of rec.files.filter((x) => x.changed)) {
306 if (f.skipped) { files.push({ ...f, rows: [], hunks: 0 }); continue }
307 const before = f.isNew ? null : await readOrNull($, dir + '/before/' + f.path)
308 const after = await readOrNull($, dir + '/after/' + f.path)
309 files.push({ ...f, beforeText: before, afterText: after, ...buildRows(before, after || '') })
310 }
311 return { turn: rec, files, dir }
312}
313
314// ── 해설 생성 ────────────────────────────────────────────────
315
316// 보기 순서를 섞는다. 모델은 정답을 첫 번째에 두는 버릇이 있어서 순서는 코드가 정한다
317function shuffled(n) {
318 const a = Array.from({ length: n }, (_, i) => i)
319 for (let i = n - 1; i > 0; i--) {
320 const j = Math.floor(Math.random() * (i + 1))
321 ;[a[i], a[j]] = [a[j], a[i]]
322 }
323 return a
324}
325
326const newQuiz = (data) => ({ order: data.questions.map((q) => shuffled(q.options.length)), picked: data.questions.map(() => null) })
327
328// 모델 호출: 이 세션의 턴이면 현재 대화를 fork(맥락을 알고, 캐시로 싸다), 아니면 요청문·답변·diff만으로 complete
329// prompt: 블록 목록 (generate.js). fork에는 글로 합쳐 보내고, complete에는 캐시 표시를 살려 보낸다
330async function callModel($, prompt) {
331 const c = await ensureCtx($)
332 if (!evalModel && (!view || view.turn.sessionId === c.sessionId)) {
333 const r = await $.model.fork({ prompt: joinBlocks(prompt) })
334 if (r.isAnswered) return { text: r.text, usage: r.usage }
335 if (r.reason !== 'nothing-to-fork') return { error: r.reason + (r.status ? ' ' + r.status : ''), usage: r.usage }
336 }
337 // 설정 model이 있으면 그것, 없으면 세션 모델
338 let model = evalModel || (options.model && options.model !== 'session' ? options.model : 'sonnet')
339 if (!evalModel && (!options.model || options.model === 'session')) {
340 try { model = (await $.session.model()) || model } catch {}
341 }
342 const r = await $.model.complete({
343 model,
344 system: '너는 코드 변경을 사용자에게 해설하는 도우미다. 요청한 JSON 객체 하나만 답한다.',
345 prompt,
346 maxTokens: 4000,
347 timeoutMs: 120000,
348 })
349 return r.isAnswered ? { text: r.text, usage: r.usage } : { error: r.reason + (r.status ? ' ' + r.status : ''), usage: r.usage }
350}
351
352// ── 토큰 사용량 ──
353// 모델이 보고한 사용량을 해설 단위(턴)와 저장소 단위로 쌓는다
354
355// /explain-check model=… 로 모델을 정해 평가할 때만 (fork하지 않고 그 모델로)
356let evalModel = null
357
358const emptyUsage = () => ({ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
359
360function addUsage(total, u) {
361 if (!u) return total
362 return {
363 calls: total.calls + 1,
364 input: total.input + (u.input_tokens || 0),
365 output: total.output + (u.output_tokens || 0),
366 cacheRead: total.cacheRead + (u.cache_read_input_tokens || 0),
367 cacheWrite: total.cacheWrite + (u.cache_creation_input_tokens || 0),
368 }
369}
370
371// 저장소 누적 사용량 (작업 목록에 보인다)
372let repoUsage = null
373
374async function recordUsage($, target, u) {
375 if (!u) return
376 // 개발용 평가(/explain-check model=…)는 사용자의 사용량에 넣지 않는다
377 if (evalModel) {
378 if (target) target.usage = addUsage(target.usage || emptyUsage(), u)
379 return
380 }
381 const c = await ensureCtx($)
382 if (!repoUsage) {
383 const saved = await readOrNull($, c.repoBase + '/usage.json')
384 try { repoUsage = saved ? { ...emptyUsage(), ...JSON.parse(saved) } : emptyUsage() } catch { repoUsage = emptyUsage() }
385 }
386 repoUsage = addUsage(repoUsage, u)
387 await saveJson($, c.repoBase + '/usage.json', repoUsage)
388 if (target) {
389 target.usage = addUsage(target.usage || emptyUsage(), u)
390 await saveJson($, target.dir + '/views/usage.json', target.usage)
391 }
392}
393
394// 프롬프트를 보내고 kind 형식으로 검증한다. 형식이 틀리면 한 번 더 요청한다. 쓴 토큰은 target에 기록
395async function ask($, prompt, kind, check, target) {
396 let reply = await callModel($, prompt)
397 await recordUsage($, target, reply.usage)
398 if (reply.error) return { ok: false, error: '모델 응답 없음(' + reply.error + ')' }
399 let v = validate(kind, reply.text, check)
400 if (v.ok) return v
401 reply = await callModel($, [...prompt, { text: '\n\n' + retryPrompt(v.error) }])
402 await recordUsage($, target, reply.usage)
403 if (reply.error) return { ok: false, error: '모델 응답 없음(' + reply.error + ')' }
404 v = validate(kind, reply.text, check)
405 return v.ok ? v : { ok: false, error: '형식이 맞지 않음(' + v.error + ')' }
406}
407
408async function saveJson($, path, value) {
409 try { await $.fs.write(path, JSON.stringify(value, null, 2)) } catch (err) { $.ui.log('저장 실패: ' + path + ': ' + err) }
410}
411
412const saveThreads = ($, target) => saveJson($, target.dir + '/views/threads.json', { threads: target.threads, qa: target.qa })
413const saveLearning = async ($) => saveJson($, (await ensureCtx($)).repoBase + '/learning.json', learning)
414
415// 섹션 하나를 만든다. 다른 턴으로 바꾼 뒤에 답이 와도 원래 턴에만 반영한다
416async function generateSection($, id) {
417 const target = ex
418 const v = view
419 if (!target || target.sections[id].status === 'loading') return
420 target.sections[id] = { status: 'loading', data: null, error: null }
421 $.ui.invalidate('ui.render')
422 const context = turnContext(v.turn, v.files, learning)
423 const r = await ask($, sectionPrompt(id, context), id, context, target)
424 if (r.ok) {
425 target.sections[id] = { status: 'done', data: r.value, error: null }
426 if (id === 'quiz') target.quiz = newQuiz(r.value)
427 await saveJson($, target.dir + '/views/' + id + '.json', r.value)
428 } else {
429 target.sections[id] = { status: 'error', data: null, error: r.error }
430 }
431 $.ui.invalidate('ui.render')
432}
433
434// 대화 상자의 i번째 항목을 채운다
435function fillThread(target, id, i, patch) {
436 target.threads = { ...target.threads, [id]: target.threads[id].map((x, j) => (j === i ? { ...x, ...patch } : x)) }
437}
438
439// Wait, what?: 앞의 설명들과 다른 방식으로 더 쉽게. 누른 섹션은 막혔던 곳으로 기록한다
440async function explainEasier($, id) {
441 const target = ex
442 const thread = target.threads[id] || []
443 const i = thread.length
444 target.threads = { ...target.threads, [id]: [...thread, { type: 'easy', text: null }] }
445 $.ui.invalidate('ui.render')
446 const title = SECTION_TITLE[id] + ' (' + view.turn.title + ')'
447 if (!learning.stuck.includes(title)) {
448 learning = { ...learning, stuck: [...learning.stuck, title].slice(-20) }
449 await saveLearning($)
450 }
451 const r = await ask($, easyPrompt(id, turnContext(view.turn, view.files, learning), target.sections[id].data, thread), 'text', null, target)
452 fillThread(target, id, i, { text: r.ok ? r.value.text : '(설명을 만들지 못했습니다: ' + r.error + ')' })
453 await saveThreads($, target)
454 $.ui.invalidate('ui.render')
455}
456
457// 섹션 안의 질문
458async function askInSection($, id, q) {
459 const target = ex
460 const thread = target.threads[id] || []
461 const i = thread.length
462 target.threads = { ...target.threads, [id]: [...thread, { type: 'q', q, a: null }] }
463 $.ui.invalidate('ui.render')
464 const section = { id, json: target.sections[id].data, thread: thread.filter((x) => (x.type === 'easy' ? x.text : x.a)) }
465 const r = await ask($, answerPrompt(q, turnContext(view.turn, view.files, learning), section), 'text', null, target)
466 fillThread(target, id, i, { a: r.ok ? r.value.text : '(답을 만들지 못했습니다: ' + r.error + ')' })
467 await saveThreads($, target)
468 $.ui.invalidate('ui.render')
469}
470
471// 해설 창 맨 아래의 질문
472async function askTurn($, q) {
473 const target = ex
474 const i = target.qa.length
475 target.qa = [...target.qa, { q, a: null }]
476 $.ui.invalidate('ui.render')
477 const r = await ask($, answerPrompt(q, turnContext(view.turn, view.files, learning), null), 'text', null, target)
478 target.qa = target.qa.map((x, j) => (j === i ? { ...x, a: r.ok ? r.value.text : '(답을 만들지 못했습니다: ' + r.error + ')' } : x))
479 await saveThreads($, target)
480 $.ui.invalidate('ui.render')
481}
482
483// 퀴즈를 다 맞히면 이해함으로 남긴다: 작업 폴더의 state.json, 이 세션의 턴이면 세션 목록에도
484async function markUnderstood($) {
485 const c = await ensureCtx($)
486 const id = view.turn.turnId
487 await saveJson($, view.dir + '/state.json', { understood: true })
488 if (c.session.turns.some((t) => t.turnId === id)) {
489 c.session = { ...c.session, turns: c.session.turns.map((t) => (t.turnId === id ? { ...t, understood: true } : t)) }
490 await saveJson($, c.base + '/session.json', c.session)
491 }
492}
493
494async function readUnderstood($, dir) {
495 const text = await readOrNull($, dir + '/state.json')
496 try { return !!(text && JSON.parse(text).understood) } catch { return false }
497}
498
499// 턴 하나를 해설 창에 띄운다: 저장해 둔 섹션·대화를 불러오고, 요약과 펼쳐 둔 섹션을 만든다
500async function openTurn($, rec, at) {
501 const c = await ensureCtx($)
502 view = await loadView($, rec, at)
503 const sections = Object.fromEntries(SECTIONS.map((s) => [s.id, { status: 'none', data: null, error: null }]))
504 let quiz = { order: [], picked: [] }
505 for (const s of SECTIONS) {
506 const saved = await readOrNull($, view.dir + '/views/' + s.id + '.json')
507 if (!saved) continue
508 try {
509 sections[s.id] = { status: 'done', data: JSON.parse(saved), error: null }
510 if (s.id === 'quiz') quiz = newQuiz(sections[s.id].data)
511 } catch {}
512 }
513 let threads = {}
514 let qa = []
515 const savedThreads = await readOrNull($, view.dir + '/views/threads.json')
516 if (savedThreads) {
517 try {
518 const t = JSON.parse(savedThreads)
519 threads = t.threads || {}
520 qa = t.qa || []
521 } catch {}
522 }
523 let open = { summary: true }
524 try {
525 const saved = await $.store.get('open')
526 if (saved && typeof saved === 'object') open = { ...saved, summary: saved.summary !== false }
527 } catch {}
528 const summary = c.session.turns.find((t) => t.turnId === rec.turnId)
529 const understood = !!(summary && summary.understood) || (await readUnderstood($, view.dir))
530 let usage = emptyUsage()
531 const savedUsage = await readOrNull($, view.dir + '/views/usage.json')
532 try { if (savedUsage) usage = { ...usage, ...JSON.parse(savedUsage) } } catch {}
533 ex = { dir: view.dir, sections, open, current: 'summary', threads, qa, quiz, seqLeft: 0, pans: {}, understood, usage }
534 mode = 'explain'
535 pos = 0
536 diffTop = 0
537 diffLeft = 0
538 // 요약은 창을 열 때, 펼쳐 둔 섹션도 함께 만든다 (모두 처음 한 번만)
539 for (const s of SECTIONS) {
540 if (open[s.id] && sections[s.id].status === 'none') generateSection($, s.id).catch((err) => $.ui.log('생성 실패: ' + err))
541 }
542}
543
544// ── 작업 목록: 이 세션과 지난 세션의 턴 ──────────────────────
545
546async function git($, cwd, args) {
547 try {
548 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 20000 })
549 return { ok: r.exitCode === 0, out: r.stdout }
550 } catch {
551 return { ok: false, out: '' }
552 }
553}
554
555// sh에 인자로 넘겨 실행한다 (경로를 스크립트 글에 끼워 넣지 않는다). sh가 없으면 빈 글
556async function sh($, script, args) {
557 try {
558 const r = await $.process.run(['sh', '-c', script, 'sh', ...args], { timeoutMs: 60000 })
559 return r.stdout || ''
560 } catch {
561 return ''
562 }
563}
564
565// 이 프로젝트의 대화 기록 폴더 (세션을 연 디렉토리 기준)
566async function transcriptsDir($) {
567 const c = await ensureCtx($)
568 return (await $.env.get('HOME')) + '/.claude/projects/' + projectDirName(c.cwd)
569}
570
571
572const MAX_SESSIONS = 30
573const SCAN_SESSIONS = 120
574
575// 세션 파일마다: 바꾼 흔적이 있으면 @@CHANGED (Claude 메모리·임시 폴더가 아닌 곳의 Edit/Write, 또는 git commit. 서브에이전트 기록 포함),
576// 그리고 앞쪽 사용자 줄 몇 개 (제목용)
577const SCAN_SCRIPT = [
578 'for f in "$@"; do',
579 ' echo "@@F $f"',
580 ' sub="${f%.jsonl}/subagents"',
581 ' if { cat "$f"; [ -d "$sub" ] && cat "$sub"/*.jsonl; } 2>/dev/null | grep -E \'"name":"(Edit|Write|MultiEdit)"|"command":"[^"]*git [^"]*commit\' | grep -v -E \'"file_path":"[^"]*(/[.]claude/|/tmp/claude-)\' | grep -q .; then echo "@@CHANGED"; fi',
582 ' grep -m 12 -E \'"type":"user"\' "$f" | grep -v \'"tool_result"\'',
583 'done',
584].join('\n')
585
586// 지난 세션 목록: 최근에 쓴 것부터, 무언가 바꾼 세션만. 제목은 첫 요청, 시각은 마지막으로 쓴 때
587async function listSessions($) {
588 const c = await ensureCtx($)
589 const dir = await transcriptsDir($)
590 let entries = []
591 try { entries = await $.fs.list(dir) } catch {}
592 const files = entries
593 .filter((x) => x.kind === 'file' && x.name.endsWith('.jsonl') && x.name.slice(0, -6) !== c.sessionId)
594 .sort((a, b) => b.mtimeMs - a.mtimeMs)
595 .slice(0, SCAN_SESSIONS)
596 const scan = {}
597 let cur = null
598 for (const line of (await sh($, SCAN_SCRIPT, files.map((f) => dir + '/' + f.name))).split('\n')) {
599 if (line.startsWith('@@F ')) scan[(cur = line.slice(4).split('/').pop())] = { changed: false, title: '' }
600 else if (!cur) continue
601 else if (line === '@@CHANGED') scan[cur].changed = true
602 else if (!scan[cur].title) {
603 let o = null
604 try { o = JSON.parse(line) } catch {}
605 const text = promptText(o)
606 if (text) scan[cur].title = text.split('\n')[0].slice(0, 60)
607 }
608 }
609 const out = []
610 for (const f of files) {
611 const s = scan[f.name]
612 if (!s || !s.changed || !s.title) continue
613 out.push({ id: f.name.slice(0, -6), title: s.title, mtimeMs: f.mtimeMs, size: f.size })
614 if (out.length >= MAX_SESSIONS) break
615 }
616 return out
617}
618
619// 그 턴이 다룬 git 저장소: Edit/Write 파일이 있는 곳, Bash 명령의 경로, 세션 디렉토리
620async function turnRepos($, t) {
621 const c = await ensureCtx($)
622 const home = await $.env.get('HOME')
623 const dirs = new Set([c.cwd])
624 for (const path of Object.keys(t.files)) dirs.add(path.slice(0, path.lastIndexOf('/')) || '/')
625 for (const cmd of t.bashCommands) for (const d of commandDirs(cmd, c.cwd, home)) dirs.add(d)
626 const tops = new Set()
627 for (const d of dirs) {
628 const top = await topOf($, d)
629 if (top) tops.add(top)
630 }
631 return [...tops]
632}
633
634// 그 턴의 시간 범위에 내가(저장소의 user.email) 만든 커밋. 병합 커밋 제외, 오래된 것부터
635async function turnCommits($, top, t) {
636 const email = (await git($, top, ['config', 'user.email'])).out.trim()
637 const args = ['log', '--all', '--no-merges', '--reverse', '--since=' + t.startAt, '--until=' + t.endAt, '--format=%H%x09%cI%x09%s']
638 if (email) args.push('--author=' + email)
639 return parseLog((await git($, top, args)).out).filter((cm) => madeByTurn(cm.subject, t.bashCommands))
640}
641
642// 세션 하나의 턴들을 대화 기록에서 복원한다 (펼칠 때). 서브에이전트 기록도 함께
643async function loadSessionTurns($, sessionId) {
644 const dir = await transcriptsDir($)
645 const file = dir + '/' + sessionId + '.jsonl'
646 // 요청·설명·도구 호출 줄, 그리고 편집 결과(고치기 전 파일이 든 줄)만. 아주 긴 줄은 편집 결과일 때만
647 const mainScript = '{ grep -E "\\"type\\":\\"(user|assistant)\\"" "$1" | grep -v "\\"tool_result\\"" | awk \'length($0) < 500000\'; grep -F "\\"originalFile\\"" "$1"; } 2>/dev/null'
648 const main = (await sh($, mainScript, [file])).split('\n')
649 const subScript = 'for f in "$1"/subagents/*.jsonl; do [ -f "$f" ] || continue; echo "@@AGENT $(basename "$f" .jsonl)"; grep -h -E "\\"name\\":\\"(Edit|Write|MultiEdit)\\"|\\"originalFile\\"" "$f"; done 2>/dev/null'
650 const agentLines = []
651 let agent = ''
652 for (const line of (await sh($, subScript, [dir + '/' + sessionId])).split('\n')) {
653 if (line.startsWith('@@AGENT ')) agent = line.slice(8)
654 else if (line) agentLines.push({ agent, line })
655 }
656 const home = await $.env.get('HOME')
657 const out = []
658 const turns = turnsFromTranscript(main, agentLines)
659 for (let i = 0; i < turns.length; i++) {
660 const t = turns[i]
661 // Claude 메모리나 임시 작업 폴더를 고친 것은 작업 결과가 아니다
662 for (const path of Object.keys(t.files)) if (!isWorkPath(path, home)) delete t.files[path]
663 // 실제로 바뀐 것이 있는 턴만: Edit/Write 변경, 또는 그 시간대의 커밋
664 const subjects = []
665 if (t.bashCommands.length) for (const top of await turnRepos($, t)) for (const cm of await turnCommits($, top, t)) subjects.push(cm.subject)
666 const fileCount = Object.keys(t.files).length
667 // "가자"처럼 짧은 요청만으로는 무슨 턴인지 모른다: 커밋 메시지와 바꾼 파일 이름을 함께 보여 준다
668 const fileNames = Object.keys(t.files).map((x) => x.split('/').pop())
669 // files는 복원에 쓰는 파일 내용 그대로 두고, 개수는 fileCount로
670 if (fileCount || subjects.length) out.push({ ...t, seq: i + 1, fileCount, commits: subjects.length, subjects, fileNames })
671 }
672 return out
673}
674
675// 지난 턴 하나를 턴 기록으로 만든다: Edit/Write는 대화 기록에서, Bash로 바꾼 것은 그 턴 시간 범위의 커밋에서
676async function reconstructTurn($, sessionId, t) {
677 const c = await ensureCtx($)
678 // 그 시간 범위에 내가 만든 커밋에서 바뀐 파일
679 const files = {}
680 const commits = []
681 for (const top of await turnRepos($, t)) {
682 for (const cm of await turnCommits($, top, t)) {
683 commits.push({ ...cm, repo: top.split('/').pop() })
684 for (const ch of parseNameStatus((await git($, top, ['show', '--name-status', '-M', '--format=', cm.sha])).out)) {
685 const abs = top + '/' + ch.path
686 if (!files[abs]) {
687 const prev = ch.status === 'A' ? { ok: false } : await git($, top, ['show', cm.sha + '^:' + (ch.from || ch.path)])
688 files[abs] = { before: prev.ok ? prev.out : null, tools: ['git commit'] }
689 }
690 const now = ch.status === 'D' ? { ok: true, out: '' } : await git($, top, ['show', cm.sha + ':' + ch.path])
691 files[abs].after = now.ok ? now.out : null
692 }
693 }
694 }
695 // Edit/Write: 변경 전은 대화 기록의 것(더 이르다), 변경 후는 커밋된 것이 있으면 그것
696 for (const [abs, f] of Object.entries(t.files)) {
697 if (files[abs]) files[abs] = { ...files[abs], before: f.before, tools: [...new Set([...f.tools, ...files[abs].tools])], agents: f.agents, skipped: f.skipped }
698 else files[abs] = { ...f }
699 }
700
701 const turn = newTurn('h-' + t.id, t.request, 0)
702 const afters = {}
703 for (const [abs, f] of Object.entries(files)) {
704 const skipped = f.skipped || (typeof f.before === 'string' && looksBinary(f.before)) || (typeof f.after === 'string' && looksBinary(f.after)) ? f.skipped || 'binary' : null
705 let entry = newFileEntry(abs, relFor(c, abs), skipped ? undefined : f.before, skipped)
706 for (const tool of f.tools) entry = recordEdit(entry, tool, null)
707 for (const a of f.agents || []) entry = recordEdit(entry, f.tools[0], { agentId: a, type: null })
708 turn.files[abs] = entry
709 afters[abs] = typeof f.after === 'string' ? f.after : null
710 }
711 const commitText = commits.length ? '\n\n## 이 턴에 만든 커밋\n' + commits.map((x) => '- ' + x.repo + ' ' + x.sha.slice(0, 9) + ' ' + x.subject).join('\n') : ''
712 const record = turnRecord(turn, afters, {
713 kind: 'past',
714 sessionId,
715 seq: t.seq,
716 answer: t.answer,
717 endedAt: t.endAt,
718 context: t.excerpt + commitText,
719 commits: commits.map((x) => ({ repo: x.repo, sha: x.sha, subject: x.subject })),
720 })
721 const dir = c.repoBase + '/' + sessionId + '/turns/' + pad(t.seq) + '-h-' + t.id
722 for (const x of Object.values(turn.files)) {
723 if (x.skipped) continue
724 if (typeof x.before === 'string') await $.fs.write(dir + '/before/' + x.rel, x.before)
725 if (typeof afters[x.abs] === 'string') await $.fs.write(dir + '/after/' + x.rel, afters[x.abs])
726 }
727 await saveJson($, dir + '/turn.json', record)
728 return { record, dir }
729}
730
731// 지난 세션 하나의 "바뀐 턴" 목록. 대화 기록 파일이 그대로면 저장해 둔 것을 쓴다
732const PAST_INDEX_VERSION = 2
733async function sessionItems($, ses) {
734 const c = await ensureCtx($)
735 const file = c.repoBase + '/past-index/' + ses.id + '.json'
736 let items = null
737 const saved = await readOrNull($, file)
738 if (saved) {
739 try {
740 const x = JSON.parse(saved)
741 if (x.v === PAST_INDEX_VERSION && x.mtimeMs === ses.mtimeMs && x.size === ses.size) items = x.items
742 } catch {}
743 }
744 if (!items) {
745 items = (await loadSessionTurns($, ses.id)).map((t) => ({
746 sessionId: ses.id,
747 id: t.id,
748 seq: t.seq,
749 request: t.request.split('\n')[0].slice(0, 80),
750 startAt: t.startAt,
751 at: localDay(Date.parse(t.startAt)),
752 subjects: t.subjects,
753 fileNames: t.fileNames,
754 files: t.fileCount,
755 commits: t.commits,
756 }))
757 await saveJson($, file, { v: PAST_INDEX_VERSION, mtimeMs: ses.mtimeMs, size: ses.size, items })
758 }
759 for (const t of items) t.understood = await readUnderstood($, c.repoBase + '/' + ses.id + '/turns/' + pad(t.seq) + '-h-' + t.id)
760 return items
761}
762
763// 작업 목록을 만든다: 이 세션의 턴(기록) + 지난 작업(지난 세션들의 바뀐 턴, 최근 것부터. 세션을 하나씩 읽으며 채운다)
764async function loadList($) {
765 const c = await ensureCtx($)
766 if (!repoUsage) {
767 const saved = await readOrNull($, c.repoBase + '/usage.json')
768 try { repoUsage = saved ? { ...emptyUsage(), ...JSON.parse(saved) } : emptyUsage() } catch { repoUsage = emptyUsage() }
769 }
770 const turns = []
771 for (const t of c.session.turns) turns.push({ ...t, understood: t.understood || (await readUnderstood($, turnDir(c, t))) })
772 list = { status: 'loading', progress: '지난 세션을 찾는 중…', turns, past: [], usage: repoUsage, error: '' }
773 $.ui.invalidate('ui.render')
774 const sessions = await listSessions($)
775 for (let i = 0; i < sessions.length; i++) {
776 list = { ...list, progress: '지난 작업을 불러오는 중 ' + i + '/' + sessions.length + ' 세션…' }
777 $.ui.invalidate('ui.render')
778 const items = await sessionItems($, sessions[i])
779 list = { ...list, past: [...list.past, ...items].sort((a, b) => (b.startAt > a.startAt ? 1 : b.startAt < a.startAt ? -1 : 0)) }
780 }
781 list = { ...list, status: 'done', progress: '' }
782 $.ui.invalidate('ui.render')
783}
784
785// 작업 목록 (/explain-list, /explain list). 창을 그릴 수 없으면(claude -p) 글로
786async function showList($) {
787 const hasSurface = (await $.session.surfaces()).length > 0
788 // 화면이 없으면 목록을 글로 (claude -p에서 확인용)
789 if (!hasSurface) {
790 await loadList($)
791 const lines = []
792 for (const t of list.turns) lines.push('#' + t.seq + ' ' + t.title + ' 파일 ' + t.files + ' +' + t.added + ' −' + t.removed)
793 if (list.past.length) lines.push('', '지난 작업')
794 for (const t of list.past) {
795 lines.push('', t.at + ' ' + t.request.slice(0, 60) + ' [' + t.sessionId.slice(0, 8) + '/' + t.seq + ']')
796 for (const subject of t.subjects) lines.push(' 커밋: ' + subject)
797 if (t.fileNames.length) lines.push(' 파일: ' + t.fileNames.join(', '))
798 }
799 const u = list.usage
800 lines.push('', '해설에 쓴 토큰 (이 저장소 누적): 호출 ' + u.calls + '회, 입력 ' + u.input + ' (캐시 읽기 ' + u.cacheRead + ', 캐시 쓰기 ' + u.cacheWrite + '), 출력 ' + u.output)
801 return { text: lines.join('\n') }
802 }
803 mode = 'list'
804 loadList($).catch((err) => $.ui.log('목록을 만들지 못함: ' + err))
805 const opened = await $.ui.open({ id: PANE, title: '변경 해설', focus: true, closeOnEscape: true, columns: WANT_COLUMNS })
806 return opened && opened.isPlaced ? {} : { text: '창을 열지 못했습니다. 터미널을 넓혀 주세요.' }
807}
808
809// ── 훅 ─────────────────────────────────────────────────────
810
811export function register(on, opts) {
812 options = opts || {}
813 applyTheme(options.theme)
814 setLanguage(options.language)
815 on('session.start', async ($, e, next) => {
816 try {
817 await $.command.register({
818 name: 'explain',
819 description: '이 세션에서 Claude가 마지막으로 바꾼 내용 해설',
820 argumentHint: '[list]',
821 immediate: true,
822 })
823 await $.command.register({
824 name: 'explain-list',
825 description: '작업 목록: 이 세션의 턴과 지난 작업 (고르면 해설)',
826 immediate: true,
827 })
828 } catch (err) {
829 $.ui.log('/explain 등록 실패: ' + err)
830 }
831 // 개발용 명령은 CHANGE_EXPLAINER_DEV=1 일 때만
832 if ((await $.env.get('CHANGE_EXPLAINER_DEV')) === '1') try {
833 await $.command.register({
834 name: 'explain-check',
835 description: '(개발용) 마지막 변경 턴의 해설 섹션 하나를 실제로 만들어 JSON으로 출력',
836 argumentHint: '[섹션] [세션 id 앞부분/턴 번호]',
837 immediate: true,
838 })
839 } catch (err) {
840 $.ui.log('/explain-check 등록 실패: ' + err)
841 }
842 return next(e)
843 })
844
845 on('prompt.submit', async ($, e, next) => {
846 lastPrompt = e.text
847 return next(e)
848 }).catch(passThrough)
849
850 // 메인 대화의 턴만 시작된다 (서브에이전트의 실행은 turn.start를 내지 않는다)
851 on('turn.start', async ($, e, next) => {
852 current = newTurn(e.turnId, e.text || lastPrompt, await $.clock.now())
853 return next(e)
854 }).catch(passThrough)
855
856 // 서브에이전트가 시작되면 그때의 메인 턴과 묶어 둔다. 서브에이전트가 낳은 서브에이전트는 부모의 턴을 따른다
857 on('agent.spawn', async ($, e, next) => {
858 const result = await next(e)
859 if (result && result.agentId) {
860 const owner = e.parentAgentId ? agentTurn[e.parentAgentId] : current && current.turnId
861 if (owner) agentTurn[result.agentId] = owner
862 agentType[result.agentId] = e.subagentType
863 }
864 return result
865 }).catch(passThrough)
866
867 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
868 const path = editedPath(e)
869 const turnId = e.agentId ? agentTurn[e.agentId] : current && current.turnId
870 const turn = !turnId ? null : current && current.turnId === turnId ? current : finished[turnId] && finished[turnId].turn
871 if (!path || !turn) return next(e)
872
873 let abs = null
874 try {
875 const c = await ensureCtx($)
876 abs = absolutePath(path, c.cwd)
877 // 이 턴에서 처음 건드리는 파일이면 고치기 직전의 내용을 남긴다
878 if (!turn.files[abs]) {
879 const before = await readSnapshot($, abs)
880 const skipped = before && typeof before === 'object' ? before.skipped : null
881 await topOf($, abs.slice(0, abs.lastIndexOf('/')) || '/')
882 turn.files[abs] = newFileEntry(abs, relFor(c, abs), skipped ? undefined : before, skipped)
883 }
884 } catch {
885 abs = null
886 }
887
888 const result = await next(e)
889
890 if (abs && result && !result.deny && !result.isError) {
891 const agent = e.agentId ? { agentId: e.agentId, type: agentType[e.agentId] || null } : null
892 turn.files[abs] = recordEdit(turn.files[abs], e.tool, agent)
893 // 메인 턴이 이미 끝난 뒤의 수정(백그라운드 서브에이전트)이면 그 턴의 기록을 다시 저장한다
894 if (finished[turnId]) {
895 try { await saveTurn($, turnId) } catch (err) { $.ui.log('턴 기록 갱신 실패: ' + err) }
896 }
897 }
898 return result
899 }).catch(passThrough)
900
901 // Bash: 실행 전에 건드릴 저장소의 상태를 남긴다 (턴마다 저장소당 한 번)
902 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
903 const turnId = e.agentId ? agentTurn[e.agentId] : current && current.turnId
904 const turn = !turnId ? null : current && current.turnId === turnId ? current : finished[turnId] && finished[turnId].turn
905 if (!turn || typeof e.command !== 'string') return next(e)
906 const agent = e.agentId ? { agentId: e.agentId, type: agentType[e.agentId] || null } : null
907 let tops = []
908 try { tops = await trackBashRepos($, turn, e.command, agent) } catch (err) { $.ui.log('저장소 상태 기록 실패: ' + err) }
909 const result = await next(e)
910 // 메인 턴이 이미 끝난 뒤의 명령(백그라운드 서브에이전트)이면 그 저장소를 다시 비교해 저장한다
911 if (finished[turnId] && tops.length) {
912 try {
913 for (const top of tops) await finalizeRepo($, turn, top)
914 await saveTurn($, turnId)
915 } catch (err) { $.ui.log('턴 기록 갱신 실패: ' + err) }
916 }
917 return result
918 }).catch(passThrough)
919
920 on('turn.complete', async ($, e, next) => {
921 if (e.agentId || !current || current.turnId !== e.turnId) return next(e)
922 const turn = current
923 current = null
924 finished[turn.turnId] = {
925 turn,
926 seq: 0,
927 answer: e.answer,
928 isAborted: e.isAborted,
929 durationMs: e.durationMs,
930 endedAt: await $.clock.now(),
931 }
932 // 오래된 턴은 메모리에서 뺀다 (변경 전 내용을 들고 있어서)
933 const ids = Object.keys(finished)
934 if (ids.length > KEEP_FINISHED) {
935 const keep = {}
936 ids.slice(-KEEP_FINISHED).forEach((id) => { keep[id] = finished[id] })
937 finished = keep
938 }
939 // Bash가 건드린 저장소: 턴 시작 상태와 지금을 비교해 바뀐 파일을 넣는다
940 for (const top of Object.keys(turn.repos)) {
941 try { await finalizeRepo($, turn, top) } catch (err) { $.ui.log('Bash 변경 비교 실패: ' + top + ': ' + err) }
942 }
943 if (Object.keys(turn.files).length) {
944 try { await saveTurn($, turn.turnId) } catch (err) { $.ui.log('턴 기록 저장 실패: ' + err) }
945 }
946 return next(e)
947 }).catch(passThrough)
948
949 // 대화 기록 파일 위치: 지난 세션을 해설할 때 쓴다
950 on('classic.Stop', async ($, e, next) => {
951 if (e.transcript_path) {
952 try {
953 const c = await ensureCtx($)
954 if (c.session.transcriptPath !== e.transcript_path) {
955 c.session = { ...c.session, transcriptPath: e.transcript_path }
956 if (c.session.turns.length) await $.fs.write(c.base + '/session.json', JSON.stringify(c.session, null, 2))
957 }
958 } catch {}
959 }
960 return next(e)
961 }).catch(passThrough)
962
963 on('command.run', { command: 'explain-list' }, async ($) => showList($))
964
965 // /explain: 마지막 변경 턴의 diff 창을 연다. 창을 그릴 수 없는 곳(claude -p 등)에서는 텍스트로 답한다
966 on('command.run', { command: 'explain' }, async ($, e) => {
967 const arg = (e.args || '').trim()
968 // /explain list: /explain-list 와 같다
969 if (/^(list|목록|branches|history)$/.test(arg)) return showList($)
970 // 기본은 지금 세션: 기록이 없으면 안내만 (지난 작업은 /explain-list)
971 const rec = await latestRecord($)
972 if (!rec) return { text: NOTHING_YET }
973 const textAnswer = () => {
974 const changed = rec.files.filter((f) => f.changed)
975 return [
976 '#' + rec.seq + ' ' + rec.title,
977 '파일 ' + changed.length + '개 +' + rec.added + ' −' + rec.removed,
978 ...changed.map((f) => {
979 const tag = f.skipped ? '내용 생략(' + f.skipped + ')' : f.isNew ? '새 파일' : '수정'
980 const who = f.agents.length ? ' · 서브에이전트 ' + f.agents.map((a) => a.type || a.agentId).join(', ') : ''
981 return ' ' + f.path + ' ' + tag + ' +' + f.added + ' −' + f.removed + who
982 }),
983 ].join('\n')
984 }
985 // 화면이 없는 실행(claude -p)에서는 창을 열지 않고 글로 답한다
986 if (!(await $.session.surfaces()).length) return { text: textAnswer() }
987 await openTurn($, rec)
988 const opened = await $.ui.open({ id: PANE, title: '변경 해설', focus: true, closeOnEscape: true, columns: WANT_COLUMNS })
989 if (opened && opened.isPlaced) return {}
990 return { text: textAnswer() + '\n(창이 아직 열리지 않았습니다: ' + ((opened && opened.reason) || '터미널이 좁음') + ')' }
991 })
992
993 // 개발용: 섹션 하나를 실제 모델로 만들어 검증 결과를 글로 보여 준다 (claude -p에서도 동작)
994 on('command.run', { command: 'explain-check' }, async ($, e) => {
995 // /explain-check <섹션|all|섹션,섹션> [세션 id 앞부분/턴 번호] [model=sonnet] [json]
996 const words = (e.args || '').trim().split(/\s+/).filter(Boolean)
997 const flags = Object.fromEntries(words.filter((w) => w.includes('=')).map((w) => w.split('=')))
998 const plain = words.filter((w) => !w.includes('='))
999 const asJson = plain.includes('json')
1000 const [which = 'summary', target] = plain.filter((w) => w !== 'json')
1001 const ids = which === 'all' ? SECTIONS.map((x) => x.id) : which.split(',')
1002 if (ids.some((id) => !SECTION_TITLE[id])) return { text: '섹션 이름: all, ' + SECTIONS.map((x) => x.id).join(', ') }
1003 if (target) {
1004 // 지난 세션의 턴: "<세션 id 앞부분>/<턴 번호>"
1005 const [prefix, num] = target.split('/')
1006 const ses = (await listSessions($)).find((x) => x.id.startsWith(prefix))
1007 if (!ses) return { text: '세션을 찾지 못했습니다: ' + prefix }
1008 const turns = await loadSessionTurns($, ses.id)
1009 const t = num ? turns.find((x) => String(x.seq) === num) : turns[turns.length - 1]
1010 if (!t) return { text: '변경이 있는 턴을 찾지 못했습니다. 있는 턴: ' + turns.map((x) => x.seq).join(', ') }
1011 const { record, dir } = await reconstructTurn($, ses.id, t)
1012 view = await loadView($, record, dir)
1013 } else {
1014 const rec = await latestRecord($)
1015 if (!rec) return { text: NOTHING_YET }
1016 view = await loadView($, rec)
1017 }
1018 evalModel = flags.model || null
1019 try {
1020 const context = turnContext(view.turn, view.files, learning)
1021 const total = { dir: view.dir, usage: emptyUsage() }
1022 const sections = {}
1023 for (const id of ids) {
1024 const used = { dir: view.dir, usage: emptyUsage() }
1025 const started = Date.now()
1026 const r = await ask($, sectionPrompt(id, context), id, context, used)
1027 sections[id] = { ok: r.ok, value: r.value, error: r.error, usage: used.usage, ms: Date.now() - started }
1028 for (const k of Object.keys(total.usage)) total.usage[k] += used.usage[k]
1029 }
1030 if (asJson) return { text: JSON.stringify({ model: evalModel || 'session', turn: target || 'latest', context: context.text, sections, usage: total.usage }) }
1031 const u = total.usage
1032 const usageLine = '\n토큰: 호출 ' + u.calls + '회, 입력 ' + u.input + ' (캐시 읽기 ' + u.cacheRead + ', 캐시 쓰기 ' + u.cacheWrite + '), 출력 ' + u.output
1033 return { text: ids.map((id) => (sections[id].ok ? JSON.stringify(sections[id].value, null, 2) : id + ' 실패: ' + sections[id].error)).join('\n\n') + usageLine }
1034 } finally {
1035 evalModel = null
1036 }
1037 })
1038
1039 // 가로 이동 창(views/pan.js)이 보내는 것: 끌거나 ←/→ 로 옮긴 위치, 그리고 그 창이 쓰지 않는 키
1040 // (창을 클릭하면 키가 그 창으로 가므로, diff의 위아래 이동 키는 여기서 이어 받는다)
1041 on('ui.message', { requestId: PANE }, async ($, e, next) => {
1042 const d = e.data || {}
1043 if (typeof d.pan !== 'string') return next(e)
1044 if (typeof d.left === 'number') {
1045 if (d.pan === 'diff') diffLeft = d.left
1046 else if (ex && d.pan === 'seq') ex.seqLeft = d.left
1047 else if (ex) ex.pans = { ...ex.pans, [d.pan]: d.left }
1048 $.ui.invalidate('ui.render')
1049 return {}
1050 }
1051 const by = { j: 1, down: 1, k: -1, up: -1, pagedown: 4, pageup: -4 }[d.key]
1052 if (mode === 'diff' && by) {
1053 diffTop = Math.max(0, Math.min(diffMaxTop, diffTop + by * STEP_ROWS))
1054 $.ui.invalidate('ui.render')
1055 }
1056 return {}
1057 }).catch(passThrough)
1058
1059 // diff 창에서는 휠과 스크롤 키로 창 전체가 아니라 코드 영역만 움직인다
1060 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
1061 if (!view || mode !== 'diff') return next(e)
1062 diffTop = Math.max(0, Math.min(diffMaxTop, diffTop + e.by))
1063 $.ui.invalidate('ui.render')
1064 return {}
1065 }).catch(passThrough)
1066
1067 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1068 if (e.requestId !== PANE) return next(e)
1069 const el = $.ui.resolve(e)
1070 const cols = e.props.bodyColumns || 100
1071 const failedList = (err) => $.ui.log('작업 목록 오류: ' + err)
1072 const showList = () => {
1073 mode = 'list'
1074 if (!list) loadList($).catch(failedList)
1075 $.ui.invalidate('ui.render')
1076 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
1077 }
1078
1079 // ── 작업 목록 ──
1080 if (mode === 'list' || !view) {
1081 if (!list) return el.Text({ color: '#858b94', children: ['작업을 찾는 중…'] })
1082 return listView(el, cols, list, {
1083 refresh: () => { if (list.status !== 'loading') loadList($).catch(failedList) },
1084 back: view ? () => { mode = 'explain'; $.ui.invalidate('ui.render') } : null,
1085 openTurn: async (t) => {
1086 const c = await ensureCtx($)
1087 const text = await readOrNull($, turnDir(c, t) + '/turn.json')
1088 if (!text) return
1089 await openTurn($, JSON.parse(text))
1090 $.ui.invalidate('ui.render')
1091 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
1092 },
1093 openPast: async (item) => {
1094 if (list.opening) return
1095 const prev = list.progress
1096 list = { ...list, opening: true, progress: '대화 기록과 git에서 그 턴을 복원하는 중…' }
1097 $.ui.invalidate('ui.render')
1098 try {
1099 const t = (await loadSessionTurns($, item.sessionId)).find((x) => x.id === item.id)
1100 if (!t) throw new Error('대화 기록에서 그 턴을 찾지 못했습니다')
1101 const { record, dir } = await reconstructTurn($, item.sessionId, t)
1102 await openTurn($, record, dir)
1103 } catch (err) {
1104 list = { ...list, error: '복원하지 못했습니다: ' + err }
1105 }
1106 list = { ...list, opening: false, progress: list.status === 'loading' ? prev : '' }
1107 $.ui.invalidate('ui.render')
1108 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
1109 },
1110 })
1111 }
1112 const bodyRows = (e.props.scroll && e.props.scroll.bodyRows) || 30
1113 const redraw = () => $.ui.invalidate('ui.render')
1114 const reveal = (key) => { $.ui.scroll({ in: PANE, to: { key }, block: 'start' }).catch(() => {}) }
1115 const hunks = hunkList(view.files)
1116
1117 // 변경 블록으로 이동: 그 블록이 코드 영역 위쪽에 오도록
1118 const goTo = (n) => {
1119 pos = Math.max(0, Math.min(hunks.length - 1, n))
1120 diffTop = Math.max(0, diffModel(cols, view.files, pos).hunkStart - 2)
1121 diffLeft = 0
1122 redraw()
1123 }
1124 const firstHunkOf = (f) => {
1125 const i = hunks.findIndex((h) => h.f === f)
1126 return i < 0 ? pos : i
1127 }
1128 const openDiff = (f) => {
1129 mode = 'diff'
1130 goTo(f === undefined ? pos : firstHunkOf(f))
1131 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
1132 }
1133
1134 // ── diff 화면 ──
1135 if (mode === 'diff') {
1136 const m = diffModel(cols, view.files, pos)
1137 diffMaxTop = Math.max(0, m.lines.length - diffCodeRows(bodyRows))
1138 diffTop = Math.min(diffTop, diffMaxTop)
1139 const summary = ex && ex.sections.summary.status === 'done' ? ex.sections.summary.data : null
1140 return diffView(el, cols, bodyRows, { turn: view.turn, files: view.files, pos, top: diffTop, left: diffLeft, notes: summary ? summary.hunkNotes : null }, {
1141 prev: () => goTo(pos - 1),
1142 next: () => goTo(pos + 1),
1143 nextFile: () => goTo(firstHunkOf(((hunks[pos] ? hunks[pos].f : 0) + 1) % view.files.length)),
1144 pickFile: (f) => goTo(firstHunkOf(f)),
1145 up: () => { diffTop = Math.max(0, diffTop - STEP_ROWS); redraw() },
1146 down: () => { diffTop = Math.min(diffMaxTop, diffTop + STEP_ROWS); redraw() },
1147 leftward: () => { diffLeft = Math.max(0, diffLeft - STEP_COLS); redraw() },
1148 rightward: () => { diffLeft = Math.min(Math.max(m.maxLeft, diffLeft), diffLeft + STEP_COLS); redraw() },
1149 back: () => { mode = 'explain'; redraw() },
1150 })
1151 }
1152
1153 // ── 해설 화면 ──
1154 const failed = (err) => $.ui.log('해설 생성 실패: ' + err)
1155 const saveOpen = () => { $.store.set('open', ex.open).catch(() => {}) }
1156 const toggle = (id, show) => {
1157 ex.open = { ...ex.open, [id]: !ex.open[id] }
1158 if (ex.open[id]) {
1159 ex.current = id
1160 if (ex.sections[id].status === 'none') generateSection($, id).catch(failed)
1161 }
1162 saveOpen()
1163 redraw()
1164 if (show !== false && ex.open[id]) reveal('sec-' + id)
1165 }
1166 const codeFor = (at) => {
1167 const p = parseAt(at)
1168 if (!p) return null
1169 const file = view.files.find((f) => f.path === p.path) || view.files.find((f) => f.path.endsWith('/' + p.path) || p.path.endsWith('/' + f.path))
1170 const text = file && (p.deleted ? file.beforeText : file.afterText)
1171 if (!text) return null
1172 const all = text.split(/\r?\n/)
1173 const lines = []
1174 for (let n = p.start; n <= p.end && n <= all.length; n++) lines.push({ num: n, text: all[n - 1] })
1175 return lines.length ? { lines, deleted: p.deleted } : null
1176 }
1177
1178 return explainView(el, cols, { turn: view.turn, files: view.files, ...ex, known: learning.known, codeFor }, {
1179 openDiff,
1180 list: showList,
1181 toggle: (id) => toggle(id, true),
1182 toc: (id) => { if (!ex.open[id]) toggle(id, true); else reveal('sec-' + id) },
1183 toggleAll: (target) => { SECTIONS.forEach((s) => { if (!!ex.open[s.id] !== target) toggle(s.id, false) }) },
1184 regen: () => {
1185 if (ex.sections[ex.current].status === 'loading') return
1186 if (!ex.open[ex.current]) { toggle(ex.current, true); return }
1187 ex.sections[ex.current] = { status: 'none', data: null, error: null }
1188 generateSection($, ex.current).catch(failed)
1189 },
1190 goQuiz: () => { if (!ex.open.quiz) toggle('quiz', true); else reveal('sec-quiz') },
1191 easy: (id) => { explainEasier($, id).catch(failed) },
1192 askSection: (id, q) => { askInSection($, id, q).catch(failed) },
1193 ask: (q) => {
1194 askTurn($, q).catch(failed)
1195 $.ui.scroll({ in: PANE, to: { key: 'qa-' + ex.qa.length }, block: 'nearest' }).catch(() => {})
1196 },
1197 toggleKnown: (term) => {
1198 learning = { ...learning, known: learning.known.includes(term) ? learning.known.filter((x) => x !== term) : [...learning.known, term] }
1199 redraw()
1200 saveLearning($).catch(failed)hooks/git.js 56 lines1// git 출력 해석과 Bash 명령의 경로 추출. mods API를 쓰지 않는 순수 함수만 둔다.
2
3// Claude Code가 대화 기록을 두는 폴더 이름: 작업 디렉토리의 영숫자 아닌 글자를 '-'로
4export function projectDirName(cwd) {
5 return cwd.replace(/[^A-Za-z0-9]/g, '-')
6}
7
8// `git diff --name-status -M <base>` → [{ status, path, from }]
9export function parseNameStatus(text) {
10 return text.split('\n').filter(Boolean).map((line) => {
11 const parts = line.split('\t')
12 const code = parts[0][0]
13 if (code === 'R' || code === 'C') return { status: code, from: parts[1], path: parts[2] }
14 return { status: code, path: parts[1], from: null }
15 })
16}
17
18// Bash 명령에서 디렉토리 후보를 뽑는다: cd 대상, git -C 대상, 절대 경로와 ~/ 경로
19// 상대 경로의 cd는 cwd 기준. 파일 경로는 폴더로(확장자가 있으면). 많아야 8개
20export function commandDirs(command, cwd, home) {
21 const out = new Set()
22 const add = (p) => {
23 if (!p) return
24 let x = p.replace(/^["']|["']$/g, '')
25 if (x.startsWith('~/')) x = home + x.slice(1)
26 else if (x === '~') x = home
27 else if (!x.startsWith('/')) x = cwd.replace(/\/$/, '') + '/' + x
28 x = x.replace(/\/+$/, '')
29 if (/\.[A-Za-z0-9]{1,8}$/.test(x.split('/').pop())) x = x.slice(0, x.lastIndexOf('/'))
30 // 장치·커널 경로와 Claude Code 자체 데이터만 뺀다. 임시 폴더라도 git 저장소면 작업 대상이다
31 // (저장소가 아닌 디렉토리는 git 최상위를 찾는 단계에서 걸러진다)
32 if (/^\/(dev|proc|sys)(\/|$)/.test(x) || x.startsWith(home + '/.claude')) return
33 if (x) out.add(x)
34 }
35 for (const m of command.matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) add(m[1])
36 for (const m of command.matchAll(/git\s+-C\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) add(m[1])
37 for (const m of command.matchAll(/(?:^|[\s'"=:(>])((?:\/|~\/)[A-Za-z0-9._~@+\/-]+)/g)) add(m[1])
38 return [...out].slice(0, 8)
39}
40
41// `git status --porcelain` → { dirty: [경로], untracked: [경로] } (이름 바꾸기는 새 경로)
42export function parseStatus(text) {
43 const dirty = []
44 const untracked = []
45 for (const line of text.split('\n')) {
46 if (line.length < 4) continue
47 const code = line.slice(0, 2)
48 let path = line.slice(3)
49 if (path.includes(' -> ')) path = path.split(' -> ').pop()
50 path = path.replace(/^"|"$/g, '')
51 if (code === '??') untracked.push(path)
52 else dirty.push(path)
53 }
54 return { dirty, untracked }
55}
56hooks/history.js 186 lines1// 지난 세션 복원: Claude Code의 대화 기록(JSONL)에서 턴과 그 턴의 변경을 되살리는 순수 함수.
2// 파일 읽기와 git 실행은 register.js가 한다.
3//
4// 턴 = 사용자 요청 하나부터 다음 요청 직전까지. 그 턴에서
5// - Edit/Write는 기록에 남은 고치기 전 파일(originalFile)과 바꾼 내용으로 정확히 복원하고
6// - Bash로 다룬 저장소는 경로만 모아 둔다 (register.js가 그 턴 시간 범위의 커밋을 git에서 찾는다)
7
8// 대화 기록 줄에서 사용자의 요청(시스템이 넣은 <…> 글과 도구 결과는 제외).
9// 이미지를 붙인 요청은 내용이 블록 목록이다: 그 안의 글 블록을 쓴다
10export function promptText(o) {
11 const c = o && o.message && o.message.content
12 if (!o || o.type !== 'user' || o.isSidechain) return null
13 let t = ''
14 if (typeof c === 'string') t = c
15 else if (Array.isArray(c) && !c.some((b) => b && b.type === 'tool_result')) t = c.filter((b) => b && b.type === 'text' && b.text).map((b) => b.text).join('\n')
16 t = t.trim()
17 if (!t || t.startsWith('<') || t.startsWith('[Request interrupted')) return null
18 // 붙인 이미지 표시([Image #1], [Image: source: …])는 요청의 내용이 아니다
19 const clean = t.replace(/\[Image(?: #\d+|: source: [^\]]*)\]/g, ' ').replace(/[ \t]+/g, ' ').trim()
20 return clean || '(이미지)'
21}
22
23// 시각(ms) → 이 컴퓨터 시간대의 "MM-DD HH:mm"
24export function localDay(ms) {
25 if (!ms) return ''
26 const d = new Date(ms)
27 const p = (n) => String(n).padStart(2, '0')
28 return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
29}
30
31// Claude 자체 데이터와 임시 작업 폴더는 작업 결과가 아니다
32export function isWorkPath(path, home) {
33 return !path.startsWith(home + '/.claude/') && !/^\/(private\/)?tmp\/claude-/.test(path)
34}
35
36function assistantText(o) {
37 const c = o.message && o.message.content
38 if (o.type !== 'assistant' || !Array.isArray(c)) return ''
39 return c.filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n').trim()
40}
41
42// 편집 하나를 고치기 전 내용에 적용한 결과. 적용할 수 없으면 null
43export function applyEdit(original, edit) {
44 if (edit.tool === 'Write') return edit.content
45 if (original === null || original === undefined) return null
46 const steps = edit.tool === 'MultiEdit' ? edit.edits : [{ old_string: edit.old_string, new_string: edit.new_string, replace_all: edit.replace_all }]
47 let text = original
48 for (const s of steps || []) {
49 if (typeof s.old_string !== 'string' || typeof s.new_string !== 'string') return null
50 if (!text.includes(s.old_string)) return null
51 text = s.replace_all ? text.split(s.old_string).join(s.new_string) : text.replace(s.old_string, s.new_string)
52 }
53 return text
54}
55
56// 대화 기록 줄들 → 턴 목록
57// [{ id, startAt, endAt, request, answer, excerpt, files: { 경로: { before, after, tools, agents, skipped } }, bashCommands }]
58// agentLines: 서브에이전트 기록 줄들 (시각으로 메인 턴에 붙인다)
59export function turnsFromTranscript(lines, agentLines = []) {
60 const turns = []
61 let cur = null
62 const uses = {}
63 const events = []
64
65 const parse = (line, agent) => {
66 try {
67 const o = JSON.parse(line)
68 if (agent) o.__agent = agent
69 return o
70 } catch {
71 return null
72 }
73 }
74 const main = lines.map((l) => parse(l, null)).filter(Boolean)
75 const subs = agentLines.map((x) => parse(x.line, x.agent)).filter(Boolean)
76
77 for (const o of main) {
78 const prompt = promptText(o)
79 if (prompt) {
80 cur = { id: o.uuid || 't' + turns.length, startAt: o.timestamp || '', endAt: o.timestamp || '', request: prompt, texts: [], files: {}, bashCommands: [], order: [] }
81 turns.push(cur)
82 continue
83 }
84 if (!cur) continue
85 if (o.timestamp) cur.endAt = o.timestamp
86 const text = assistantText(o)
87 if (text && !o.isSidechain) cur.texts.push({ at: o.timestamp || '', text })
88 events.push({ o, turn: cur })
89 }
90 // 서브에이전트의 기록은 시각이 들어가는 메인 턴에 붙인다
91 for (const o of subs) {
92 const at = o.timestamp || ''
93 const turn = turns.filter((t) => t.startAt <= at).pop()
94 if (turn) events.push({ o, turn })
95 }
96
97 for (const { o, turn } of events) {
98 const c = o.message && o.message.content
99 if (!Array.isArray(c)) continue
100 for (const b of c) {
101 if (b.type === 'tool_use') {
102 if (['Edit', 'Write', 'MultiEdit'].includes(b.name)) uses[b.id] = { tool: b.name, input: b.input || {}, turn, agent: o.__agent }
103 else if (b.name === 'Bash' && b.input && typeof b.input.command === 'string') turn.bashCommands.push(b.input.command)
104 }
105 if (b.type === 'tool_result' && uses[b.tool_use_id]) {
106 const u = uses[b.tool_use_id]
107 delete uses[b.tool_use_id]
108 if (b.is_error) continue
109 const r = o.toolUseResult && typeof o.toolUseResult === 'object' ? o.toolUseResult : {}
110 const path = r.filePath || u.input.file_path
111 if (!path) continue
112 const original = typeof r.originalFile === 'string' ? r.originalFile : r.type === 'create' ? null : undefined
113 const edit = { tool: u.tool, ...u.input }
114 const after = original === undefined ? null : applyEdit(original, edit)
115 const f = u.turn.files[path]
116 if (!f) {
117 u.turn.files[path] = {
118 before: original === undefined ? null : original,
119 after,
120 tools: [u.tool],
121 agents: u.agent ? [u.agent] : [],
122 // 고치기 전 내용이 기록에 없거나 적용이 안 되면 내용 비교를 하지 않는다
123 skipped: original === undefined || after === null ? 'unreadable' : null,
124 }
125 } else {
126 f.after = after
127 if (after === null) f.skipped = 'unreadable'
128 if (!f.tools.includes(u.tool)) f.tools.push(u.tool)
129 if (u.agent && !f.agents.includes(u.agent)) f.agents.push(u.agent)
130 }
131 }
132 }
133 }
134
135 return turns.map((t, i) => ({
136 id: t.id,
137 startAt: t.startAt,
138 // 다음 요청 직전까지가 이 턴 (커밋 시간 범위)
139 endAt: turns[i + 1] ? turns[i + 1].startAt : t.endAt,
140 request: t.request,
141 answer: t.texts.length ? t.texts[t.texts.length - 1].text : '',
142 // "가자"처럼 짧은 요청은 앞 턴들에 진짜 이유가 있다: 같은 세션의 앞선 요청 몇 개를 함께
143 excerpt: priorRequests(turns, i) + turnExcerpt(t),
144 files: t.files,
145 bashCommands: t.bashCommands,
146 }))
147}
148
149const PRIOR_TURNS = 4
150
151function priorRequests(turns, i) {
152 const prior = turns.slice(Math.max(0, i - PRIOR_TURNS), i)
153 if (!prior.length) return ''
154 return '## 같은 세션의 앞선 요청 (오래된 것부터)\n' + prior.map((p) => '- ' + (p.request.length > 400 ? p.request.slice(0, 400) + '…' : p.request).replace(/\n+/g, ' ')).join('\n') + '\n\n## 이 턴\n'
155}
156
157// 이 턴의 대화: 요청과 Claude의 설명 (해설 프롬프트의 맥락)
158function turnExcerpt(t, maxChars = 12000) {
159 const parts = ['사용자: ' + t.request, ...t.texts.map((x) => 'Claude: ' + (x.text.length > 2000 ? x.text.slice(0, 2000) + '…' : x.text))]
160 let out = parts.join('\n\n')
161 if (out.length > maxChars) out = out.slice(0, 2000) + '\n\n…(중간 생략)…\n\n' + out.slice(out.length - (maxChars - 2100))
162 return out
163}
164
165// 그 턴이 이 커밋을 만들었나. 같은 시간대에 다른 세션도 커밋할 수 있어서 시간만으로는 모른다:
166// - 그 턴에 커밋을 만드는 명령(git commit, cherry-pick, merge, revert, am)이 있어야 하고
167// - git commit에 메시지를 적어 넣었다면(-m, heredoc) 그 메시지가 커밋 제목과 맞아야 한다
168const COMMIT_CMD = /\bgit(?:\s+-[Cc]\s+\S+)*\s+(?:commit|cherry-pick|merge|revert|am)(?![\w-])/
169export function madeByTurn(subject, bashCommands) {
170 const makers = bashCommands.filter((c) => COMMIT_CMD.test(c))
171 if (!makers.length) return false
172 const norm = (x) => x.replace(/\s+/g, ' ')
173 const head = norm(subject).slice(0, 30)
174 const inline = makers.filter((c) => /\bcommit\b[\s\S]*(\s-[a-zA-Z]*m\b|\s--message\b|<<)/.test(c))
175 if (inline.length === makers.length) return inline.some((c) => norm(c).includes(head))
176 return true
177}
178
179// `git log --format=%H%x09%cI%x09%s` → [{ sha, at, subject }]
180export function parseLog(text) {
181 return text.split('\n').filter(Boolean).map((line) => {
182 const [sha, at, ...rest] = line.split('\t')
183 return { sha, at, subject: rest.join('\t') }
184 })
185}
186hooks/diff.js 186 lines1// 줄 단위 diff (Myers 알고리즘). mods API를 쓰지 않는 순수 함수만 둔다.
2
3// 글을 줄 배열로 나눈다. 끝의 줄바꿈 하나는 빈 줄로 세지 않는다
4export function splitLines(text) {
5 if (text === null || text === undefined || text === '') return []
6 const lines = text.split(/\r?\n/)
7 if (lines[lines.length - 1] === '') lines.pop()
8 return lines
9}
10
11// a에서 b로 가는 최단 편집: [{ op: 'eq' | 'del' | 'add', a?: a의 줄 위치, b?: b의 줄 위치 }]
12// 위치는 0부터. eq는 a와 b 둘 다, del은 a만, add는 b만 가진다
13export function diffLines(a, b) {
14 const n = a.length
15 const m = b.length
16 const max = n + m
17 const offset = max
18 const v = new Array(2 * max + 2).fill(0)
19 const trace = []
20
21 outer: for (let d = 0; d <= max; d++) {
22 trace.push(v.slice())
23 for (let k = -d; k <= d; k += 2) {
24 let x
25 if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1]
26 else x = v[offset + k - 1] + 1
27 let y = x - k
28 while (x < n && y < m && a[x] === b[y]) { x++; y++ }
29 v[offset + k] = x
30 if (x >= n && y >= m) break outer
31 }
32 }
33
34 // 지나온 길을 거꾸로 따라가며 편집 목록을 만든다
35 const ops = []
36 let x = n
37 let y = m
38 for (let d = trace.length - 1; d >= 0; d--) {
39 const vv = trace[d]
40 const k = x - y
41 let prevK
42 if (k === -d || (k !== d && vv[offset + k - 1] < vv[offset + k + 1])) prevK = k + 1
43 else prevK = k - 1
44 const prevX = vv[offset + prevK]
45 const prevY = prevX - prevK
46 while (x > prevX && y > prevY) { x--; y--; ops.push({ op: 'eq', a: x, b: y }) }
47 if (d > 0) {
48 if (x === prevX) { y--; ops.push({ op: 'add', b: y }) }
49 else { x--; ops.push({ op: 'del', a: x }) }
50 }
51 }
52 return ops.reverse()
53}
54
55// 추가·삭제 줄 수
56export function countChanges(ops) {
57 let added = 0
58 let removed = 0
59 for (const o of ops) {
60 if (o.op === 'add') added++
61 else if (o.op === 'del') removed++
62 }
63 return { added, removed }
64}
65
66// 두 글의 추가·삭제 줄 수 (before가 null이면 새 파일)
67export function lineStats(before, after) {
68 return countChanges(diffLines(splitLines(before), splitLines(after)))
69}
70
71// ── 화면용 diff 행 ───────────────────────────────────────────
72// 변경 블록(연속된 추가·삭제) 단위로 묶고, 변경 없는 긴 구간은 접는다.
73//
74// 행 하나: { kind, left, right, hunk }
75// kind 'eq' 같음 · 'add' 추가만 · 'del' 삭제만 · 'mod' 같은 블록 안에 삭제와 추가가 함께
76// left { num, text, hi } 또는 null (변경 전 줄, num은 1부터)
77// right { num, text, hi } 또는 null (변경 후 줄)
78// hi 줄 안에서 바뀐 구간 [[시작, 끝)] (mod 짝 줄에만)
79// hunk 변경 블록 번호 (1부터, eq는 0)
80// 접힌 구간: { kind: 'fold', count }
81
82export const CONTEXT = 3
83
84// 이보다 긴 줄은 단어 diff 대신 앞뒤가 같은 부분만 빼고 강조한다 (계산량 제한)
85const WORD_DIFF_MAX = 2000
86
87// 줄을 단어·공백·기호 단위로 쪼갠다
88function tokens(text) {
89 return text.match(/[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu) || []
90}
91
92// 두 줄에서 바뀐 구간들을 찾는다: { left: [[시작, 끝)], right: [[시작, 끝)] }
93// 단어 단위로 diff해서, 바뀐 단어가 이어지는 곳을 한 구간으로 묶는다
94export function changedSpan(a, b) {
95 if (a.length + b.length > WORD_DIFF_MAX) return edgeSpan(a, b)
96 const ta = tokens(a)
97 const tb = tokens(b)
98 const left = []
99 const right = []
100 let pa = 0
101 let pb = 0
102 const push = (list, s, e) => {
103 const last = list[list.length - 1]
104 if (last && last[1] === s) last[1] = e
105 else list.push([s, e])
106 }
107 for (const o of diffLines(ta, tb)) {
108 if (o.op === 'eq') { pa += ta[o.a].length; pb += tb[o.b].length; continue }
109 if (o.op === 'del') { push(left, pa, pa + ta[o.a].length); pa += ta[o.a].length }
110 else { push(right, pb, pb + tb[o.b].length); pb += tb[o.b].length }
111 }
112 return { left, right }
113}
114
115// 앞뒤의 같은 부분을 뺀 가운데 한 구간
116function edgeSpan(a, b) {
117 let start = 0
118 while (start < a.length && start < b.length && a[start] === b[start]) start++
119 let endA = a.length
120 let endB = b.length
121 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB-- }
122 return { left: endA > start ? [[start, endA]] : [], right: endB > start ? [[start, endB]] : [] }
123}
124
125export function buildRows(beforeText, afterText, context = CONTEXT) {
126 const a = splitLines(beforeText)
127 const b = splitLines(afterText)
128 const ops = diffLines(a, b)
129 const rows = []
130 let hunk = 0
131 let i = 0
132 while (i < ops.length) {
133 if (ops[i].op === 'eq') {
134 const o = ops[i]
135 rows.push({ kind: 'eq', left: { num: o.a + 1, text: a[o.a] }, right: { num: o.b + 1, text: b[o.b] }, hunk: 0 })
136 i++
137 continue
138 }
139 // 연속된 추가·삭제를 한 블록으로
140 const dels = []
141 const adds = []
142 while (i < ops.length && ops[i].op !== 'eq') {
143 if (ops[i].op === 'del') dels.push(ops[i].a)
144 else adds.push(ops[i].b)
145 i++
146 }
147 hunk++
148 const kind = dels.length && adds.length ? 'mod' : dels.length ? 'del' : 'add'
149 const n = Math.max(dels.length, adds.length)
150 for (let k = 0; k < n; k++) {
151 const l = k < dels.length ? { num: dels[k] + 1, text: a[dels[k]], hi: [] } : null
152 const r = k < adds.length ? { num: adds[k] + 1, text: b[adds[k]], hi: [] } : null
153 if (l && r) {
154 const span = changedSpan(l.text, r.text)
155 l.hi = span.left
156 r.hi = span.right
157 }
158 rows.push({ kind, left: l, right: r, hunk })
159 }
160 }
161 return { rows: fold(rows, context), hunks: hunk }
162}
163
164// 변경 블록에서 context줄보다 먼 같은 줄은 접는다. 숨길 줄이 한 줄뿐이면 접지 않고 보여 준다
165function fold(rows, context) {
166 const near = rows.map(() => false)
167 rows.forEach((r, i) => {
168 if (r.kind === 'eq') return
169 for (let k = Math.max(0, i - context); k <= Math.min(rows.length - 1, i + context); k++) near[k] = true
170 })
171 const out = []
172 let hidden = []
173 const flush = () => {
174 if (hidden.length === 1) out.push(hidden[0])
175 else if (hidden.length > 1) out.push({ kind: 'fold', count: hidden.length })
176 hidden = []
177 }
178 rows.forEach((r, i) => {
179 if (r.kind === 'eq' && !near[i]) { hidden.push(r); return }
180 flush()
181 out.push(r)
182 })
183 flush()
184 return out
185}
186hooks/generate.js 346 lines1// 해설 생성: 모델에 보낼 프롬프트와, 돌아온 JSON의 검증. mods API를 쓰지 않는 순수 함수만 둔다.
2//
3// 원칙 (SPEC 5장, teach·wait-what 스킬)
4// - 모델은 구조(JSON)만 돌려준다. 그리기는 mod가 한다
5// - 코드 조각은 모델이 쓰지 않는다. 위치(path:줄)만 받고 mod가 스냅숏에서 읽는다
6// - 대화 기록에 근거가 없는 "왜"는 (추정)으로 표시하게 한다
7// - 이미 아는 용어(Known)는 짧게, 정의하지 않은 용어는 쓰지 않게 한다
8
9export const SECTIONS = [
10 { id: 'summary', key: '1', title: '요약' },
11 { id: 'background', key: '2', title: '배경' },
12 { id: 'walk', key: '3', title: '코드 따라가기' },
13 { id: 'flow', key: '4', title: '흐름도' },
14 { id: 'seq', key: '5', title: '시퀀스' },
15 { id: 'ba', key: '6', title: '전후 비교' },
16 { id: 'impact', key: '7', title: '영향 범위' },
17 { id: 'terms', key: '8', title: '용어' },
18 { id: 'quiz', key: '9', title: '확인 퀴즈' },
19]
20
21const MAX_DIFF_CHARS = 30000
22const MAX_ANSWER_CHARS = 4000
23
24const clip = (s, n) => (s && s.length > n ? s.slice(0, n) + '\n…(생략)' : s || '')
25
26// ── 턴 맥락: 요청문, 답변, 번호 붙은 diff ─────────────────────
27
28// files: [{ path, isNew, skipped, rows, hunks }] (diff.js의 buildRows 결과)
29// 변경 블록마다 "변경 N"을 붙인다. 번호는 diff 창의 변경 순서와 같다
30export function diffText(files) {
31 const out = []
32 let n = 0
33 for (const f of files) {
34 out.push('### ' + f.path + (f.skipped ? ' (내용 생략: ' + f.skipped + ')' : f.isNew ? ' (새 파일)' : ' (수정)'))
35 if (f.skipped) continue
36 let lastHunk = 0
37 for (const r of f.rows) {
38 if (r.kind === 'fold') { out.push(' … 변경 없는 ' + r.count + '줄'); continue }
39 if (r.hunk && r.hunk !== lastHunk) { n++; lastHunk = r.hunk; out.push('@@ 변경 ' + n) }
40 if (r.kind === 'eq') { out.push(' ' + pad(r.right.num) + ' ' + r.right.text); continue }
41 if (r.left) out.push('- ' + pad(r.left.num) + ' ' + r.left.text)
42 if (r.right) out.push('+ ' + pad(r.right.num) + ' ' + r.right.text)
43 }
44 }
45 return { text: clip(out.join('\n'), MAX_DIFF_CHARS), hunkCount: n }
46}
47
48const pad = (n) => String(n).padStart(4, ' ')
49
50// turn: 턴 기록(turn.json), files: 화면용 파일, learning: { known: [용어], stuck: [섹션 제목] }
51export function turnContext(turn, files, learning) {
52 const d = diffText(files)
53 const lines = [
54 '## 해설할 턴',
55 '사용자 요청: ' + turn.request,
56 '',
57 ...(turn.answer ? ['Claude의 최종 답변:', clip(turn.answer, MAX_ANSWER_CHARS)] : []),
58 '',
59 '## 이 턴의 diff',
60 '형식: "- 줄번호"는 변경 전 파일의 줄, "+ 줄번호"는 변경 후 파일의 줄, 앞이 공백인 줄은 같은 줄(변경 후 줄번호).',
61 '"@@ 변경 N"은 변경 블록 번호.',
62 d.text,
63 ]
64 if (turn.context) {
65 lines.push('', '## 이 작업을 하던 대화 (발췌, 오래된 것부터)', '이 대화에 근거해 "왜"를 설명해. 대화에 없는 이유는 (추정)으로 표시해.', clip(turn.context, 16000))
66 }
67 if (learning && learning.known && learning.known.length) {
68 lines.push('', '## 사용자가 이미 아는 용어 (짧게만 언급하고 다시 정의하지 말 것)', learning.known.join(', '))
69 }
70 if (learning && learning.stuck && learning.stuck.length) {
71 lines.push('', '## 사용자가 전에 막혔던 부분 (이번에도 나오면 처음부터 풀어서 설명할 것)', learning.stuck.join(', '))
72 }
73 return { text: lines.join('\n'), hunkCount: d.hunkCount }
74}
75
76// 해설을 쓸 언어 (설정 language): 'ko' 또는 'en'
77let language = 'ko'
78export function setLanguage(lang) {
79 language = lang === 'en' ? 'en' : 'ko'
80}
81
82function rules() {
83 return [
84 language === 'en'
85 ? '- Write every text value in English, in short plain sentences. One idea per sentence.'
86 : '- 한국어로, 짧고 쉬운 문장으로 쓴다. 한 문장에 하나의 생각.',
87 '- 이 대화와 위 diff에 근거해서 쓴다. 대화에 근거가 없는 이유는 문장 끝에 ' + (language === 'en' ? '"(guess)"' : '"(추정)"') + '을 붙인다.',
88 '- 정의하지 않은 전문 용어를 쓰지 않는다. 꼭 필요하면 쉬운 말로 풀어서 함께 쓴다.',
89 '- 코드는 짧은 식별자(함수 이름 등)만 인용한다. 코드 블록을 쓰지 않는다.',
90 '- 답은 JSON 객체 하나뿐이다. 앞뒤에 설명이나 ``` 를 붙이지 않는다.',
91 ]
92}
93
94// 섹션별 지시와 출력 형식
95const SECTION_SPECS = {
96 summary: (n) => [
97 '이 턴의 변경을 요약해.',
98 '형식:',
99 '{ "tldr": "무엇이 바뀌었는지 한 문장",',
100 ' "files": [{ "path": "diff의 파일 경로 그대로", "desc": "이 파일에서 바뀐 것 한 줄" }],',
101 ' "why": ["왜 이렇게 바꿨는지, 사용자의 요청과 연결해서. 1~3문장"],',
102 ' "how": [{ "label": "핵심 동작이나 함수 이름", "detail": "어떻게 동작하는지 한두 문장" }],',
103 ' "review": [{ "kind": "warn" | "question", "text": "사용자가 확인해야 할 점" }],',
104 ' "hunkNotes": ["변경 1 한 줄 설명", "변경 2 한 줄 설명", ...] }',
105 'files는 diff의 모든 파일을, hunkNotes는 정확히 ' + n + '개를 변경 번호 순서대로 쓴다. 각 hunkNote는 60자 이내.',
106 'how는 2~4개, review는 0~3개.',
107 ],
108 background: () => [
109 '이 변경이 왜 필요했는지 배경을 설명해. 바꾸기 전에 어떤 문제가 있었고 왜 그랬는지.',
110 '형식:',
111 '{ "problem": "사용자 입장에서 무슨 문제가 있었나",',
112 ' "cause": "코드에서 왜 그런 일이 일어났나",',
113 ' "causeAt": "원인이 있던 위치. 변경 전 파일 기준 \\"경로:시작-끝\\", 대화에서만 알 수 있으면 \\"대화\\"",',
114 ' "goal": "이번 변경의 목표",',
115 ' "outOfScope": ["이번에 일부러 다루지 않은 것"] }',
116 ],
117 walk: () => [
118 '변경된 코드를 실행 순서나 이해하기 쉬운 순서로 2~6단계로 나눠 따라가며 설명해.',
119 '각 단계의 at은 코드 위치다. mod가 그 줄을 파일에서 직접 읽어 보여 준다.',
120 '추가·수정된 코드는 변경 후 줄번호로 "경로:시작-끝", 삭제된 코드는 변경 전 줄번호로 "경로:시작-끝 (삭제)". 한 단계는 15줄 이내.',
121 '형식:',
122 '{ "steps": [{ "title": "단계 제목 (짧게)", "at": "경로:시작-끝",',
123 ' "does": "이 코드가 하는 일", "why": "왜 이렇게 했나", "alt": "검토할 만한 다른 방법과 쓰지 않은 이유. 없으면 빈 문자열" }] }',
124 ],
125 flow: () => [
126 '바뀐 로직의 실행 흐름을 위에서 아래로 이어지는 단계로 정리해. 4~8개.',
127 'kind: "step" 일반 단계, "cond" 조건(분기), "changed" 이번에 추가·변경된 단계나 조건, "ok" 정상 종료, "err" 에러 종료.',
128 '조건의 한쪽 결과가 옆으로 빠지면 branch로, 다음 단계로 이어지는 쪽의 라벨은 next로 쓴다.',
129 'title은 24자 이내, desc는 한 줄.',
130 '형식:',
131 '{ "nodes": [{ "title": "...", "desc": "...", "kind": "step", "tag": "이번에 추가 같은 표시(선택)", "next": "다음 단계로 가는 조건 라벨(선택)",',
132 ' "branch": { "label": "예", "title": "...", "desc": "...", "kind": "ok" } }] }',
133 ],
134 seq: () => [
135 '바뀐 동작에서 누가 누구를 어떤 순서로 부르는지 시퀀스로 정리해.',
136 'lanes는 2~5개, 이름은 코드 식별자처럼 짧게(12자 이내). msgs는 4~12개.',
137 'kind: "normal", "changed" 이번에 추가된 호출, "fail" 실패 응답, "ok" 성공 응답. 자기 자신 호출은 from과 to가 같다.',
138 'label은 16자 이내, note는 그 호출의 의미를 짧게(선택).',
139 '형식:',
140 '{ "lanes": [{ "name": "LoginPage" }], "msgs": [{ "from": 0, "to": 1, "label": "submit(u, p)", "kind": "normal", "note": "..." }] }',
141 ],
142 ba: () => [
143 '코드가 아니라 동작이 어떻게 달라졌는지 상황별로 비교해. 2~5개.',
144 'tone: "better" 좋아짐, "same" 그대로, "worse" 나빠지거나 비용이 생김.',
145 '형식:',
146 '{ "rows": [{ "when": "상황", "before": "이전 동작", "after": "이후 동작", "tone": "better" }] }',
147 ],
148 impact: () => [
149 '이 변경이 영향을 주는 곳을 정리해: 바뀐 함수를 부르는 곳, 새로 생긴 것, 확인할 테스트.',
150 '이 대화에서 실제로 본 위치만 경로로 쓰고, 보지 못한 것은 desc 끝에 (추정)을 붙인다.',
151 '형식:',
152 '{ "groups": [{ "title": "묶음 제목", "items": [{ "path": "경로:줄 또는 이름", "desc": "어떤 영향", "flag": "꼭 확인할 점(선택)" }] }] }',
153 ],
154 terms: () => [
155 '이 변경을 이해하는 데 필요한 개념 중 사용자가 모를 수 있는 것을 1~5개 골라 정의해. 이미 아는 용어는 빼.',
156 'def는 한두 문장, 그 개념이 무엇인지. 이 변경에서 어떻게 쓰였는지도 짧게.',
157 '형식:',
158 '{ "terms": [{ "id": "영문 소문자-하이픈", "term": "용어", "en": "영어 표현", "def": "정의" }] }',
159 ],
160 quiz: () => [
161 '사용자가 이 변경을 실제로 이해했는지 확인하는 퀴즈 3문제를 만들어. 기억에서 떠올려야 풀리는 문제로.',
162 '바뀐 코드의 동작(어떤 입력에서 어떻게 되나)과 설계 이유만 묻는다. 작업 과정(권한, 도구 사용, 테스트를 했는지 등)은 묻지 않는다.',
163 '보기는 정확히 3개, 첫 번째가 정답이다(순서는 mod가 섞는다). 세 보기의 단어 수와 길이를 비슷하게 맞추고, 서식이나 길이로 정답이 드러나지 않게 한다.',
164 'explain은 왜 그게 정답인지 한두 문장.',
165 '형식:',
166 '{ "questions": [{ "q": "질문", "options": ["정답", "오답", "오답"], "explain": "..." }] }',
167 ],
168}
169
170// 프롬프트는 블록 두 개다: 앞은 같은 턴의 모든 요청(섹션, Wait what, 질문)이 똑같이 여는 글이라
171// 프롬프트 캐시에 올리고(5분 안의 다음 요청은 약 1/10 값으로 읽는다), 뒤에 이번 요청만의 지시를 붙인다
172function shared(ctx) {
173 return [
174 '[change-explainer 요청: 코드를 고치거나 도구를 쓰지 말고, 요청한 JSON 하나만 답해줘]',
175 '사용자가 Claude가 한 변경을 이해하려고 한다. 아래는 그 턴이다.',
176 '',
177 ctx.text,
178 '',
179 '## 규칙 (모든 답에)',
180 ...rules(),
181 '',
182 ].join('\n')
183}
184
185const blocks = (ctx, lines) => [{ text: shared(ctx), cache: true }, { text: lines.join('\n') }]
186
187// 블록 → 글 하나 (fork는 글만 받는다)
188export const joinBlocks = (prompt) => (typeof prompt === 'string' ? prompt : prompt.map((b) => b.text).join(''))
189
190export function sectionPrompt(id, ctx) {
191 return blocks(ctx, ['## 지금 만들 것: ' + SECTIONS.find((s) => s.id === id).title, ...SECTION_SPECS[id](ctx.hunkCount)])
192}
193
194// Wait, what?: 같은 내용을 더 쉽게, 앞의 설명들과 다른 방식으로
195export function easyPrompt(id, ctx, sectionJson, thread) {
196 const tried = thread.filter((x) => x.type === 'easy' && x.text).map((x, i) => '설명 ' + (i + 1) + ': ' + x.text)
197 const asked = thread.filter((x) => x.type === 'q' && x.a).map((x) => 'Q: ' + x.q + '\nA: ' + x.a)
198 return blocks(ctx, [
199 '## 지금 할 것: 다시 설명',
200 '사용자가 아래 해설 섹션을 읽고 "잠깐, 무슨 말이야?"라고 했다. 이해하지 못했다.',
201 '짧게 줄이지 말고, 사용자가 놓쳤을 전제를 채워서 더 쉬운 말로 다시 설명해.',
202 tried.length
203 ? '아래 설명들은 이미 했지만 통하지 않았다. 같은 비유나 같은 순서를 반복하지 말고, 아직 안 쓴 방식(비유, 전제를 하나씩 짚기, 숫자 예, 실행 순서 따라가기 중)으로 설명해.'
204 : '일상의 비유로 시작해도 좋다.',
205 '',
206 '## 사용자가 막힌 섹션: ' + SECTIONS.find((s) => s.id === id).title,
207 JSON.stringify(sectionJson),
208 ...(tried.length ? ['', '## 이미 한 설명', ...tried] : []),
209 ...(asked.length ? ['', '## 이 섹션에서 사용자가 한 질문과 답', ...asked] : []),
210 '',
211 '형식: { "text": "다시 설명한 글. 3~6문장" }',
212 ])
213}
214
215// 질문: sectionId가 있으면 그 섹션과 대화 상자를 맥락으로, 없으면 턴 전체에 대해
216export function answerPrompt(question, ctx, section) {
217 return blocks(ctx, [
218 '## 지금 할 것: 질문에 답하기',
219 '사용자가 Claude가 한 변경에 대해 질문했다. 이 대화와 diff에 근거해서 답해.',
220 ...(section
221 ? ['', '## 질문이 나온 섹션: ' + SECTIONS.find((s) => s.id === section.id).title, JSON.stringify(section.json),
222 ...(section.thread.length ? ['', '## 이 섹션에서 이어진 대화', ...section.thread.map((x) => (x.type === 'easy' ? '쉬운 설명: ' + x.text : 'Q: ' + x.q + '\nA: ' + x.a))] : [])]
223 : []),
224 '',
225 '## 질문',
226 question,
227 '',
228 '형식: { "text": "답. 필요한 만큼, 보통 2~6문장" }',
229 ])
230}
231
232// 형식이 틀렸을 때 한 번 더 요청하는 말
233export function retryPrompt(error) {
234 return '[change-explainer 요청] 방금 답이 요청한 JSON 형식이 아니었다(' + error + '). 같은 내용을 JSON 객체 하나로만 다시 답해줘. 앞뒤 설명이나 ``` 없이.'
235}
236
237// ── 응답 해석과 검증 ─────────────────────────────────────────
238
239// 응답 글에서 JSON 객체 하나를 꺼낸다 (``` 로 감싸거나 앞뒤에 말을 붙여도)
240export function parseReply(text) {
241 if (!text) return { ok: false, error: '빈 응답' }
242 const s = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')
243 const start = s.indexOf('{')
244 const end = s.lastIndexOf('}')
245 if (start < 0 || end <= start) return { ok: false, error: 'JSON 객체가 없음' }
246 try {
247 return { ok: true, value: JSON.parse(s.slice(start, end + 1)) }
248 } catch (err) {
249 return { ok: false, error: 'JSON 해석 실패' }
250 }
251}
252
253const str = (v, max) => (typeof v === 'string' ? (max && v.length > max ? v.slice(0, max - 1) + '…' : v) : '')
254const arr = (v) => (Array.isArray(v) ? v : [])
255const oneOf = (v, list, d) => (list.includes(v) ? v : d)
256
257// 섹션별로 필요한 필드를 확인하고, 모자란 곳은 안전한 기본값으로 채운다. 핵심 필드가 없으면 실패
258const VALIDATORS = {
259 summary: (v, ctx) => {
260 if (!str(v.tldr)) return 'tldr 없음'
261 const notes = arr(v.hunkNotes).map((x) => str(x, 80))
262 while (notes.length < ctx.hunkCount) notes.push('')
263 return {
264 tldr: str(v.tldr),
265 files: arr(v.files).map((f) => ({ path: str(f && f.path), desc: str(f && f.desc) })).filter((f) => f.path),
266 why: arr(v.why).map((x) => str(x)).filter(Boolean),
267 how: arr(v.how).map((h) => ({ label: str(h && h.label), detail: str(h && h.detail) })).filter((h) => h.label || h.detail),
268 review: arr(v.review).map((r) => ({ kind: oneOf(r && r.kind, ['warn', 'question'], 'question'), text: str(r && r.text) })).filter((r) => r.text),
269 hunkNotes: notes.slice(0, ctx.hunkCount),
270 }
271 },
272 background: (v) => {
273 if (!str(v.problem) && !str(v.cause)) return 'problem, cause 없음'
274 return { problem: str(v.problem), cause: str(v.cause), causeAt: str(v.causeAt), goal: str(v.goal), outOfScope: arr(v.outOfScope).map((x) => str(x)).filter(Boolean) }
275 },
276 walk: (v) => {
277 const steps = arr(v.steps).map((s) => ({ title: str(s && s.title, 60), at: str(s && s.at), does: str(s && s.does), why: str(s && s.why), alt: str(s && s.alt) })).filter((s) => s.title && s.does)
278 return steps.length ? { steps: steps.slice(0, 8) } : 'steps 없음'
279 },
280 flow: (v) => {
281 const kinds = ['step', 'cond', 'changed', 'ok', 'err']
282 const nodes = arr(v.nodes).map((n) => ({
283 title: str(n && n.title, 40),
284 desc: str(n && n.desc),
285 kind: oneOf(n && n.kind, kinds, 'step'),
286 tag: str(n && n.tag, 40),
287 next: str(n && n.next, 20),
288 branch: n && n.branch && str(n.branch.title)
289 ? { label: str(n.branch.label, 12), title: str(n.branch.title, 40), desc: str(n.branch.desc), kind: oneOf(n.branch.kind, kinds, 'step') }
290 : null,
291 })).filter((n) => n.title)
292 return nodes.length >= 2 ? { nodes: nodes.slice(0, 12) } : '단계가 2개 미만'
293 },
294 seq: (v) => {
295 const lanes = arr(v.lanes).map((l) => ({ name: str(l && l.name, 16) })).filter((l) => l.name).slice(0, 6)
296 if (lanes.length < 2) return '참여자가 2개 미만'
297 const ok = (i) => Number.isInteger(i) && i >= 0 && i < lanes.length
298 const msgs = arr(v.msgs)
299 .filter((m) => m && ok(m.from) && ok(m.to))
300 .map((m) => ({ from: m.from, to: m.to, label: str(m.label, 24), kind: oneOf(m.kind, ['normal', 'changed', 'fail', 'ok'], 'normal'), note: str(m.note, 60) }))
301 return msgs.length ? { lanes, msgs: msgs.slice(0, 16) } : '호출이 없음'
302 },
303 ba: (v) => {
304 const rows = arr(v.rows).map((r) => ({ when: str(r && r.when), before: str(r && r.before), after: str(r && r.after), tone: oneOf(r && r.tone, ['better', 'same', 'worse'], 'same') })).filter((r) => r.when)
305 return rows.length ? { rows } : '비교가 없음'
306 },
307 impact: (v) => {
308 const groups = arr(v.groups).map((g) => ({
309 title: str(g && g.title),
310 items: arr(g && g.items).map((i) => ({ path: str(i && i.path), desc: str(i && i.desc), flag: str(i && i.flag) })).filter((i) => i.path || i.desc),
311 })).filter((g) => g.title && g.items.length)
312 return groups.length ? { groups } : '영향 범위가 없음'
313 },
314 terms: (v) => {
315 const terms = arr(v.terms).map((t, i) => ({ id: str(t && t.id, 40) || 'term-' + i, term: str(t && t.term, 40), en: str(t && t.en, 40), def: str(t && t.def) })).filter((t) => t.term && t.def)
316 return { terms }
317 },
318 quiz: (v) => {
319 const questions = arr(v.questions)
320 .map((q) => ({ q: str(q && q.q), options: arr(q && q.options).map((o) => str(o, 80)).filter(Boolean), explain: str(q && q.explain) }))
321 .filter((q) => q.q && q.options.length === 3)
322 return questions.length ? { questions: questions.slice(0, 5) } : '보기가 3개인 문제가 없음'
323 },
324 text: (v) => (str(v.text) ? { text: str(v.text) } : 'text 없음'),
325}
326
327// 응답 글 → { ok: true, value } 또는 { ok: false, error }
328export function validate(kind, text, ctx) {
329 const parsed = parseReply(text)
330 if (!parsed.ok) return parsed
331 if (!parsed.value || typeof parsed.value !== 'object') return { ok: false, error: 'JSON 객체가 아님' }
332 const result = VALIDATORS[kind](parsed.value, ctx || { hunkCount: 0 })
333 return typeof result === 'string' ? { ok: false, error: result } : { ok: true, value: result }
334}
335
336// ── 코드 위치 ────────────────────────────────────────────────
337
338// "경로:시작-끝" 또는 "경로:줄 (삭제)" → { path, start, end, deleted }
339export function parseAt(at) {
340 const m = /^\s*(.+?):(\d+)(?:\s*[-–~]\s*(\d+))?\s*(\(삭제\))?\s*$/.exec(at || '')
341 if (!m) return null
342 const start = Number(m[2])
343 const end = Math.max(start, Number(m[3] || m[2]))
344 return { path: m[1].trim(), start, end: Math.min(end, start + 14), deleted: !!m[4] }
345}
346hooks/views/common.js 103 lines1// 화면 공통 도우미. `el`은 $.ui.resolve(e)가 준 요소 생성자 묶음.
2//
3// 그리기 규칙 (SPEC 4.6)
4// - 한 줄에 여러 색이 필요하면 Text 하나 안에 Text를 넣는다. 조각을 Box 가로줄로
5// 늘어놓으면 좁은 창에서 조각마다 따로 줄바꿈되어 글자가 뒤섞인다.
6// - 다이어그램과 코드 줄은 줄바꿈 대신 잘라낸다(wrap: 'truncate-end').
7// - 한글은 2칸을 차지하므로 다이어그램 줄에서 한글은 줄 끝에만 둔다.
8
9const DARK = {
10 C: {
11 fg: '#d6d6d6', title: '#f0f0f0', dim: '#858b94', faint: '#5c6370', rule: '#2f3238',
12 accent: '#e3a857', blue: '#79b8ff', green: '#7fc79a', red: '#f08c7c', purple: '#c3a6ff',
13 },
14 // diff 배경색 (인텔리제이 다크 테마 계열)
15 D: {
16 add: '#1f3a28', addHi: '#2f6e41', mod: '#1c2c43', modHi: '#2f5a8f', modFill: '#151d29',
17 del: '#2a2c31', fill: '#16171a', rem: '#3a2326', remHi: '#6e2f36',
18 },
19}
20
21const LIGHT = {
22 C: {
23 fg: '#1f2328', title: '#0d1117', dim: '#57606a', faint: '#8c959f', rule: '#d0d7de',
24 accent: '#9a6700', blue: '#0969da', green: '#1a7f37', red: '#cf222e', purple: '#8250df',
25 },
26 D: {
27 add: '#dafbe1', addHi: '#aceebb', mod: '#ddf4ff', modHi: '#b6e3ff', modFill: '#f1f8ff',
28 del: '#eaeef2', fill: '#f6f8fa', rem: '#ffebe9', remHi: '#ffcecb',
29 },
30}
31
32// 화면이 쓰는 색. applyTheme으로 바꾼다
33export const C = { ...DARK.C }
34export const D = { ...DARK.D }
35
36// 설정 theme: 'dark'(기본) 또는 'light'
37export function applyTheme(name) {
38 const t = name === 'light' ? LIGHT : DARK
39 Object.assign(C, t.C)
40 Object.assign(D, t.D)
41}
42
43// 한 줄(또는 한 문단) 안의 색 조각들: parts = ['plain' | [text, color, bold?, background?], ...]
44export function rich(el, parts, opts) {
45 return el.Text({
46 color: C.fg,
47 ...(opts || {}),
48 children: parts.map((p) => {
49 if (typeof p === 'string') return p
50 const props = { color: p[1], bold: !!p[2], children: [p[0]] }
51 if (p[3]) props.backgroundColor = p[3]
52 return el.Text(props)
53 }),
54 })
55}
56
57export function rule(el, cols) {
58 return el.Text({ color: C.rule, wrap: 'truncate-end', children: ['─'.repeat(Math.max(10, cols))] })
59}
60
61// 버튼: Claude Code 기본 버튼. 터미널에서는 [ d diff ], Desktop 앱에서는 네이티브 버튼.
62// 대괄호 버튼은 단축키를 따로 표시하지 않으므로 라벨 앞에 키를 넣는다. primary는 화면마다 하나
63export function btn(el, { key, hotkey, label, onPress, primary, dim }) {
64 const props = { key, label: hotkey ? hotkey + ' ' + label : label, onPress }
65 if (hotkey) props.hotkey = hotkey
66 if (primary) props.variant = 'primary'
67 if (dim) props.dimColor = true
68 return el.Button(props)
69}
70
71// 대괄호 없는 글자 링크: 목차·파일 탭처럼 여러 개를 늘어놓을 때
72export function link(el, { key, label, onPress, dim }) {
73 const props = { key, label, plain: true, onPress }
74 if (dim) props.dimColor = true
75 return el.Button(props)
76}
77
78// 가운뎃점으로 구분한 링크 줄
79export function linkRow(el, links) {
80 return el.Box({
81 flexDirection: 'row',
82 flexWrap: 'wrap',
83 children: links.flatMap((l, i) => [...(i ? [el.Text({ color: C.faint, children: [' · '] })] : []), l]),
84 })
85}
86
87export function buttonRow(el, children) {
88 return el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children })
89}
90
91// 색 조각들의 앞에서 n글자를 버린다 (가로 스크롤)
92export function shift(parts, n) {
93 const out = []
94 let rest = n
95 for (const p of parts) {
96 const t = p[0]
97 if (rest >= t.length) { rest -= t.length; continue }
98 out.push(rest ? [t.slice(rest), p[1], p[2], p[3]] : p)
99 rest = 0
100 }
101 return out
102}
103hooks/views/diff.js 206 lines1// diff 창 (인텔리제이식). 위쪽 안내·버튼과 아래쪽 위치 설명은 고정하고,
2// 가운데 코드 영역만 위아래(top)·좌우(left)로 민다.
3//
4// files: [{ path, isNew, skipped, added, removed, agents, rows, hunks }] (rows는 diff.js의 buildRows)
5
6import { C, D, btn, buttonRow, linkRow, link, rich, rule, shift } from './common.js'
7
8// 창이 이 너비 이상이면 좌우 비교, 좁으면 통합 보기
9export const SIDE_BY_SIDE_COLS = 96
10
11// 모든 파일의 변경 블록을 순서대로: [{ f: 파일 위치, h: 블록 번호 }]
12export function hunkList(files) {
13 const out = []
14 files.forEach((file, f) => {
15 for (let h = 1; h <= (file.hunks || 0); h++) out.push({ f, h })
16 })
17 return out
18}
19
20// 바뀐 구간 [[시작, 끝)]으로 한 줄을 색 조각으로 나눈다
21function segments(text, ranges, hi, fg) {
22 if (!text) return [[' ', fg]]
23 if (!hi || !ranges || !ranges.length) return [[text, fg]]
24 const out = []
25 let at = 0
26 for (const [s, e] of ranges) {
27 if (s > at) out.push([text.slice(at, s), fg])
28 out.push([text.slice(s, e), fg, false, hi])
29 at = e
30 }
31 if (at < text.length) out.push([text.slice(at), fg])
32 return out
33}
34
35function filled(el, width, bg, child) {
36 const props = { width, flexShrink: 0, children: [child] }
37 if (bg) props.backgroundColor = bg
38 return el.Box(props)
39}
40
41const sideBg = (row, side) => {
42 const has = row[side] !== null
43 if (row.kind === 'eq') return null
44 if (row.kind === 'add') return side === 'left' ? D.fill : D.add
45 if (row.kind === 'del') return side === 'left' ? D.del : D.fill
46 return has ? D.mod : D.modFill
47}
48
49// 보고 있는 파일을 화면 줄 목록으로 만든다 (그리기 전 데이터)
50export function diffModel(cols, files, pos) {
51 const hunks = hunkList(files)
52 const cur = hunks[pos] || { f: 0, h: 0 }
53 const file = files[cur.f]
54 const side = cols >= SIDE_BY_SIDE_COLS
55 const lines = []
56 let hunkStart = -1
57 let longest = 0
58 const mark = (row) => (row.hunk && row.hunk === cur.h ? '>' : ' ')
59 const startHere = (row) => { if (hunkStart < 0 && row.hunk === cur.h) hunkStart = lines.length }
60
61 const rows = (file && file.rows) || []
62 for (let i = 0; i < rows.length; i++) {
63 const row = rows[i]
64 if (row.kind === 'fold') { lines.push({ fold: row.count }); continue }
65 longest = Math.max(longest, ((row.left && row.left.text) || '').length + 2, ((row.right && row.right.text) || '').length + 2)
66 if (side) {
67 startHere(row)
68 const hiL = row.kind === 'mod' ? D.modHi : null
69 const hiR = row.kind === 'mod' ? D.modHi : null
70 lines.push({
71 mark: mark(row),
72 l: { num: row.left ? row.left.num : '', parts: segments(row.left && row.left.text, row.left && row.left.hi, hiL, row.kind === 'del' ? C.dim : C.fg), bg: sideBg(row, 'left') },
73 r: { num: row.right ? row.right.num : '', parts: segments(row.right && row.right.text, row.right && row.right.hi, hiR, C.fg), bg: sideBg(row, 'right') },
74 })
75 continue
76 }
77 // 통합 보기: 한 블록의 지운 줄(−)을 모두 보여 준 뒤 추가한 줄(+)
78 if (row.kind === 'eq') {
79 lines.push({ mark: ' ', num: row.right.num, parts: [[' ' + row.right.text, C.dim]], bg: null })
80 continue
81 }
82 const block = []
83 while (i < rows.length && rows[i].hunk === row.hunk && rows[i].kind !== 'fold') block.push(rows[i++])
84 i--
85 startHere(row)
86 for (const r of block) {
87 if (r.left) lines.push({ mark: mark(r), num: r.left.num, parts: [['− ', C.red], ...segments(r.left.text, r.left.hi, D.remHi, C.fg)], bg: D.rem })
88 }
89 for (const r of block) {
90 if (r.right) lines.push({ mark: mark(r), num: r.right.num, parts: [['+ ', C.green], ...segments(r.right.text, r.right.hi, D.addHi, C.fg)], bg: D.add })
91 }
92 }
93 const codeCols = side ? Math.floor((cols - 1) / 2) - 6 : cols - 6
94 return { hunks, cur, file, side, lines, hunkStart: Math.max(0, hunkStart), maxLeft: Math.max(0, longest - codeCols) }
95}
96
97// 코드 영역에 보일 줄 수: 창 높이에서 고정된 위아래 영역을 뺀 만큼
98export function diffCodeRows(bodyRows) {
99 return Math.max(6, bodyRows - 13)
100}
101
102// 변경 블록의 줄 범위를 "14–17줄"처럼
103function hunkRange(file, h) {
104 const nums = (file.rows || []).filter((r) => r.hunk === h).map((r) => (r.right || r.left).num)
105 if (!nums.length) return ''
106 const a = Math.min(...nums)
107 const b = Math.max(...nums)
108 return a === b ? a + '줄' : a + '–' + b + '줄'
109}
110
111// st = { turn, files, pos, top, left }. on = { prev, next, nextFile, pickFile, up, down, leftward, rightward, back? }
112export function diffView(el, cols, bodyRows, st, on) {
113 const m = diffModel(cols, st.files, st.pos)
114 const codeRows = diffCodeRows(bodyRows)
115 const top = Math.min(st.top, Math.max(0, m.lines.length - codeRows))
116 const left = Math.min(st.left, m.maxLeft)
117 const half = Math.floor((cols - 1) / 2)
118 const file = m.file
119
120 const header = rich(el, [
121 ['diff ', C.accent, true],
122 ['#' + st.turn.seq + ' ', C.accent],
123 [st.turn.title, C.title, true],
124 [' 파일 ' + st.files.length + '개 ', C.dim],
125 ['+' + st.turn.added, C.green], ' ', ['−' + st.turn.removed, C.red],
126 ], { wrap: 'truncate-end' })
127
128 // 파일 탭: 대괄호 없는 링크. 보고 있는 파일은 밝게
129 const tabs = linkRow(el, st.files.map((f, i) =>
130 link(el, { key: 'tab-' + i, label: f.path.split('/').pop(), dim: i !== m.cur.f, onPress: () => on.pickFile(i) }),
131 ))
132
133 const nav = buttonRow(el, [
134 btn(el, { key: 'prev', hotkey: 'p', label: '이전 변경', dim: st.pos === 0, onPress: on.prev }),
135 btn(el, { key: 'next', hotkey: 'n', label: '다음 변경', primary: true, dim: st.pos >= m.hunks.length - 1, onPress: on.next }),
136 btn(el, { key: 'next-file', hotkey: 'f', label: '다음 파일', dim: st.files.length < 2, onPress: on.nextFile }),
137 ...(on.back ? [btn(el, { key: 'back', hotkey: 'b', label: '해설로', onPress: on.back })] : []),
138 el.Text({ color: C.accent, children: [m.hunks.length ? ' 변경 ' + (st.pos + 1) + '/' + m.hunks.length : ' 변경 없음'] }),
139 ])
140
141 const scroll = buttonRow(el, [
142 btn(el, { key: 'up', hotkey: 'k', label: '위', dim: top === 0, onPress: on.up }),
143 btn(el, { key: 'down', hotkey: 'j', label: '아래', dim: top + codeRows >= m.lines.length, onPress: on.down }),
144 btn(el, { key: 'left', hotkey: 'h', label: '왼쪽', dim: left === 0, onPress: on.leftward }),
145 btn(el, { key: 'right', hotkey: 'l', label: '오른쪽', dim: left >= m.maxLeft, onPress: on.rightward }),
146 el.Text({
147 color: C.faint,
148 children: [' 줄 ' + (m.lines.length ? top + 1 : 0) + '–' + Math.min(top + codeRows, m.lines.length) + ' / ' + m.lines.length + (left ? ' · 가로 +' + left : '') + ' · 휠로도 스크롤'],
149 }),
150 ])
151
152 const agents = file && file.agents && file.agents.length ? ' 서브에이전트 · ' + file.agents.map((a) => a.type || a.agentId).join(', ') : ''
153 const meta = !file ? el.Text({ color: C.dim, children: ['바뀐 파일이 없습니다.'] }) : rich(el, [
154 [file.path, C.blue],
155 ' ',
156 file.skipped ? ['내용 생략', C.dim] : file.isNew ? ['새 파일', C.green] : ['수정', C.blue],
157 ' ',
158 ['+' + file.added, C.green], ' ', ['−' + file.removed, C.red],
159 agents ? [agents, C.purple] : '',
160 [' · ' + (m.side ? '좌우 보기' : '통합 보기 (창이 좁음)'), C.faint],
161 ], { wrap: 'truncate-end' })
162
163 const code = []
164 if (file && file.skipped) {
165 const why = { 'too-large': '4MB가 넘는 파일', binary: '바이너리 파일', unreadable: '읽을 수 없는 파일' }[file.skipped] || file.skipped
166 code.push(el.Text({ color: C.dim, children: [' ' + why + '이라 내용을 비교하지 않았습니다.'] }))
167 } else {
168 if (m.side) {
169 code.push(el.Box({
170 flexDirection: 'row',
171 children: [
172 filled(el, half, null, el.Text({ color: C.faint, children: [' 변경 전 · 턴 시작 시점'] })),
173 el.Text({ color: C.rule, children: ['│'] }),
174 filled(el, half, null, el.Text({ color: C.faint, children: [' 변경 후 · 턴 종료 시점'] })),
175 ],
176 }))
177 }
178 // 코드 줄은 끌거나 ←/→ 로 가로 이동하는 창(pan.js)에 담는다. 위아래는 여기서 잘라 넘긴다
179 const fixed = (mark, num) => [[mark, C.accent, true], [String(num).padStart(4, ' ') + ' ', C.faint]]
180 const rows = m.lines.slice(top, top + codeRows).map((ln) => {
181 if (ln.fold) return { center: ['··· 변경 없는 ' + ln.fold + '줄 ···', C.faint] }
182 if (m.side) {
183 return {
184 segs: [
185 { fixed: fixed(ln.mark, ln.l.num), parts: ln.l.parts, bg: ln.l.bg },
186 { fixed: fixed(ln.mark, ln.r.num), parts: ln.r.parts, bg: ln.r.bg },
187 ],
188 sep: C.rule,
189 }
190 }
191 return { segs: [{ fixed: fixed(ln.mark, ln.num), parts: ln.parts, bg: ln.bg }] }
192 })
193 code.push(el.Client({ key: 'pan-diff', module: './pan.js', width: cols, props: { id: 'diff', left: st.left, rows } }))
194 }
195
196 // 아래쪽: 지금 보고 있는 변경의 위치. 변경별 한 줄 해설은 해설 생성 단계에서 붙는다
197 const where = file && m.hunks.length && !file.skipped
198 ? rich(el, [['변경 ' + (st.pos + 1) + ' ', C.accent, true], [file.path + ' ' + hunkRange(file, m.cur.h), C.fg], st.notes && st.notes[st.pos] ? [' ' + st.notes[st.pos], C.dim] : ''])
199 : el.Text({ children: [' '] })
200
201 return el.Box({
202 flexDirection: 'column',
203 children: [header, tabs, nav, scroll, rule(el, cols), meta, ...code, rule(el, cols), where],
204 })
205}
206hooks/views/explain.js 454 lines1// 해설 창. 섹션 데이터는 모델이 만든 JSON(generate.js의 검증을 거친 것)이다.
2//
3// st = {
4// turn, files, 턴 기록과 화면용 파일
5// sections: { id: { status, data, error } } status: none | loading | done | error
6// open: { id: bool }, current, 펼친 섹션, r(다시 만들기)의 대상
7// threads: { id: [{ type: 'easy', text } | { type: 'q', q, a }] } Wait, what? 대화 (null이면 만드는 중)
8// qa: [{ q, a }], known: [용어], quiz: { order, picked }, understood, seqLeft, pans: { 'walk-1': 가로 위치 },
9// codeFor: (at) => { title, lines: [{ num, text }] } | null 코드 위치를 스냅숏에서 읽은 결과
10// }
11
12import { SECTIONS } from '../generate.js'
13import { C, btn, buttonRow, link, linkRow, rich, rule } from './common.js'
14import { flowView, seqMaxLeft, seqView } from './diagrams.js'
15import { usageText } from './list.js'
16
17// 이 회차부터는 더 설명하기보다 어디가 막히는지 묻는다
18export const EASY_ASK_AFTER = 3
19export const STEP_COLS = 8
20
21function heading(el, text) {
22 return el.Text({ bold: true, color: C.accent, children: [text] })
23}
24
25function block(el, title, children) {
26 return el.Box({ flexDirection: 'column', children: [heading(el, title), el.Box({ flexDirection: 'column', paddingLeft: 2, children })] })
27}
28
29function labeled(el, label, color, text) {
30 return el.Box({
31 flexDirection: 'column',
32 children: [el.Text({ bold: true, color, children: [label] }), el.Box({ paddingLeft: 2, children: [el.Text({ children: [text] })] })],
33 })
34}
35
36// ── 섹션들 ───────────────────────────────────────────────────
37
38function summaryView(el, data, files, onFile) {
39 const descOf = (path) => (data.files.find((f) => f.path === path) || {}).desc || ''
40 const children = [
41 block(el, '무엇을 바꿨나', [
42 ...files.map((f, i) =>
43 el.Box({
44 flexDirection: 'column',
45 children: [
46 btn(el, { key: 'file-' + i, label: f.path + ' → diff', onPress: () => onFile(i) }),
47 rich(el, [
48 ' ',
49 f.skipped ? ['내용 생략', C.dim] : f.isNew ? ['새 파일', C.green] : ['수정', C.blue],
50 ' ', ['+' + f.added, C.green], f.removed ? ' ' : '', f.removed ? ['−' + f.removed, C.red] : '',
51 f.agents.length ? [' 서브에이전트', C.purple] : '',
52 descOf(f.path) ? [' ' + descOf(f.path), C.dim] : '',
53 ]),
54 ],
55 }),
56 ),
57 ]),
58 ]
59 if (data.why.length) children.push(block(el, '왜 이렇게 했나', data.why.map((w) => el.Text({ children: [w] }))))
60 if (data.how.length) {
61 children.push(block(el, '어떻게 동작하나', data.how.flatMap((h, i) => [
62 rich(el, [[(i + 1) + ' ', C.accent], [h.label, C.blue]]),
63 el.Box({ paddingLeft: 2, children: [el.Text({ color: C.dim, children: [h.detail] })] }),
64 ])))
65 }
66 if (data.review.length) {
67 children.push(block(el, '검토할 점', data.review.map((r) =>
68 rich(el, [r.kind === 'warn' ? ['! ', C.red, true] : ['? ', C.blue, true], r.text]),
69 )))
70 }
71 return el.Box({ flexDirection: 'column', rowGap: 1, children })
72}
73
74function backgroundView(el, data) {
75 const children = []
76 if (data.problem) children.push(block(el, '무슨 문제가 있었나', [el.Text({ children: [data.problem] })]))
77 if (data.cause) {
78 children.push(block(el, '왜 그랬나', [
79 el.Text({ children: [data.cause] }),
80 ...(data.causeAt ? [el.Text({ color: C.blue, children: [data.causeAt] })] : []),
81 ]))
82 }
83 if (data.goal) children.push(block(el, '이번 변경의 목표', [el.Text({ children: [data.goal] })]))
84 if (data.outOfScope.length) children.push(block(el, '이번에 다루지 않은 것', data.outOfScope.map((x) => rich(el, [['- ', C.faint], [x, C.dim]]))))
85 return el.Box({ flexDirection: 'column', rowGap: 1, children })
86}
87
88function walkView(el, data, codeFor, width, pans) {
89 return el.Box({
90 flexDirection: 'column',
91 rowGap: 1,
92 children: [
93 el.Text({ color: C.dim, children: ['실제 코드를 순서대로 따라가며 각 줄이 하는 일과 그렇게 한 이유를 설명합니다.'] }),
94 ...data.steps.map((st, i) => {
95 const code = codeFor(st.at)
96 return el.Box({
97 flexDirection: 'column',
98 rowGap: 1,
99 children: [
100 el.Box({
101 flexDirection: 'column',
102 children: [
103 rich(el, [[(i + 1) + '단계 ', C.accent, true], [st.title, C.title, true]]),
104 el.Text({ color: C.blue, wrap: 'truncate-start', children: [st.at || '(위치 없음)'] }),
105 ],
106 }),
107 // 긴 코드 줄은 끌거나 ←/→ 로 가로 이동
108 code
109 ? el.Box({
110 paddingLeft: 1,
111 children: [el.Client({
112 key: 'pan-walk-' + i,
113 module: './pan.js',
114 width: Math.max(20, width - 1),
115 props: {
116 id: 'walk-' + i,
117 left: (pans && pans['walk-' + i]) || 0,
118 rows: code.lines.map((ln) => ({ segs: [{ fixed: [[String(ln.num).padStart(4, ' ') + ' ┃ ', C.faint]], parts: [[ln.text, code.deleted ? C.dim : C.fg]] }] })),
119 hint: C.faint,
120 },
121 })],
122 })
123 : el.Text({ color: C.faint, children: [' (이 위치의 코드를 스냅숏에서 찾지 못했습니다)'] }),
124 labeled(el, '하는 일', C.green, st.does),
125 ...(st.why ? [labeled(el, '이유', C.accent, st.why)] : []),
126 ...(st.alt ? [labeled(el, '검토한 다른 방법', C.purple, st.alt)] : []),
127 ],
128 })
129 }),
130 ],
131 })
132}
133
134function baView(el, data) {
135 const tone = { better: C.green, same: C.dim, worse: C.red }
136 return el.Box({
137 flexDirection: 'column',
138 rowGap: 1,
139 children: data.rows.map((r) =>
140 el.Box({
141 flexDirection: 'column',
142 children: [
143 el.Text({ bold: true, color: C.title, children: [r.when] }),
144 rich(el, [' ', [r.before, C.dim], [' → ', C.faint], [r.after, tone[r.tone], true]]),
145 ],
146 }),
147 ),
148 })
149}
150
151function impactView(el, data) {
152 return el.Box({
153 flexDirection: 'column',
154 rowGap: 1,
155 children: data.groups.map((g) =>
156 block(el, g.title, g.items.map((it) =>
157 el.Box({
158 flexDirection: 'column',
159 children: [
160 el.Text({ color: C.blue, wrap: 'truncate-start', children: [it.path || '-'] }),
161 rich(el, [' ', [it.desc, C.fg], it.flag ? [' ' + it.flag, C.red, true] : '']),
162 ],
163 }),
164 )),
165 ),
166 })
167}
168
169function termsView(el, data, known, onToggle) {
170 if (!data.terms.length) return el.Text({ color: C.dim, children: ['새로 알아야 할 용어가 없습니다.'] })
171 const rows = data.terms.map((t) => {
172 const isKnown = known.includes(t.term)
173 const button = btn(el, { key: 'term-' + t.id, label: isKnown ? 'Undo' : 'Known', onPress: () => onToggle(t.term) })
174 if (isKnown) {
175 return el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: [rich(el, [['✓ ', C.green], [t.term, C.dim]]), button] })
176 }
177 return el.Box({
178 flexDirection: 'column',
179 children: [
180 el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: [rich(el, [[t.term, C.title, true], t.en ? [' ' + t.en, C.faint] : '']), button] }),
181 el.Box({ paddingLeft: 2, children: [el.Text({ children: [t.def] })] }),
182 ],
183 })
184 })
185 return el.Box({
186 flexDirection: 'column',
187 rowGap: 1,
188 children: [el.Text({ color: C.dim, children: ['이 변경에 필요한 개념입니다. 이미 아는 것은 [Known]을 누르면 다음 해설부터 짧게 넘어갑니다.'] }), ...rows],
189 })
190}
191
192// quiz = { order: [[보기 원래 위치...]], picked: [고른 원래 위치 | null] }. 원래 위치 0이 정답
193function quizView(el, data, quiz, on) {
194 const answered = quiz.picked.filter((x) => x !== null).length
195 const correct = quiz.picked.filter((x) => x === 0).length
196 const total = data.questions.length
197 const done = answered === total
198 return el.Box({
199 flexDirection: 'column',
200 rowGap: 1,
201 children: [
202 el.Text({ color: C.dim, children: ['기억에서 떠올리며 풀어 보세요. 다 맞히면 이 턴이 이해함으로 표시됩니다.'] }),
203 ...data.questions.map((qz, i) => {
204 const picked = quiz.picked[i]
205 const options = quiz.order[i].map((orig, j) => {
206 const label = String.fromCharCode(65 + j) + ' ' + qz.options[orig]
207 if (picked === null) return btn(el, { key: 'q' + i + '-o' + orig, label, onPress: () => on.pick(i, orig) })
208 const mark = orig === 0 ? '✓ ' : orig === picked ? '✗ ' : ' '
209 return el.Text({ color: orig === 0 ? C.green : orig === picked ? C.red : C.faint, children: [mark + label] })
210 })
211 return el.Box({
212 flexDirection: 'column',
213 children: [
214 rich(el, [['Q' + (i + 1) + ' ', C.accent, true], [qz.q, C.title, true]]),
215 el.Box({ flexDirection: 'column', paddingLeft: 2, children: options }),
216 picked === null
217 ? el.Text({ children: [' '] })
218 : el.Box({ paddingLeft: 2, children: [rich(el, [[picked === 0 ? '맞았습니다. ' : '틀렸습니다. ', picked === 0 ? C.green : C.red, true], [qz.explain, C.fg]])] }),
219 ],
220 })
221 }),
222 done
223 ? el.Box({
224 flexDirection: 'row',
225 flexWrap: 'wrap',
226 columnGap: 2,
227 children: [
228 rich(el, [[correct + '/' + total + ' 정답', correct === total ? C.green : C.accent, true],
229 [correct === total ? ' 이 턴을 이해함으로 표시했습니다.' : ' 틀린 문제의 이유를 읽고 다시 풀어 보세요.', C.dim]]),
230 ...(correct === total ? [] : [btn(el, { key: 'quiz-retry', label: '다시 풀기', onPress: on.quizRetry })]),
231 ],
232 })
233 : el.Text({ color: C.faint, children: [answered + '/' + total + ' 답함'] }),
234 ],
235 })
236}
237
238// ── Wait, what? 대화 상자 ───────────────────────────────────
239
240function threadBox(el, id, thread, on) {
241 const busy = thread.some((x) => (x.type === 'easy' ? x.text === null : x.a === null))
242 const rounds = thread.filter((x) => x.type === 'easy').length
243 const askFirst = rounds >= EASY_ASK_AFTER
244 let round = 0
245 const ask = { key: 'easy-ask-' + id, label: '이 부분 질문', placeholder: '어디가 막히는지 적고 Enter', value: '', submitLabel: '묻기', onSubmit: (v) => { if (v.trim()) on.askSection(id, v.trim()) } }
246 if (askFirst) ask.autoFocus = true
247 return el.Box({
248 key: 'easy-box-' + id,
249 marginLeft: 2,
250 marginTop: 1,
251 borderStyle: 'round',
252 borderColor: C.blue,
253 paddingX: 1,
254 flexDirection: 'column',
255 rowGap: 1,
256 children: [
257 el.Text({ bold: true, color: C.blue, children: ['Wait, what? 쉬운 말로 다시 설명'] }),
258 ...thread.map((x) => {
259 if (x.type === 'easy') {
260 round += 1
261 return el.Box({
262 flexDirection: 'column',
263 children: [
264 el.Text({ bold: true, color: C.blue, children: ['설명 ' + round] }),
265 x.text === null ? el.Text({ color: C.dim, children: ['쉬운 말로 다시 설명하는 중…'] }) : el.Text({ children: [x.text] }),
266 ],
267 })
268 }
269 return el.Box({
270 flexDirection: 'column',
271 children: [
272 rich(el, [['Q ', C.accent, true], [x.q, C.title]]),
273 x.a === null ? el.Text({ color: C.dim, children: [' 답을 만드는 중…'] }) : rich(el, [['A ', C.green, true], [x.a, C.fg]]),
274 ],
275 })
276 }),
277 ...(askFirst ? [rich(el, [['세 번 설명했는데도 막힌다면 ', C.dim], ['어느 문장, 어느 단어', C.accent, true], ['에서 막히는지 아래에 적어 주세요. 그 지점부터 다시 설명합니다.', C.dim]])] : []),
278 buttonRow(el, [btn(el, {
279 key: 'easy-again-' + id,
280 label: askFirst ? 'Wait, what? 그래도 한 번 더' : 'Wait, what? 아직 모르겠어요',
281 primary: !askFirst,
282 dim: busy || askFirst,
283 onPress: () => { if (!busy) on.easy(id) },
284 })]),
285 el.Input(ask),
286 ],
287 })
288}
289
290function statusOf(sec) {
291 if (sec.status === 'done') return ['● 생성됨', C.green]
292 if (sec.status === 'loading') return ['◌ 만드는 중', C.accent]
293 if (sec.status === 'error') return ['✗ 만들지 못함 · r로 다시', C.red]
294 return ['○ 펼치면 생성', C.faint]
295}
296
297// ── 해설 창 전체 ─────────────────────────────────────────────
298
299export function explainView(el, cols, st, on) {
300 const t = st.turn
301 const summary = st.sections.summary
302 const changed = st.files
303 const allOpen = SECTIONS.every((s) => st.open[s.id])
304 const agents = [...new Set(changed.flatMap((f) => f.agents.map((a) => a.type || 'subagent')))]
305 const when = new Date(t.endedAt || t.startedAt)
306 const hhmm = String(when.getHours()).padStart(2, '0') + ':' + String(when.getMinutes()).padStart(2, '0')
307
308 const header = el.Box({
309 flexDirection: 'column',
310 children: [
311 rich(el, [['#' + t.seq + ' ', C.accent, true], [t.title, C.title, true]]),
312 rich(el, [
313 ['파일 ' + changed.length + '개 ', C.dim], ['+' + t.added, C.green], ' ', ['−' + t.removed, C.red],
314 agents.length ? [' · 서브에이전트 ' + agents.join(', '), C.dim] : '',
315 [' · ' + hhmm + ' · ', C.dim],
316 st.understood ? ['✓ 이해함 (퀴즈 통과)', C.green, true] : ['○ 아직 확인 안 함', C.faint],
317 ], { wrap: 'truncate-end' }),
318 rich(el, [['해설에 쓴 토큰 ', C.faint], [usageText(st.usage), C.dim]], { wrap: 'truncate-end' }),
319 el.Text({ children: [' '] }),
320 rich(el, [['요청 ', C.faint], [t.request, C.dim]]),
321 el.Box({
322 marginTop: 1,
323 borderStyle: 'round',
324 borderColor: summary.status === 'error' ? C.red : C.accent,
325 paddingX: 1,
326 flexDirection: 'column',
327 children: [
328 el.Text({ bold: true, color: C.accent, children: ['한 줄 요약'] }),
329 summary.status === 'done'
330 ? el.Text({ color: C.title, children: [summary.data.tldr] })
331 : summary.status === 'error'
332 ? el.Text({ color: C.red, children: ['요약을 만들지 못했습니다: ' + summary.error + ' (r로 다시)'] })
333 : el.Text({ color: C.dim, children: ['현재 대화를 바탕으로 요약을 만드는 중…'] }),
334 ],
335 }),
336 el.Box({ marginTop: 1, children: [buttonRow(el, [
337 btn(el, { key: 'diff', hotkey: 'd', label: 'diff', primary: true, onPress: () => on.openDiff() }),
338 btn(el, { key: 'all', hotkey: 'a', label: allOpen ? '모두 접기' : '모두 펼치기', onPress: () => on.toggleAll(!allOpen) }),
339 btn(el, { key: 'regen', hotkey: 'r', label: '다시 만들기', dim: st.sections[st.current].status === 'loading', onPress: on.regen }),
340 btn(el, { key: 'go-quiz', hotkey: 'q', label: '퀴즈', onPress: on.goQuiz }),
341 ...(on.list ? [btn(el, { key: 'go-list', hotkey: 't', label: '목록', onPress: on.list })] : []),
342 ])] }),
343 el.Box({
344 marginTop: 1,
345 children: [linkRow(el, SECTIONS.map((sec) => link(el, {
346 key: 'toc-' + sec.id,
347 label: sec.key + ' ' + sec.title,
348 dim: !st.open[sec.id],
349 onPress: () => on.toc(sec.id),
350 })))],
351 }),
352 ],
353 })
354
355 const body = []
356 SECTIONS.forEach((s) => {
357 const sec = st.sections[s.id]
358 const [status, statusColor] = statusOf(sec)
359 const isOpen = !!st.open[s.id]
360 const thread = st.threads[s.id]
361 body.push(rule(el, cols))
362 body.push(el.Box({
363 key: 'sec-' + s.id,
364 flexDirection: 'row',
365 justifyContent: 'space-between',
366 children: [
367 btn(el, { key: 'btn-' + s.id, hotkey: s.key, label: s.title + (isOpen ? ' ▾' : ' ▸'), onPress: () => on.toggle(s.id) }),
368 el.Box({
369 flexDirection: 'row',
370 columnGap: 2,
371 children: [
372 ...(isOpen && sec.status === 'done' && !thread ? [btn(el, { key: 'easy-' + s.id, label: 'Wait, what?', primary: true, onPress: () => on.easy(s.id) })] : []),
373 el.Text({ color: statusColor, children: [status] }),
374 ],
375 }),
376 ],
377 }))
378 if (!isOpen) return
379 if (thread) body.push(threadBox(el, s.id, thread, on))
380 if (sec.status === 'loading') {
381 body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [el.Text({ color: C.dim, children: ['현재 대화를 바탕으로 만드는 중…'] })] }))
382 return
383 }
384 if (sec.status === 'error') {
385 body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [el.Text({ color: C.red, children: ['만들지 못했습니다: ' + sec.error + '. r을 누르면 다시 만듭니다.'] })] }))
386 return
387 }
388 if (sec.status !== 'done') return
389 const inner = cols - 2
390 const d = sec.data
391 let view
392 if (s.id === 'summary') view = summaryView(el, d, changed, on.openDiff)
393 else if (s.id === 'background') view = backgroundView(el, d)
394 else if (s.id === 'walk') view = walkView(el, d, st.codeFor, inner, st.pans)
395 else if (s.id === 'flow') view = flowView(el, d, inner)
396 else if (s.id === 'ba') view = baView(el, d)
397 else if (s.id === 'impact') view = impactView(el, d)
398 else if (s.id === 'terms') view = termsView(el, d, st.known, on.toggleKnown)
399 else if (s.id === 'quiz') view = quizView(el, d, st.quiz, on)
400 else {
401 const maxLeft = seqMaxLeft(d, inner)
402 // 위치는 창(pan.js)이 끌기와 키로도 바꾼다. 버튼의 흐림만 여기서 판단
403 const left = st.seqLeft
404 const diagram = seqView(el, d, inner, left)
405 view = maxLeft === 0 ? diagram : el.Box({
406 flexDirection: 'column',
407 rowGap: 1,
408 children: [
409 buttonRow(el, [
410 btn(el, { key: 'seq-left', hotkey: 'h', label: '왼쪽', dim: left === 0, onPress: () => on.seqShift(-STEP_COLS, maxLeft) }),
411 btn(el, { key: 'seq-right', hotkey: 'l', label: '오른쪽', dim: left >= maxLeft, onPress: () => on.seqShift(STEP_COLS, maxLeft) }),
412 el.Text({ color: C.faint, children: [' 창보다 넓은 다이어그램' + (left ? ' · 가로 +' + left : '')] }),
413 ]),
414 diagram,
415 ],
416 })
417 }
418 body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [view] }))
419 })
420 body.push(rule(el, cols))
421
422 const ask = el.Box({
423 flexDirection: 'column',
424 rowGap: 1,
425 children: [
426 el.Text({ bold: true, color: C.accent, children: ['질문하기'] }),
427 ...st.qa.map((x, i) => el.Box({
428 key: 'qa-' + i,
429 flexDirection: 'column',
430 paddingLeft: 2,
431 children: [
432 rich(el, [['Q ', C.accent, true], [x.q, C.title]]),
433 x.a === null ? el.Text({ color: C.dim, children: [' 답을 만드는 중…'] }) : rich(el, [['A ', C.green, true], [x.a, C.fg]]),
434 ],
435 })),
436 el.Input({ key: 'ask', label: '질문', placeholder: '이 변경에 대해 궁금한 점을 입력하고 Enter', value: '', submitLabel: '묻기', onSubmit: (v) => { if (v.trim()) on.ask(v.trim()) } }),
437 el.Text({ color: C.faint, children: ['본 대화에는 남지 않습니다.'] }),
438 ],
439 })
440
441 return el.Box({
442 flexDirection: 'column',
443 children: [
444 header,
445 el.Text({ children: [' '] }),
446 ...body,
447 ask,
448 rule(el, cols),
449 el.Text({ color: C.faint, children: ['휠·↑↓·PgUp/PgDn 위아래 목차로 섹션 이동 1–9 펼치기·접기 Esc 닫기'] }),
450 el.Text({ color: C.faint, children: ['대화 기록을 바탕으로 재구성한 해설입니다. 실제 내부 추론과 다를 수 있습니다.'] }),
451 ],
452 })
453}
454hooks/views/list.js 69 lines1// 작업 목록 창: 이 세션의 턴과 지난 작업. 한 줄 = 바뀐 턴 하나 (세션으로 묶지 않는다).
2//
3// st = {
4// status, progress, error,
5// turns: [{ seq, turnId, title, files, added, removed, understood }], 이 세션 (실시간 기록)
6// past: [{ sessionId, id, seq, request, at, subjects, fileNames, files, commits, understood }]
7// 지난 세션들의 바뀐 턴 (최근 것부터, 불러오며 채워진다)
8// usage: { calls, input, output, cacheRead, cacheWrite } 이 저장소에서 해설에 쓴 토큰
9// }
10
11import { C, btn, buttonRow, link, rich, rule } from './common.js'
12
13// 1234 → 1.2k
14export const k = (n) => (n >= 10000 ? Math.round(n / 1000) + 'k' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n))
15
16export function usageText(u) {
17 if (!u || !u.calls) return '아직 없음'
18 return '호출 ' + u.calls + '회 · 입력 ' + k(u.input + u.cacheRead + u.cacheWrite) + ' (캐시 읽기 ' + k(u.cacheRead) + ') · 출력 ' + k(u.output)
19}
20
21const mark = (understood) => (understood ? ['✓ 이해함', C.green, true] : ['○ 아직', C.faint])
22const sep = [' · ', C.faint]
23
24// 지난 턴의 제목: 커밋 메시지, 없으면 바꾼 파일 이름 ("가자" 같은 요청만으로는 무슨 일인지 모른다)
25function pastTitle(t) {
26 if (t.subjects.length) return t.subjects[0] + (t.subjects.length > 1 ? ' 외 커밋 ' + (t.subjects.length - 1) + '개' : '')
27 return t.fileNames.slice(0, 3).join(', ') + (t.fileNames.length > 3 ? ' 외 ' + (t.fileNames.length - 3) + '개' : '')
28}
29
30export function listView(el, cols, st, on) {
31 const out = [
32 rich(el, [['작업 목록 ', C.accent, true], ['고르면 해설 창이 열립니다. 아직 이해하지 않은 턴은 ○', C.dim]]),
33 rich(el, [['해설에 쓴 토큰 (이 저장소 누적) ', C.faint], [usageText(st.usage), C.dim]], { wrap: 'truncate-end' }),
34 el.Box({ marginTop: 1, children: [buttonRow(el, [
35 btn(el, { key: 'refresh', hotkey: 'r', label: '다시 찾기', dim: st.status === 'loading', onPress: on.refresh }),
36 ...(on.back ? [btn(el, { key: 'back', hotkey: 'b', label: '해설로', onPress: on.back })] : []),
37 ])] }),
38 ]
39
40 // 지난 턴을 여는 중이거나 실패했으면 목록 위에 (긴 목록 아래에 두면 보이지 않는다)
41 if (st.opening) out.push(el.Text({ color: C.accent, children: [st.progress] }))
42 if (st.error) out.push(el.Text({ color: C.red, children: [st.error] }))
43 out.push(rule(el, cols), el.Text({ bold: true, color: C.title, children: ['이 세션'] }))
44 if (!st.turns.length) out.push(el.Text({ color: C.faint, children: [' 아직 기록된 변경이 없습니다.'] }))
45 for (const t of [...st.turns].reverse()) {
46 out.push(el.Box({
47 flexDirection: 'column',
48 children: [
49 link(el, { key: 'turn-' + t.turnId, label: '#' + t.seq + ' ' + t.title, onPress: () => on.openTurn(t) }),
50 rich(el, [' ', ['파일 ' + t.files + ' ', C.dim], ['+' + t.added, C.green], ' ', ['−' + t.removed, C.red], sep, mark(t.understood)], { wrap: 'truncate-end' }),
51 ],
52 }))
53 }
54
55 out.push(rule(el, cols), rich(el, [['지난 작업', C.title, true], st.status === 'loading' ? [' ' + (st.progress || '찾는 중…'), C.dim] : '']))
56 if (!st.past.length && st.status !== 'loading') out.push(el.Text({ color: C.faint, children: [' 이 프로젝트의 지난 대화에서 바뀐 것이 없습니다.'] }))
57 for (const t of st.past) {
58 out.push(el.Box({
59 flexDirection: 'column',
60 children: [
61 link(el, { key: 'past-' + t.id, label: pastTitle(t), onPress: () => on.openPast(t) }),
62 rich(el, [' ', [t.at, C.dim], sep, [t.request, C.dim], sep, mark(t.understood)], { wrap: 'truncate-end' }),
63 ],
64 }))
65 }
66
67 return el.Box({ flexDirection: 'column', children: out })
68}
69hooks/turns.js 124 lines1// 턴 기록을 다루는 순수 함수. 파일 읽기·쓰기는 register.js가 한다.
2
3import { lineStats } from './diff.js'
4
5// 파일 하나를 통째로 다룰 수 있는 최대 크기 ($.fs 제한과 같다)
6export const MAX_FILE_BYTES = 4 * 1024 * 1024
7
8// 이 mod가 기록하는 수정 도구
9export const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
10
11// 도구 입력에서 고친 파일 경로를 꺼낸다
12export function editedPath(e) {
13 return e.file_path || e.notebook_path || null
14}
15
16// 상대 경로면 작업 디렉터리 기준 절대 경로로
17export function absolutePath(path, cwd) {
18 if (path.startsWith('/')) return normalize(path)
19 return normalize(cwd.replace(/\/$/, '') + '/' + path)
20}
21
22function normalize(path) {
23 const out = []
24 for (const part of path.split('/')) {
25 if (part === '' || part === '.') continue
26 if (part === '..') out.pop()
27 else out.push(part)
28 }
29 return '/' + out.join('/')
30}
31
32// 저장소 안이면 저장소 기준 상대 경로, 밖이면 절대 경로를 '_outside/' 아래로
33export function storedPath(abs, root) {
34 const base = root.replace(/\/$/, '') + '/'
35 if (abs.startsWith(base)) return abs.slice(base.length)
36 return '_outside' + abs
37}
38
39// 글이 텍스트로 다룰 수 없는 내용인지 (NUL 문자가 있으면 바이너리로 본다)
40export function looksBinary(text) {
41 return text.includes('\u0000')
42}
43
44// 요청문 첫 줄로 턴 제목을 만든다 (모델을 부르지 않는다)
45export function turnTitle(request, max = 60) {
46 const first = (request || '').split('\n').map((x) => x.trim()).find((x) => x) || '(요청 없음)'
47 return first.length > max ? first.slice(0, max - 1) + '…' : first
48}
49
50// repos: Bash가 건드린 git 저장소의 턴 시작 상태 (register.js의 snapshotRepo)
51export function newTurn(turnId, request, startedAt) {
52 return { turnId, request: request || '', startedAt, files: {}, repos: {} }
53}
54
55// 턴에서 처음 건드리는 파일의 기록. before가 null이면 새 파일, skipped면 내용을 다루지 않는다
56export function newFileEntry(abs, rel, before, skipped) {
57 return { abs, rel, before, isNew: before === null && !skipped, skipped: skipped || null, edits: 0, tools: [], agents: [] }
58}
59
60// 성공한 수정 한 번을 기록한다
61export function recordEdit(entry, tool, agent) {
62 const tools = entry.tools.includes(tool) ? entry.tools : [...entry.tools, tool]
63 const agents = agent && !entry.agents.some((x) => x.agentId === agent.agentId) ? [...entry.agents, agent] : entry.agents
64 return { ...entry, edits: entry.edits + 1, tools, agents }
65}
66
67// 저장할 턴 기록 (스냅숏 내용은 빼고 파일 목록과 줄 수만)
68export function turnRecord(turn, afters, extra) {
69 const files = Object.values(turn.files)
70 .filter((f) => f.edits > 0)
71 .map((f) => {
72 const after = afters[f.abs]
73 const changed = f.skipped ? true : f.before !== after
74 const stats = f.skipped ? { added: 0, removed: 0 } : lineStats(f.before, after ?? '')
75 return {
76 path: f.rel,
77 absPath: f.abs,
78 isNew: f.isNew,
79 skipped: f.skipped,
80 tools: f.tools,
81 agents: f.agents,
82 changed,
83 added: stats.added,
84 removed: stats.removed,
85 }
86 })
87 const changedFiles = files.filter((f) => f.changed)
88 return {
89 turnId: turn.turnId,
90 title: turnTitle(turn.request),
91 request: turn.request,
92 startedAt: turn.startedAt,
93 files,
94 added: changedFiles.reduce((a, f) => a + f.added, 0),
95 removed: changedFiles.reduce((a, f) => a + f.removed, 0),
96 hasChanges: changedFiles.length > 0,
97 ...extra,
98 }
99}
100
101// 세션 목록에 올릴 턴 요약
102export function turnSummary(record) {
103 return {
104 seq: record.seq,
105 turnId: record.turnId,
106 title: record.title,
107 endedAt: record.endedAt,
108 files: record.files.filter((f) => f.changed).length,
109 added: record.added,
110 removed: record.removed,
111 agents: [...new Set(record.files.flatMap((f) => f.agents.map((a) => a.type || 'subagent')))],
112 understood: false,
113 }
114}
115
116// 저장 위치의 저장소 폴더 이름: 저장소 이름 + 경로 해시 8자리
117export async function repoFolder(root) {
118 const name = root.replace(/\/$/, '').split('/').pop() || 'root'
119 const bytes = new TextEncoder().encode(root)
120 const hash = await crypto.subtle.digest('SHA-1', bytes)
121 const hex = [...new Uint8Array(hash)].map((x) => x.toString(16).padStart(2, '0')).join('')
122 return name.replace(/[^A-Za-z0-9._-]/g, '_') + '-' + hex.slice(0, 8)
123}
124hooks/views/pan.js 121 lines1// 가로로 넓은 내용(시퀀스 다이어그램, diff 코드, 코드 줄)을 담는 창 (Client 모듈).
2// 휠과 트랙패드의 가로 움직임은 mod에 전달되지 않는다. 그래서 마우스로 끌거나 ←/→(h/l) 키로 가로 이동한다.
3// 클릭하면 이 창이 키를 받으므로, 쓰지 않는 키는 훅 모듈로 넘긴다(post). Esc로 창 단축키로 돌아간다.
4//
5// props = {
6// id: 'seq' | 'diff' | 'walk-3' … 훅 모듈이 위치를 기억하는 이름
7// left: 가로 위치 (창의 h/l 버튼이 바꾸면 따라간다)
8// rows: [{ segs: [{ fixed: parts, parts, bg }], sep } segs가 둘이면 반씩 나누고 sep 색의 │로 가른다
9// | { center: [text, color] }] 가운데 정렬, 밀지 않는다 (접힌 줄 표시 등)
10// hint: 넓을 때 아래에 보일 안내 색
11// }
12// part = [text, color, bold?, background?]. fixed는 밀지 않는 앞부분(줄 번호 등), parts만 민다.
13
14const STEP = 8
15
16// 한글 등 넓은 글자는 2칸
17function wide(c) {
18 return (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
19 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)
20}
21
22function cells(s) {
23 let n = 0
24 for (const ch of s) n += wide(ch.codePointAt(0)) ? 2 : 1
25 return n
26}
27
28const textOf = (parts) => (parts || []).map((p) => p[0]).join('')
29
30// 색 조각들의 앞에서 n글자를 버린다
31function shift(parts, n) {
32 const out = []
33 let rest = n
34 for (const p of parts || []) {
35 const t = p[0]
36 if (rest >= t.length) { rest -= t.length; continue }
37 out.push(rest ? [t.slice(rest), p[1], p[2], p[3]] : p)
38 rest = 0
39 }
40 return out
41}
42
43// 가장 많이 밀 수 있는 만큼: 가장 긴 줄이 창 끝에 닿을 때까지
44function maxLeftOf(rows, columns) {
45 let max = 0
46 for (const row of rows || []) {
47 if (!row.segs) continue
48 const width = row.segs.length > 1 ? Math.floor((columns - 1) / row.segs.length) : columns
49 for (const seg of row.segs) max = Math.max(max, cells(textOf(seg.parts)) - (width - cells(textOf(seg.fixed))))
50 }
51 return Math.max(0, max)
52}
53
54export default function Pan(props, surface) {
55 const { Box, Text } = surface.elements
56 const p = props || {}
57 let st = surface.state
58 if (!st) {
59 st = { left: p.left || 0, seen: p.left || 0, max: 0, drag: null }
60 const move = (left) => {
61 const s = surface.state
62 const next = Math.max(0, Math.min(s.max, left))
63 if (next === s.left) return
64 surface.setState({ ...s, left: next })
65 surface.post({ pan: s.id, left: next })
66 }
67 // 끌기: 누른 곳에서 오른쪽으로 끌면 내용이 따라 오른쪽으로 (왼쪽 내용이 보인다)
68 surface.onPointer((e) => {
69 const s = surface.state
70 if (!s) return
71 if (e.type === 'down' && e.button === 'left') surface.setState({ ...s, drag: { x: e.x, left: s.left } })
72 else if (e.type === 'move' && s.drag && e.button) move(s.drag.left - (e.x - s.drag.x))
73 else if (e.type === 'up' && s.drag) surface.setState({ ...surface.state, drag: null })
74 })
75 surface.onKey((e) => {
76 const s = surface.state
77 if (!s) return
78 if (e.key === 'right' || e.key === 'l') move(s.left + STEP)
79 else if (e.key === 'left' || e.key === 'h') move(s.left - STEP)
80 else if (e.key === 'home') move(0)
81 else if (e.key === 'end') move(s.max)
82 else surface.post({ pan: s.id, key: e.key })
83 })
84 surface.setState(st)
85 }
86 // 창의 h/l 버튼으로 위치가 바뀌었으면 따라간다
87 st.id = p.id
88 if ((p.left || 0) !== st.seen) {
89 st.seen = p.left || 0
90 st.left = st.seen
91 }
92 const columns = surface.columns || 80
93 st.max = maxLeftOf(p.rows, columns)
94 const left = Math.min(st.left, st.max)
95
96 const line = (parts) => Text({
97 wrap: 'truncate-end',
98 children: parts.map((q) => {
99 const t = { color: q[1], bold: !!q[2], children: [q[0]] }
100 if (q[3]) t.backgroundColor = q[3]
101 return Text(t)
102 }),
103 })
104 const out = (p.rows || []).map((row) => {
105 if (row.center) return Box({ justifyContent: 'center', children: [Text({ color: row.center[1], children: [row.center[0]] })] })
106 const width = row.segs.length > 1 ? Math.floor((columns - 1) / row.segs.length) : columns
107 const boxes = []
108 row.segs.forEach((seg, i) => {
109 if (i) boxes.push(Text({ color: row.sep, children: ['│'] }))
110 const b = { width, flexShrink: 0, children: [line([...(seg.fixed || []), ...shift(seg.parts, left)])] }
111 if (seg.bg) b.backgroundColor = seg.bg
112 boxes.push(Box(b))
113 })
114 return Box({ flexDirection: 'row', children: boxes })
115 })
116 if (st.max > 0 && p.hint) {
117 out.push(Text({ color: p.hint, wrap: 'truncate-end', children: ['끌거나 클릭 후 ←/→ 로 가로 이동' + (left ? ' · 가로 +' + left : '') + ' · Esc: 창 단축키로'] }))
118 }
119 return Box({ flexDirection: 'column', children: out })
120}
121hooks/views/diagrams.js 148 lines1// 흐름도(테두리 카드)와 시퀀스(레인 다이어그램). 모델이 준 구조를 mod가 칸을 계산해 그린다.
2
3import { C, rich, shift } from './common.js'
4
5// 색은 그릴 때 읽는다 (테마 설정이 C를 바꾸므로)
6const kindColor = (kind) => ({ step: C.faint, cond: C.blue, changed: C.accent, ok: C.green, err: C.red })[kind] || C.faint
7
8// ── 흐름도 ───────────────────────────────────────────────────
9// data: { nodes: [{ title, desc, kind, tag, next, branch }] }
10
11function card(el, item, n, width) {
12 const color = kindColor(item.kind)
13 const titleColor = item.kind === 'err' ? C.red : item.kind === 'ok' ? C.green : C.title
14 const rows = [rich(el, [n ? [n + ' ', C.accent, true] : '', [item.title, titleColor, true]])]
15 if (item.desc) rows.push(el.Text({ color: C.dim, children: [item.desc] }))
16 if (item.tag) rows.push(el.Text({ color: C.accent, children: [item.tag] }))
17 const props = { flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, children: rows }
18 if (width) props.width = width
19 return el.Box(props)
20}
21
22export function flowView(el, data, cols) {
23 const cardW = Math.min(34, cols - 2)
24 // 옆 가지 카드를 나란히 둘 공간이 없으면 카드 아래로 내린다
25 const wide = cols >= cardW + 34
26 const out = [
27 rich(el, [
28 ['■ ', C.accent], ['이번에 추가 ', C.dim],
29 ['■ ', C.blue], ['조건 ', C.dim],
30 ['■ ', C.green], ['정상 종료 ', C.dim],
31 ['■ ', C.red], ['에러', C.dim],
32 ]),
33 el.Text({ children: [' '] }),
34 ]
35 data.nodes.forEach((item, i) => {
36 const main = card(el, item, i + 1, cardW)
37 if (item.branch) {
38 const arrow = el.Text({ color: kindColor(item.branch.kind), children: [' ─ ' + (item.branch.label || '') + ' ─> '] })
39 const side = card(el, item.branch, 0)
40 out.push(
41 wide
42 ? el.Box({ flexDirection: 'row', alignItems: 'center', children: [main, arrow, side] })
43 : el.Box({
44 flexDirection: 'column',
45 children: [main, el.Box({ flexDirection: 'row', alignItems: 'center', paddingLeft: 3, children: [el.Text({ color: C.faint, children: ['└'] }), arrow, side] })],
46 }),
47 )
48 } else {
49 out.push(main)
50 }
51 if (i < data.nodes.length - 1) {
52 out.push(el.Box({ paddingLeft: Math.floor(cardW / 2), children: [rich(el, [['│', C.faint], item.next ? [' ' + item.next, C.dim] : ''])] }))
53 }
54 })
55 return el.Box({ flexDirection: 'column', children: out })
56}
57
58// ── 시퀀스 ───────────────────────────────────────────────────
59// data: { lanes: [{ name }], msgs: [{ from, to, label, kind, note }] }
60// 호출마다 위 줄에 번호와 라벨, 아래 줄에 화살표, 그 아래 흐린 설명.
61// 라벨과 설명은 줄 끝에만 둔다(한글 폭 때문에). 왼쪽 번호 칸은 고정, 나머지는 left만큼 가로로 민다.
62
63const GUTTER = 3
64const laneColor = (i) => [C.dim, C.fg, C.accent, C.blue, C.purple, C.green][i % 6]
65const msgColor = (kind) => ({ normal: C.fg, changed: C.accent, fail: C.red, ok: C.green })[kind] || C.fg
66
67function widthOf(s) {
68 let w = 0
69 for (const ch of s) {
70 const c = ch.codePointAt(0)
71 w += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
72 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) ? 2 : 1
73 }
74 return w
75}
76
77function geometry(data, cols) {
78 const laneW = Math.max(12, Math.min(20, Math.floor((cols - GUTTER) / data.lanes.length)))
79 const center = (i) => i * laneW + Math.floor(laneW / 2)
80 const width = laneW * data.lanes.length
81 let longest = width
82 data.msgs.forEach((m) => {
83 const start = Math.min(center(m.from), center(m.to)) + 2
84 longest = Math.max(longest, start + widthOf(m.label), start + widthOf(m.note || ''))
85 })
86 return { laneW, center, width, longest }
87}
88
89export function seqMaxLeft(data, cols) {
90 return Math.max(0, geometry(data, cols).longest - (cols - GUTTER))
91}
92
93// 창보다 넓으면 끌거나 ←/→ 로 가로 이동하는 창(pan.js)에 담는다
94export function seqView(el, data, cols, left) {
95 const { laneW, center, width } = geometry(data, cols)
96 const lifeline = () => {
97 const a = new Array(width).fill(' ')
98 data.lanes.forEach((_, i) => { a[center(i)] = '│' })
99 return a
100 }
101 const row = (gutter, parts) => ({ segs: [{ fixed: [[gutter, C.accent, true]], parts }] })
102 const paint = (cells, start, end, color) => [
103 [cells.slice(0, start).join(''), C.faint],
104 [cells.slice(start, end).join(''), color],
105 [cells.slice(end).join(''), C.faint],
106 ]
107 const textAt = (col, parts) => [[lifeline().slice(0, col).join(''), C.faint], ...parts]
108
109 // 참여자 머리 상자 세 줄. 아래 테두리의 ┬가 생명선과 같은 칸에 온다
110 const inner = laneW - 3
111 const colorOf = laneColor
112 const fit = (name) => (name.length > inner ? name.slice(0, inner - 1) + '…' : name)
113 const middle = (name) => {
114 const t = fit(name)
115 const padTotal = inner - t.length
116 return ' '.repeat(Math.floor(padTotal / 2)) + t + ' '.repeat(padTotal - Math.floor(padTotal / 2))
117 }
118 const boxLine = (fn) => data.lanes.map((l, i) => [fn(l).padEnd(laneW, ' '), colorOf(i), true])
119 const rows = [
120 row(' ', boxLine(() => '╭' + '─'.repeat(inner) + '╮')),
121 row(' ', boxLine((l) => '│' + middle(l.name) + '│')),
122 row(' ', boxLine(() => '╰' + '─'.repeat(Math.floor(laneW / 2) - 1) + '┬' + '─'.repeat(inner - Math.floor(laneW / 2)) + '╯')),
123 ]
124 data.msgs.forEach((m, i) => {
125 const color = msgColor(m.kind)
126 const l = Math.min(center(m.from), center(m.to))
127 const r = Math.max(center(m.from), center(m.to))
128 rows.push(row(String(i + 1).padStart(2, ' ') + ' ', textAt(l + 2, [[m.label, color, true]])))
129 const a = lifeline()
130 if (m.from === m.to) {
131 const c = center(m.from)
132 const b = lifeline()
133 a[c] = '├'; a[c + 1] = '─'; a[c + 2] = '╮'
134 b[c] = '│'; b[c + 1] = '<'; b[c + 2] = '╯'
135 rows.push(row(' ', paint(a, c, c + 3, color)))
136 rows.push(row(' ', paint(b, c + 1, c + 3, color)))
137 } else {
138 for (let k = l + 1; k < r; k++) a[k] = '─'
139 if (m.from < m.to) a[r - 1] = '>'
140 else a[l + 1] = '<'
141 rows.push(row(' ', paint(a, l + 1, r, color)))
142 }
143 if (m.note) rows.push(row(' ', textAt(l + 2, [[m.note, C.dim]])))
144 })
145 rows.push(row(' ', [[lifeline().join(''), C.faint]]))
146 return el.Client({ key: 'pan-seq', module: './pan.js', width: cols, props: { id: 'seq', left, rows, hint: C.faint } })
147}
148