SLOPSHOPPER

change-explainer

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

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-explainer
│ ┃ 변경 해설 ✕ › fix the failing auth test and add an audit log call │ ┃ 작업 목록 고르면 해설 창이 열립니다. 아직 │ ┃ 이해하지 않은 턴은 ○ ⏺ Read(src/auth.ts) │ ┃ 해설에 쓴 토큰 (이 저장소 누적) 아직 없음 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ r 다시 찾기 ] ⎿ Added 2 lines, removed 1 line │ ┃ ───────────────────────────────────────────… ⏺ Bash(bun test) │ ┃ 이 세션 ⎿ 3 pass, 1 fail │ ┃ 아직 기록된 변경이 없습니다. │ ┃ ───────────────────────────────────────────… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 지난 작업 │ ┃ 이 프로젝트의 지난 대화에서 바뀐 것이 ✻ Worked for 42s · done 4:20 PM │ ┃ 없습니다. │ › /explain │ ⎿ change-explainer: 이 세션에서 아직 기록된 변경이 없습니다. │ ⎿ change-explainer: Claude에게 파일을 고치게 하고, 그 턴이 끝난 뒤 /explain 을 입력하세 │ ⎿ change-explainer: (이 mod를 불러오기 전에 한 변경은 기록되지 않습니다) │ ⎿ change-explainer: 지난 작업은 /explain-list 로 볼 수 있습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 변경 해설
작업 목록 고르면 해설 창이 열립니다. 아직 이해하지 않은 턴은 ○ 해설에 쓴 토큰 (이 저장소 누적) 아직 없음 [ r 다시 찾기 ] ──────────────────────────────────────────────────────── 이 세션 아직 기록된 변경이 없습니다. ──────────────────────────────────────────────────────── 지난 작업 이 프로젝트의 지난 대화에서 바뀐 것이 없습니다.
README

change-explainer

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.

한국어 안내는 아래에 있습니다.

What it does

  • Records each turn's changes. Files edited with Edit/Write, and files changed through Bash (sed -i, cat >, git commit), including in other repositories and worktrees. Subagent changes go to the turn that started them.
  • Explains on demand. /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 |

  • Wait, what? If a section does not land, press it. Each press explains again in a different way (analogy, premises one by one, numbers, execution order), and you can ask a question about that section.
  • IntelliJ-style diff (d): side by side on wide panes, unified on narrow ones, changed words highlighted, only the code area scrolls.
  • Horizontal scrolling. Wide sequence diagrams, diff lines and code lines scroll sideways: drag them with the mouse, or click and use ←/→ (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.
  • Past work (/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.
  • Token usage. Every explanation records the tokens it used (input, cache reads, output). The pane shows the turn's total; the list shows the total for the repository.
  • Remembers what you learned. Terms you know and sections you got stuck on are saved per repository and shape later explanations.

Requirements

  • Claude Code 2.1.287 or later (tested on 2.1.293). Check with claude --version.
  • A terminal session (the CLI) or the Desktop app's Code tab. In 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.
  • Reading past sessions uses sh, grep and awk (macOS and Linux). On Windows the past-session list stays empty; live recording works.

Install

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.

Use

  1. Ask Claude to change some code, as usual.
  2. When the turn ends, type /explain.
  3. Press 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).

Settings

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. |

Where data goes, and what it costs

  • Everything is stored locally under ~/.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.
  • The mod runs with your permissions inside Claude Code. It reads your conversation files under ~/.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.
  • Model calls happen only when you open a section, press Wait, what? or ask a question. The summary is generated when the pane opens. Generated sections are cached and not regenerated unless you press r. Current-session explanations fork the conversation, so most of the prompt comes from the prompt cache.
  • Every call's token usage is shown in the pane (per turn) and in the list (per repository), and saved in 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.

Limitations

  • Bash changes are found through git: files outside a git repository, and changes whose paths do not appear in the command (a script that moves to another repository), are missed. Switching branches during a turn shows the branch difference as changes.
  • For past sessions, Bash changes are recovered only if they were committed; a commit belongs to the turn in which it was made.
  • The "why" is reconstructed from the conversation, not Claude's internal reasoning. Statements without support in the conversation are marked as guesses.
  • The pane labels are Korean. Explanations follow the language setting.

Development

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.

License

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 이상이 필요합니다.

사용

  • Claude가 코드를 고친 뒤 /explain: 그 턴의 해설 창이 열립니다.
  • 1~9: 섹션 펼치기 (펼칠 때 생성)
  • d: diff
  • t: 작업 목록
  • Esc: 닫기
  • /explain은 항상 지금 세션을 해설합니다.
  • /explain-list: 이 세션의 턴과 지난 작업 목록 (해설 창에서는 t). 지난 대화에서 실제로 무언가 바꾼 턴이 최근 것부터 한 줄씩(커밋 메시지나 바꾼 파일 이름으로) 나오고, 고르면 해설 창이 열립니다. 저장소 경로나 기준 브랜치 같은 설정은 필요 없습니다.
  • 막히면 Wait, what?: 누를 때마다 다른 방식으로 다시 설명하고, 그 부분만 따로 질문할 수 있습니다.
  • 퀴즈를 다 맞히면 그 턴이 "이해함"으로 남습니다.

설정 (/plugin configure change-explainer@change-explainer)

  • language: 해설 언어 (ko/en)
  • theme: 터미널 테마 (dark/light)
  • model: 지난 세션 해설 모델 (비용을 줄이려면 sonnet이나 haiku)

자세한 내용은 위 영어 표를 참고하세요.

데이터와 비용

  • 모든 기록은 ~/.claude/explanations/에만 저장됩니다.
  • 모델은 섹션을 펼치거나 질문할 때만 호출하고, 만든 결과는 캐시합니다.
  • 해설에 쓴 토큰은 해설 창(턴별)과 작업 목록(저장소 누적)에 보입니다.
  • git은 필수가 아닙니다. 없으면 Bash로 바꾼 파일만 놓치고, Edit/Write 변경은 그대로 해설합니다.
Source 12 files
hooks/register.js 1216 lines
1// 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 lines
1// 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}
56
hooks/history.js 186 lines
1// 지난 세션 복원: 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}
186
hooks/diff.js 186 lines
1// 줄 단위 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}
186
hooks/generate.js 346 lines
1// 해설 생성: 모델에 보낼 프롬프트와, 돌아온 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}
346
hooks/views/common.js 103 lines
1// 화면 공통 도우미. `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}
103
hooks/views/diff.js 206 lines
1// 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}
206
hooks/views/explain.js 454 lines
1// 해설 창. 섹션 데이터는 모델이 만든 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}
454
hooks/views/list.js 69 lines
1// 작업 목록 창: 이 세션의 턴과 지난 작업. 한 줄 = 바뀐 턴 하나 (세션으로 묶지 않는다).
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}
69
hooks/turns.js 124 lines
1// 턴 기록을 다루는 순수 함수. 파일 읽기·쓰기는 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}
124
hooks/views/pan.js 121 lines
1// 가로로 넓은 내용(시퀀스 다이어그램, 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}
121
hooks/views/diagrams.js 148 lines
1// 흐름도(테두리 카드)와 시퀀스(레인 다이어그램). 모델이 준 구조를 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