SLOPSHOPPER

explain-preview

change-explainer 해설 창 디자인 확인용 샘플 (고정 데이터, 모델 호출 없음)

newpanecommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explain-preview
│ ┃ 변경 해설 ✕ › fix the failing auth test and add an audit log call │ ┃ #5 로그인 실패 시 재시도 추가 │ ┃ 파일 3개 +42 −2 · 서브에이전트 1 · 오늘… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 요청 로그인 API가 가끔 타임아웃 나는데 ⏺ Update(src/auth.ts) │ ┃ 재시도 좀 넣어줘 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ 한 줄 요약 │ │ ┃ │ 네트워크 오류와 5xx 응답일 때만 로그인 │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 요청을 최대 3번 재시도하도록 바꿨습니다. │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ d diff ] [ a 모두 펼치기 ] [ r 다시 만들기 › /explain-sample │ ┃ │ ┃ 1 요약 · 2 배경 · 3 코드 따라가기 · 4 │ ┃ │ ┃ ───────────────────────────────────────────… │ ┃ [ 1 요약 ▾ ] [ Wait, what? ] ● 생성됨 │ ┃ │ ┃ 무엇을 바꿨나 │ ┃ [ src/auth/login.ts → diff ] │ ┃ 수정 +6 −2 api 호출을 retry()로 │ ┃ 감싸고 로그 한 줄 제거 │ ┃ [ src/lib/retry.ts → diff ] │ ┃ 새 파일 +18 retry(), isRetryable() ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 변경 해설
#5 로그인 실패 시 재시도 추가 파일 3개 +42 −2 · 서브에이전트 1 · 오늘 14:02 · ○ 아… 요청 로그인 API가 가끔 타임아웃 나는데 재시도 좀 넣어줘 ╭──────────────────────────────────────────────────────────╮ │ 한 줄 요약 │ │ 네트워크 오류와 5xx 응답일 때만 로그인 요청을 최대 3번 │ │ 재시도하도록 바꿨습니다. │ ╰──────────────────────────────────────────────────────────╯ [ d diff ] [ a 모두 펼치기 ] [ r 다시 만들기 ] [ q 퀴즈 ] 1 요약 · 2 배경 · 3 코드 따라가기 · 4 흐름도 · 5 시퀀 ──────────────────────────────────────────────────────── [ 1 요약 ▾ ] [ Wait, what? ] ● 생성됨 무엇을 바꿨나 [ src/auth/login.ts → diff ] 수정 +6 −2 api 호출을 retry()로 감싸고 로그 한 줄 제거 [ src/lib/retry.ts → diff ] 새 파일 +18 retry(), isRetryable() 헬퍼 [ src/auth/login.test.ts → diff ] 새 파일 +18 서브에이전트 재시도 테스트 3개 파일을 누르면 그 파일의 diff로 이동합니다. 왜 이렇게 했나 일시적인 실패(네트워크 오류, 5xx)만 재시도하고, 401 같은 인증 실패는 즉시 실패시켰습니다. 잘못된 비밀번호를 세 번 보내면 계정 잠금 정책에 걸릴 수 있기 때문입니다. 어떻게 동작하나 1 retry(fn, { times: 3, when }) 실패하면 200ms, 400ms 기다렸다가 다시 호출합니다. 2 isRetryable(err) 네트워크 오류이거나 status가 500 이상이면 재시도 대상입니다. 3 마지막까지 실패하면 원래 에러를 그대로 던집니다. 호출부의 에러 처리는 바뀌지 않습니다. 검토할 점 ! 최악의 경우 로그인 응답이 약 1.4초 늦어집니다. 로딩 표시를 확인하세요. ? 타임아웃 값 자체는 그대로입니다. 원인이 긴 타임아웃이면 별도 조치가 필요합니다. ──────────────────────────────────────────────────────── [ 2 배경 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 3 코드 따라가기 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 4 흐름도 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 5 시퀀스 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 6 전후 비교 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 7 영향 범위 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 8 용어 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── [ 9 확인 퀴즈 ▸ ] ○ 펼치면 생성 ──────────────────────────────────────────────────────── 질문하기 질문: 이 변경에 대해 궁금한 점을 입력하고 Enter ⏎ 묻기 본 대화에는 남지 않습니다. ──────────────────────────────────────────────────────── 휠·↑↓·PgUp/PgDn 위아래 목차로 섹션 이동 1–9 펼치기·접기 Esc 닫기 대화 기록을 바탕으로 재구성한 해설입니다. 실제 내부 추론과 다를 수 있습니다.
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 3 files
hooks/register.js 465 lines
1// explain-preview: change-explainer 해설 창의 디자인을 터미널에서 확인하기 위한 샘플.
2// 고정된 예시 데이터(로그인 재시도 추가)로 그리며, 모델을 호출하지 않는다.
3// "만드는 중"과 질문 답변은 잠깐 기다리는 것만 흉내 낸다.
4
5import { C, EASY, EASY_AGAIN, EASY_AGAIN_DEFAULT, EASY_ASK_AFTER, FILES, HUNKS, QUIZ, SAMPLE_ANSWER, SECTION_ANSWER, SECTIONS, TURN } from './data.js'
6import {
7  backgroundView, baView, btn, buttonRow, link, diffCodeRows, diffModel, diffView, flowView, impactView, quizView, rich, rule,
8  seqMaxLeft, seqView, summaryView, termsView, walkView,
9} from './views.js'
10
11const PANE = 'explain-preview'
12const WANT_COLUMNS = 100
13const FAKE_DELAY_MS = 700
14const STEP_ROWS = 5
15const STEP_COLS = 8
16
17// 화면: 'explain'(해설) 또는 'diff'
18let mode = 'explain'
19// diff: 보고 있는 변경 블록(HUNKS 위치), 코드 영역의 위쪽 줄과 가로 밀림
20let pos = 0
21let diffTop = 0
22let diffLeft = 0
23// 마지막으로 그린 diff 코드 영역의 크기: 휠 스크롤의 한계를 정할 때 쓴다
24let diffBounds = { maxTop: 0 }
25// 시퀀스 다이어그램의 가로 밀림
26let seqLeft = 0
27// 섹션별 펼침 여부(다음에도 기억)와 생성 상태(none → loading → done)
28let open = { summary: true }
29let gen = Object.fromEntries(SECTIONS.map((s) => [s.id, s.id === 'summary' ? 'done' : 'none']))
30// 섹션별 Wait, what? 대화: { [섹션 id]: [{ type: 'easy', text } | { type: 'q', q, a }] }
31// text나 a가 null이면 만드는 중
32let easy = {}
33// 이미 아는 용어 id 목록 (다음에도 기억): 다음 해설에서는 짧게 넘어간다
34let known = []
35// 확인 퀴즈: 문제별 보기 순서(섞은 결과)와 고른 보기. 보기 원래 위치 0이 정답
36let quiz = newQuiz()
37// 마지막으로 펼친 섹션: r(다시 만들기)의 대상
38let current = 'summary'
39// 퀴즈를 다 맞혀서 이 턴을 이해했다고 표시했는지 (다음에도 기억)
40let understood = false
41// 질문과 답변: [{ q, a }], a가 null이면 답을 만드는 중
42let qa = []
43
44
45// 보기 순서를 섞는다. 모델은 정답을 첫 번째에 두는 버릇이 있어서 순서는 코드가 정한다
46function shuffled(n) {
47  const a = Array.from({ length: n }, (_, i) => i)
48  for (let i = n - 1; i > 0; i--) {
49    const j = Math.floor(Math.random() * (i + 1))
50    ;[a[i], a[j]] = [a[j], a[i]]
51  }
52  return a
53}
54
55function newQuiz() {
56  return { order: QUIZ.map((q) => shuffled(q.options.length)), picked: QUIZ.map(() => null) }
57}
58const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n))
59
60// 섹션 생성을 흉내 낸다. 실제 mod에서는 여기서 $.model.fork로 구조를 받아 온다
61function generate($, id) {
62  gen = { ...gen, [id]: 'loading' }
63  $.ui.invalidate('ui.render')
64  $.clock.after(FAKE_DELAY_MS, () => {
65    gen = { ...gen, [id]: 'done' }
66    if (id === 'quiz') quiz = newQuiz()
67    $.ui.invalidate('ui.render')
68  })
69}
70
71// 섹션 대화의 i번째 항목을 채운다
72function fill(id, i, patch) {
73  easy = { ...easy, [id]: easy[id].map((x, j) => (j === i ? { ...x, ...patch } : x)) }
74}
75
76// Wait, what?: 같은 내용을 더 쉬운 말로, 빠진 전제를 채워 다시 설명한다 (짧게 줄이는 것이 아님).
77// 여러 번 누를 수 있고, 두 번째부터는 다른 비유로 설명한다
78function explainEasier($, id) {
79  const thread = easy[id] || []
80  const round = thread.filter((x) => x.type === 'easy').length
81  const i = thread.length
82  easy = { ...easy, [id]: [...thread, { type: 'easy', text: null }] }
83  $.ui.invalidate('ui.render')
84  $.clock.after(FAKE_DELAY_MS, () => {
85    const again = EASY_AGAIN[id] || []
86    fill(id, i, { text: round === 0 ? EASY[id] : again[round - 1] || EASY_AGAIN_DEFAULT })
87    $.ui.invalidate('ui.render')
88  })
89}
90
91// 섹션 안에서 추가 질문: 답은 그 섹션 대화에 이어 붙는다
92function askInSection($, id, q) {
93  const thread = easy[id] || []
94  const i = thread.length
95  easy = { ...easy, [id]: [...thread, { type: 'q', q, a: null }] }
96  $.ui.invalidate('ui.render')
97  $.clock.after(FAKE_DELAY_MS, () => {
98    fill(id, i, { a: SECTION_ANSWER })
99    $.ui.invalidate('ui.render')
100  })
101}
102
103function statusOf(id) {
104  if (gen[id] === 'done') return ['● 생성됨', C.green]
105  if (gen[id] === 'loading') return ['◌ 만드는 중', C.accent]
106  return ['○ 펼치면 생성', C.faint]
107}
108
109export function register(on) {
110  on('session.start', async ($, e, next) => {
111    // 지난번에 펼쳐 둔 섹션과 이해함 표시를 불러온다
112    const savedOpen = await $.store.get('open')
113    if (savedOpen && typeof savedOpen === 'object') open = { ...savedOpen, summary: savedOpen.summary !== false }
114    understood = (await $.store.get('understood')) === true
115    const savedKnown = await $.store.get('known')
116    if (Array.isArray(savedKnown)) known = savedKnown
117    try {
118      await $.command.register({
119        name: 'explain-sample',
120        description: '변경 해설 창 디자인 샘플 열기',
121        immediate: true,
122      })
123    } catch (err) {
124      $.ui.log('/explain-sample 등록 실패: ' + err)
125    }
126    return next(e)
127  })
128
129  on('command.run', { command: 'explain-sample' }, async ($) => {
130    mode = 'explain'
131    // 기억해 둔 섹션은 창을 열 때 바로 만든다 (사용자가 늘 보는 섹션이라서)
132    SECTIONS.forEach((s) => { if (open[s.id] && gen[s.id] === 'none') generate($, s.id) })
133    await $.ui.open({ id: PANE, title: '변경 해설', focus: true, closeOnEscape: true, columns: WANT_COLUMNS })
134    return {}
135  })
136
137  // diff 화면에서는 휠과 스크롤 키로 창 전체가 아니라 코드 영역만 움직인다
138  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
139    if (mode !== 'diff') return next(e)
140    diffTop = clamp(diffTop + e.by, 0, diffBounds.maxTop)
141    $.ui.invalidate('ui.render')
142    return {}
143  })
144
145  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
146    if (e.requestId !== PANE) return next(e)
147    const el = $.ui.resolve(e)
148    const cols = e.props.bodyColumns || 80
149    const bodyRows = (e.props.scroll && e.props.scroll.bodyRows) || 30
150    const redraw = () => $.ui.invalidate('ui.render')
151    const saveOpen = () => { $.store.set('open', open).catch(() => {}) }
152    // 스크롤할 수 없는 곳(테스트 등)에서는 그냥 둔다
153    const reveal = (key) => { $.ui.scroll({ in: PANE, to: { key }, block: 'start' }).catch(() => {}) }
154
155    const toggle = (id, show) => {
156      open = { ...open, [id]: !open[id] }
157      if (open[id]) {
158        current = id
159        if (gen[id] === 'none') generate($, id)
160      }
161      saveOpen()
162      redraw()
163      if (show && open[id]) reveal('sec-' + id)
164    }
165
166    // 변경 블록으로 이동: 그 블록이 코드 영역 위쪽에 오도록 맞춘다
167    const goTo = (n) => {
168      pos = clamp(n, 0, HUNKS.length - 1)
169      diffTop = Math.max(0, diffModel(cols, pos).hunkStart - 2)
170      diffLeft = 0
171      redraw()
172    }
173    const showDiff = (fileIdx) => {
174      mode = 'diff'
175      goTo(fileIdx === undefined ? pos : HUNKS.findIndex((h) => h.f === fileIdx))
176      $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
177    }
178
179    // ── diff 화면 ──
180    if (mode === 'diff') {
181      const m = diffModel(cols, pos)
182      diffBounds = { maxTop: Math.max(0, m.lines.length - diffCodeRows(bodyRows)) }
183      diffTop = clamp(diffTop, 0, diffBounds.maxTop)
184      diffLeft = clamp(diffLeft, 0, m.maxLeft)
185      return diffView(el, cols, bodyRows, { pos, top: diffTop, left: diffLeft }, {
186        prev: () => goTo(pos - 1),
187        next: () => goTo(pos + 1),
188        nextFile: () => showDiff((HUNKS[pos].f + 1) % FILES.length),
189        pickFile: (i) => showDiff(i),
190        back: () => { mode = 'explain'; redraw() },
191        up: () => { diffTop = clamp(diffTop - STEP_ROWS, 0, diffBounds.maxTop); redraw() },
192        down: () => { diffTop = clamp(diffTop + STEP_ROWS, 0, diffBounds.maxTop); redraw() },
193        leftward: () => { diffLeft = clamp(diffLeft - STEP_COLS, 0, m.maxLeft); redraw() },
194        rightward: () => { diffLeft = clamp(diffLeft + STEP_COLS, 0, m.maxLeft); redraw() },
195      })
196    }
197
198    // ── 해설 화면 ──
199    const allOpen = SECTIONS.every((s) => open[s.id])
200
201    const header = el.Box({
202      flexDirection: 'column',
203      children: [
204        rich(el, [['#' + TURN.n + '  ', C.accent, true], [TURN.title, C.title, true]]),
205        rich(el, [
206          ['파일 ' + FILES.length + '개  ', C.dim],
207          ['+' + FILES.reduce((a, f) => a + f.add, 0), C.green], ' ',
208          ['−' + FILES.reduce((a, f) => a + f.del, 0), C.red],
209          ['  ·  서브에이전트 1  ·  ' + TURN.when + '  ·  ', C.dim],
210          understood ? ['✓ 이해함 (퀴즈 통과)', C.green, true] : ['○ 아직 확인 안 함', C.faint],
211        ], { wrap: 'truncate-end' }),
212        el.Text({ children: [' '] }),
213        rich(el, [['요청 ', C.faint], [TURN.request, C.dim]]),
214        el.Box({
215          marginTop: 1,
216          borderStyle: 'round',
217          borderColor: C.accent,
218          paddingX: 1,
219          flexDirection: 'column',
220          children: [
221            el.Text({ bold: true, color: C.accent, children: ['한 줄 요약'] }),
222            el.Text({ color: C.title, children: [TURN.tldr] }),
223          ],
224        }),
225        el.Box({ marginTop: 1, children: [buttonRow(el, [
226          btn(el, { key: 'diff', hotkey: 'd', label: 'diff', primary: true, onPress: () => showDiff() }),
227          btn(el, { key: 'all', hotkey: 'a', label: allOpen ? '모두 접기' : '모두 펼치기', onPress: () => {
228            const target = !allOpen
229            SECTIONS.forEach((s) => { if (!!open[s.id] !== target) toggle(s.id, false) })
230          } }),
231          btn(el, { key: 'regen', hotkey: 'r', label: '다시 만들기', dim: gen[current] === 'loading', onPress: () => {
232            if (gen[current] === 'loading') return
233            if (!open[current]) toggle(current, true)
234            else generate($, current)
235          } }),
236          btn(el, { key: 'go-quiz', hotkey: 'q', label: '퀴즈', onPress: () => {
237            if (!open.quiz) toggle('quiz', true)
238            else reveal('sec-quiz')
239          } }),
240        ])] }),
241        // 목차: 대괄호 없는 글자 링크. 펼친 섹션은 밝게, 접힌 섹션은 흐리게. 누르면 펼치고 그 위치로 이동
242        el.Box({
243          marginTop: 1,
244          flexDirection: 'row',
245          flexWrap: 'wrap',
246          children: SECTIONS.flatMap((sec, i) => [
247            ...(i ? [el.Text({ color: C.faint, children: ['  ·  '] })] : []),
248            link(el, {
249              key: 'toc-' + sec.id,
250              label: sec.key + ' ' + sec.title,
251              dim: !open[sec.id],
252              onPress: () => {
253                if (!open[sec.id]) toggle(sec.id, true)
254                else reveal('sec-' + sec.id)
255              },
256            }),
257          ]),
258        }),
259      ],
260    })
261
262    const body = []
263    SECTIONS.forEach((s) => {
264      const [status, statusColor] = statusOf(s.id)
265      body.push(rule(el, cols))
266      body.push(
267        el.Box({
268          key: 'sec-' + s.id,
269          flexDirection: 'row',
270          justifyContent: 'space-between',
271          children: [
272            btn(el, { key: 'btn-' + s.id, hotkey: s.key, label: s.title + (open[s.id] ? '  ▾' : '  ▸'), onPress: () => toggle(s.id, true) }),
273            el.Box({
274              flexDirection: 'row',
275              columnGap: 2,
276              children: [
277                ...(open[s.id] && gen[s.id] === 'done' && !easy[s.id]
278                  ? [btn(el, { key: 'easy-' + s.id, label: 'Wait, what?', primary: true, onPress: () => explainEasier($, s.id) })]
279                  : []),
280                el.Text({ color: statusColor, children: [status] }),
281              ],
282            }),
283          ],
284        }),
285      )
286      // Wait, what? 대화 상자: 쉬운 설명들과 추가 질문이 이어진다
287      if (open[s.id] && easy[s.id]) {
288        const thread = easy[s.id]
289        const busy = thread.some((x) => (x.type === 'easy' ? x.text === null : x.a === null))
290        // 여러 번 설명해도 모르겠다면, 더 설명하기보다 어디가 막히는지 듣는 편이 낫다
291        const rounds = thread.filter((x) => x.type === 'easy').length
292        const askFirst = rounds >= EASY_ASK_AFTER
293        let round = 0
294        body.push(el.Box({
295          key: 'easy-box-' + s.id,
296          marginLeft: 2,
297          marginTop: 1,
298          borderStyle: 'round',
299          borderColor: C.blue,
300          paddingX: 1,
301          flexDirection: 'column',
302          rowGap: 1,
303          children: [
304            el.Text({ bold: true, color: C.blue, children: ['Wait, what?  쉬운 말로 다시 설명'] }),
305            ...thread.map((x) => {
306              if (x.type === 'easy') {
307                round += 1
308                return el.Box({
309                  flexDirection: 'column',
310                  children: [
311                    el.Text({ bold: true, color: C.blue, children: ['설명 ' + round] }),
312                    x.text === null
313                      ? el.Text({ color: C.dim, children: ['쉬운 말로 다시 설명하는 중…'] })
314                      : el.Text({ children: [x.text] }),
315                  ],
316                })
317              }
318              return el.Box({
319                flexDirection: 'column',
320                children: [
321                  rich(el, [['Q  ', C.accent, true], [x.q, C.title]]),
322                  x.a === null
323                    ? el.Text({ color: C.dim, children: ['   답을 만드는 중…'] })
324                    : rich(el, [['A  ', C.green, true], [x.a, C.fg]]),
325                ],
326              })
327            }),
328            askFirst
329              ? rich(el, [['세 번 설명했는데도 막힌다면 ', C.dim], ['어느 문장, 어느 단어', C.accent, true], ['에서 막히는지 아래에 적어 주세요. 그 지점부터 다시 설명합니다.', C.dim]])
330              : el.Text({ children: [''] }),
331            buttonRow(el, [
332              btn(el, {
333                key: 'easy-again-' + s.id,
334                label: askFirst ? 'Wait, what?  그래도 한 번 더' : 'Wait, what?  아직 모르겠어요',
335                primary: !askFirst,
336                dim: busy || askFirst,
337                onPress: () => { if (!busy) explainEasier($, s.id) },
338              }),
339            ]),
340            el.Input(Object.assign({
341              key: 'easy-ask-' + s.id,
342              label: '이 부분 질문',
343              placeholder: '어디가 막히는지 적고 Enter',
344              value: '',
345              submitLabel: '묻기',
346              onSubmit: (value) => {
347                const q = value.trim()
348                if (q) askInSection($, s.id, q)
349              },
350            }, askFirst ? { autoFocus: true } : {})),
351          ],
352        }))
353      }
354      if (open[s.id] && gen[s.id] === 'loading') {
355        body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [el.Text({ color: C.dim, children: ['현재 대화를 바탕으로 만드는 중…'] })] }))
356      }
357      if (open[s.id] && gen[s.id] === 'done') {
358        const inner = cols - 2
359        let view
360        if (s.id === 'summary') view = summaryView(el, (i) => showDiff(i))
361        else if (s.id === 'background') view = backgroundView(el)
362        else if (s.id === 'walk') view = walkView(el)
363        else if (s.id === 'terms') {
364          view = termsView(el, known, (id) => {
365            known = known.includes(id) ? known.filter((x) => x !== id) : [...known, id]
366            redraw()
367            $.store.set('known', known).catch(() => {})
368          })
369        } else if (s.id === 'quiz') {
370          view = quizView(el, quiz, {
371            pick: (i, orig) => {
372              quiz = { ...quiz, picked: quiz.picked.map((x, j) => (j === i ? orig : x)) }
373              const allRight = quiz.picked.every((x) => x === 0)
374              if (allRight && !understood) {
375                understood = true
376                $.store.set('understood', true).catch(() => {})
377                $.ui.toast('#' + TURN.n + ' 턴을 이해함으로 표시했습니다')
378              }
379              redraw()
380            },
381            retry: () => { quiz = newQuiz(); redraw() },
382          })
383        } else if (s.id === 'flow') view = flowView(el, inner)
384        else if (s.id === 'ba') view = baView(el)
385        else if (s.id === 'impact') view = impactView(el)
386        else {
387          // 시퀀스: 창보다 넓으면 좌우로 밀어 볼 수 있다
388          const maxLeft = seqMaxLeft(inner)
389          seqLeft = clamp(seqLeft, 0, maxLeft)
390          const diagram = seqView(el, inner, seqLeft)
391          view = maxLeft === 0 ? diagram : el.Box({
392            flexDirection: 'column',
393            rowGap: 1,
394            children: [
395              buttonRow(el, [
396                btn(el, { key: 'seq-left', hotkey: 'h', label: '왼쪽', dim: seqLeft === 0, onPress: () => { seqLeft = clamp(seqLeft - STEP_COLS, 0, maxLeft); redraw() } }),
397                btn(el, { key: 'seq-right', hotkey: 'l', label: '오른쪽', dim: seqLeft >= maxLeft, onPress: () => { seqLeft = clamp(seqLeft + STEP_COLS, 0, maxLeft); redraw() } }),
398                el.Text({ color: C.faint, children: [' 창보다 넓은 다이어그램' + (seqLeft ? '  ·  가로 +' + seqLeft : '')] }),
399              ]),
400              diagram,
401            ],
402          })
403        }
404        body.push(el.Box({ paddingLeft: 2, paddingY: 1, children: [view] }))
405      }
406    })
407    body.push(rule(el, cols))
408
409    // ── 질문하기 ──
410    const ask = el.Box({
411      flexDirection: 'column',
412      rowGap: 1,
413      children: [
414        el.Text({ bold: true, color: C.accent, children: ['질문하기'] }),
415        ...qa.map((x, i) =>
416          el.Box({
417            key: 'qa-' + i,
418            flexDirection: 'column',
419            paddingLeft: 2,
420            children: [
421              rich(el, [['Q  ', C.accent, true], [x.q, C.title]]),
422              x.a === null
423                ? el.Text({ color: C.dim, children: ['   답을 만드는 중…'] })
424                : rich(el, [['A  ', C.green, true], [x.a, C.fg]]),
425            ],
426          }),
427        ),
428        el.Input({
429          key: 'ask',
430          label: '질문',
431          placeholder: '이 변경에 대해 궁금한 점을 입력하고 Enter',
432          value: '',
433          submitLabel: '묻기',
434          onSubmit: (value) => {
435            const q = value.trim()
436            if (!q) return
437            const i = qa.length
438            qa = [...qa, { q, a: null }]
439            redraw()
440            $.ui.scroll({ in: PANE, to: { key: 'qa-' + i }, block: 'nearest' }).catch(() => {})
441            $.clock.after(FAKE_DELAY_MS, () => {
442              qa = qa.map((x, j) => (j === i ? { ...x, a: SAMPLE_ANSWER } : x))
443              $.ui.invalidate('ui.render')
444            })
445          },
446        }),
447        el.Text({ color: C.faint, children: ['본 대화에는 남지 않습니다.'] }),
448      ],
449    })
450
451    return el.Box({
452      flexDirection: 'column',
453      children: [
454        header,
455        el.Text({ children: [' '] }),
456        ...body,
457        ask,
458        rule(el, cols),
459        el.Text({ color: C.faint, children: ['휠·↑↓·PgUp/PgDn 위아래  목차로 섹션 이동  1–9 펼치기·접기  Esc 닫기'] }),
460        el.Text({ color: C.faint, children: ['대화 기록을 바탕으로 재구성한 해설입니다. 실제 내부 추론과 다를 수 있습니다.'] }),
461      ],
462    })
463  })
464}
465
hooks/data.js 307 lines
1// 샘플 데이터: "로그인 실패 시 재시도 추가" 턴 하나.
2// 실제 mod에서는 이 모양의 데이터를 모델(구조)과 diff 계산(코드)이 채운다.
3
4export const C = {
5  fg: '#d6d6d6',
6  title: '#f0f0f0',
7  dim: '#858b94',
8  faint: '#5c6370',
9  rule: '#2f3238',
10  accent: '#e3a857',
11  blue: '#79b8ff',
12  green: '#7fc79a',
13  red: '#f08c7c',
14  purple: '#c3a6ff',
15}
16
17// diff 배경색 (인텔리제이 다크 테마 계열)
18export const D = {
19  add: '#1f3a28', addHi: '#2f6e41',
20  mod: '#1c2c43', modHi: '#2f5a8f', modFill: '#151d29',
21  del: '#2a2c31', fill: '#16171a',
22  rem: '#3a2326', remHi: '#6e2f36',
23}
24
25export const SECTIONS = [
26  { id: 'summary', key: '1', title: '요약' },
27  { id: 'background', key: '2', title: '배경' },
28  { id: 'walk', key: '3', title: '코드 따라가기' },
29  { id: 'flow', key: '4', title: '흐름도' },
30  { id: 'seq', key: '5', title: '시퀀스' },
31  { id: 'ba', key: '6', title: '전후 비교' },
32  { id: 'impact', key: '7', title: '영향 범위' },
33  { id: 'terms', key: '8', title: '용어' },
34  { id: 'quiz', key: '9', title: '확인 퀴즈' },
35]
36
37export const TURN = {
38  n: 5,
39  title: '로그인 실패 시 재시도 추가',
40  request: '로그인 API가 가끔 타임아웃 나는데 재시도 좀 넣어줘',
41  when: '오늘 14:02',
42  tldr: '네트워크 오류와 5xx 응답일 때만 로그인 요청을 최대 3번 재시도하도록 바꿨습니다.',
43}
44
45export const FLOW = [
46  { n: 1, title: 'login(u, p)', desc: 'LoginPage에서 호출', kind: 'step' },
47  { n: 2, title: 'api(u, p) 호출', desc: '5번에서 재시도하면 여기로 돌아옴', kind: 'step' },
48  {
49    n: 3, title: 'res.ok ?', desc: '응답이 성공인지', kind: 'cond', next: '아니오',
50    branch: { label: '예', title: '응답 반환', desc: '정상 종료', kind: 'ok' },
51  },
52  {
53    n: 4, title: '재시도 대상인가?', desc: '네트워크 오류 또는 5xx', tag: '이번에 추가 · isRetryable()', kind: 'changed', next: '예',
54    branch: { label: '아니오', title: '에러 그대로 던짐', desc: '401 등 인증 실패', kind: 'err' },
55  },
56  {
57    n: 5, title: '시도 횟수 < 3 ?', desc: '최대 3번까지', tag: '이번에 추가 · retry()', kind: 'changed', next: '아니오',
58    branch: { label: '예', title: '200ms × 2ⁿ 대기', desc: '그다음 2번으로 돌아감', kind: 'changed' },
59  },
60  { n: 6, title: '마지막 에러를 던짐', desc: '호출부의 기존 에러 처리로 전달', kind: 'err' },
61]
62
63export const KIND_COLOR = { step: C.faint, cond: C.blue, changed: C.accent, ok: C.green, err: C.red }
64
65export const LANES = [
66  { name: 'LoginPage', color: C.dim },
67  { name: 'login()', color: C.fg },
68  { name: 'retry()', color: C.accent },
69  { name: 'Auth API', color: C.blue },
70]
71
72// [from, to, label, color, note]
73export const MSGS = [
74  [0, 1, 'submit(u, p)', C.fg, ''],
75  [1, 2, 'retry(fn, 3)', C.accent, '이번에 추가된 재시도 래퍼'],
76  [2, 3, '1차 호출', C.fg, ''],
77  [3, 2, '503', C.red, '서버 과부하로 실패'],
78  [2, 2, '200ms 대기', C.accent, '5xx라서 재시도 대상'],
79  [2, 3, '2차 호출', C.fg, ''],
80  [3, 2, '200 OK', C.green, '성공'],
81  [2, 1, 'res', C.fg, ''],
82  [1, 0, 'ok', C.fg, '사용자는 재시도를 모름'],
83]
84
85export const BEFORE_AFTER = [
86  { when: '네트워크 끊김', before: '즉시 에러', after: '최대 3회 재시도 후 에러', color: C.green },
87  { when: '서버 503', before: '즉시 에러', after: '재시도, 보통 2차에서 성공', color: C.green },
88  { when: '비밀번호 틀림 (401)', before: '즉시 에러', after: '즉시 에러 (변화 없음)', color: C.dim },
89  { when: '최악 응답 시간', before: '약 0.3초', after: '약 1.7초', color: C.red },
90]
91
92const added = (lines) => lines.map((t, i) => ['add', null, '', i + 1, t, 1])
93
94// diff 행: ['eq'|'add'|'del'|'mod', 왼쪽 줄번호, 왼쪽 글, 오른쪽 줄번호, 오른쪽 글, 변경 블록 번호, 왼쪽 강조, 오른쪽 강조]
95// ['fold', 설명]: 변경 없는 줄 접힘
96export const FILES = [
97  {
98    name: 'login.ts', path: 'src/auth/login.ts', tag: '수정', tagColor: C.blue, add: 6, del: 2,
99    desc: 'api 호출을 retry()로 감싸고 로그 한 줄 제거',
100    rows: [
101      ['fold', '변경 없는 9줄'],
102      ['eq', 10, "import { api } from '../lib/api'", 10, "import { api } from '../lib/api'"],
103      ['add', null, '', 11, "import { retry, isRetryable } from '../lib/retry'", 1],
104      ['eq', 11, '', 12, ''],
105      ['eq', 12, 'export async function login(u: string, p: string) {', 13, 'export async function login(u: string, p: string) {'],
106      ['mod', 13, "  const res = await api.post('/login', { u, p })", 14, "  const res = await retry(() => api.post('/login', { u, p }), {", 2, [], ['retry(() => ', '), {']],
107      ['mod', null, '', 15, '    times: 3,', 2, [], ['    times: 3,']],
108      ['mod', null, '', 16, '    when: isRetryable,', 2, [], ['    when: isRetryable,']],
109      ['mod', null, '', 17, '  })', 2, [], ['  })']],
110      ['eq', 14, '  if (!res.ok) throw new LoginError(res.status)', 18, '  if (!res.ok) throw new LoginError(res.status)'],
111      ['del', 15, "  log.info('login', res.status)", null, '', 3],
112      ['eq', 16, '  return res.json()', 19, '  return res.json()'],
113      ['eq', 17, '}', 20, '}'],
114      ['fold', '변경 없는 4줄'],
115    ],
116  },
117  {
118    name: 'retry.ts', path: 'src/lib/retry.ts', tag: '새 파일', tagColor: C.green, add: 18, del: 0,
119    desc: 'retry(), isRetryable() 헬퍼',
120    rows: added([
121      'export function isRetryable(err: unknown) {',
122      '  if (err instanceof NetworkError) return true',
123      '  return err instanceof HttpError && err.status >= 500',
124      '}',
125      '',
126      'export async function retry<T>(',
127      '  fn: () => Promise<T>,',
128      '  { times, when }: RetryOptions,',
129      '): Promise<T> {',
130      '  for (let n = 0; ; n++) {',
131      '    try {',
132      '      return await fn()',
133      '    } catch (err) {',
134      '      if (n + 1 >= times || !when(err)) throw err',
135      '      await sleep(200 * 2 ** n)',
136      '    }',
137      '  }',
138      '}',
139    ]),
140  },
141  {
142    name: 'login.test.ts', path: 'src/auth/login.test.ts', tag: '새 파일', tagColor: C.green, add: 18, del: 0,
143    desc: '재시도 테스트 3개', agent: '서브에이전트 · general-purpose',
144    rows: added([
145      "describe('login retry', () => {",
146      "  it('503 다음 200이면 성공', async () => {",
147      '    api.post.mockRejectedOnce(http(503)).mockResolvedOnce(ok())',
148      "    await expect(login('a', 'b')).resolves.toBeDefined()",
149      '  })',
150      "  it('401은 재시도하지 않음', async () => {",
151      '    api.post.mockRejectedOnce(http(401))',
152      "    await expect(login('a', 'b')).rejects.toThrow(LoginError)",
153      '    expect(api.post).toHaveBeenCalledTimes(1)',
154      '  })',
155      "  it('3회 모두 실패하면 에러', async () => { /* … */ })",
156      '})',
157    ]),
158  },
159]
160
161// 변경 블록 목록 (파일 순서대로). note는 해설 요약에서 함께 만들어지는 한 줄 설명
162export const HUNKS = [
163  { f: 0, h: 1, note: 'retry 모듈 import 추가' },
164  { f: 0, h: 2, note: 'api 호출을 retry()로 감쌈. 최대 3회, isRetryable(err)일 때만 재시도' },
165  { f: 0, h: 3, note: '로그 한 줄 삭제. 재시도 중 같은 로그가 반복 출력되지 않도록' },
166  { f: 1, h: 1, note: '재시도 헬퍼 신규 작성. 200ms × 2ⁿ 지수 백오프, 마지막 에러는 그대로 던짐' },
167  { f: 2, h: 1, note: '서브에이전트가 작성한 테스트 3개. 503 후 성공, 401 즉시 실패, 3회 모두 실패' },
168]
169
170// 질문 입력창 샘플 답변 (실제 mod에서는 현재 대화를 fork해서 답한다)
171export const SAMPLE_ANSWER =
172  '(샘플 답변) 401을 재시도하지 않은 이유는, 잘못된 비밀번호로 여러 번 요청하면 계정 잠금 정책에 걸리기 때문입니다. 실제 mod에서는 현재 대화를 바탕으로 답합니다.'
173
174// ── 배경: 원래 어떤 문제가 있었나 ──
175export const BACKGROUND = {
176  problem: '로그인 API가 가끔 타임아웃이나 503으로 실패하면, 사용자는 곧바로 "로그인 실패" 화면을 봤습니다.',
177  cause: '서버가 잠깐 바쁠 때 생기는 일시적인 실패인데, 기존 login()은 한 번 실패하면 바로 에러를 던졌습니다.',
178  causeAt: 'src/auth/login.ts:13-14 (변경 전)',
179  goal: '일시적인 실패는 사용자가 모르게 다시 시도하고, 비밀번호가 틀린 것 같은 진짜 실패는 지금처럼 바로 알려 줍니다.',
180  outOfScope: ['요청 타임아웃 값 자체', '서버가 바빠지는 원인'],
181}
182
183// ── 코드 따라가기: 단계마다 코드 조각, 하는 일, 이유, 검토한 다른 방법 ──
184export const WALK = [
185  {
186    title: '재시도 래퍼 만들기',
187    at: 'src/lib/retry.ts:6-18',
188    code: [
189      'export async function retry<T>(fn, { times, when }) {',
190      '  for (let n = 0; ; n++) {',
191      '    try {',
192      '      return await fn()',
193      '    } catch (err) {',
194      '      if (n + 1 >= times || !when(err)) throw err',
195      '      await sleep(200 * 2 ** n)',
196      '    }',
197      '  }',
198      '}',
199    ],
200    does: 'fn을 호출하고, 실패하면 기다렸다가 다시 호출합니다. 최대 times번까지 시도하고, when(err)가 false면 바로 멈춥니다.',
201    why: '재시도 규칙을 login() 안에 직접 쓰지 않고 함수로 분리했습니다. 나중에 다른 API 호출에도 그대로 쓸 수 있습니다.',
202    alt: 'HTTP 클라이언트 전체(인터셉터)에 재시도를 거는 방법도 있습니다. 하지만 그러면 결제처럼 멱등하지 않은 요청까지 중복으로 보낼 수 있어서 쓰지 않았습니다.',
203  },
204  {
205    title: '재시도 대상 판별',
206    at: 'src/lib/retry.ts:1-4',
207    code: [
208      'export function isRetryable(err: unknown) {',
209      '  if (err instanceof NetworkError) return true',
210      '  return err instanceof HttpError && err.status >= 500',
211      '}',
212    ],
213    does: '네트워크 오류이거나 서버 오류(5xx)일 때만 true를 돌려줍니다.',
214    why: '401(인증 실패)은 다시 보내도 결과가 같습니다. 잘못된 비밀번호를 반복해서 보내면 계정 잠금 정책에 걸릴 수 있습니다.',
215    alt: '모든 에러를 재시도하는 방법은 401까지 세 번 보내게 되어 쓰지 않았습니다.',
216  },
217  {
218    title: '기다리는 시간을 두 배씩 늘리기',
219    at: 'src/lib/retry.ts:15',
220    code: ['      await sleep(200 * 2 ** n)'],
221    does: '첫 실패 뒤에는 200ms, 두 번째 실패 뒤에는 400ms를 기다립니다. 이것을 지수 백오프라고 합니다.',
222    why: '바쁜 서버에 곧바로 다시 요청하면 부하가 더 커집니다. 기다리는 시간을 늘리면 서버가 회복할 시간이 생깁니다.',
223    alt: '항상 200ms를 기다리는 고정 대기도 있습니다. 단순하지만 서버가 회복하기 전에 다시 실패할 가능성이 큽니다.',
224  },
225  {
226    title: 'login()에 적용',
227    at: 'src/auth/login.ts:14-17',
228    code: [
229      "  const res = await retry(() => api.post('/login', { u, p }), {",
230      '    times: 3,',
231      '    when: isRetryable,',
232      '  })',
233    ],
234    does: 'api.post 호출을 retry로 감쌌습니다. 최대 3번, 재시도 대상일 때만 다시 시도합니다.',
235    why: '호출하는 쪽(LoginPage)은 바꾸지 않고 login() 안에서만 해결했습니다. 화면 코드는 재시도가 있는지 몰라도 됩니다.',
236    alt: 'LoginPage에서 재시도하는 방법은 화면 코드에 네트워크 규칙이 섞여서 쓰지 않았습니다.',
237  },
238  {
239    title: '로그 한 줄 삭제',
240    at: 'src/auth/login.ts:15 (삭제)',
241    code: ["  log.info('login', res.status)"],
242    does: '로그인 응답마다 남기던 로그를 지웠습니다.',
243    why: '재시도가 생기면서 같은 로그가 한 번 로그인에 여러 줄 찍힐 수 있었습니다.',
244    alt: '로그를 retry 안으로 옮기는 방법도 있습니다. 필요하면 다음 작업에서 시도 횟수와 함께 남기는 것을 검토하세요.',
245  },
246]
247
248// ── 용어: 이번 변경에 처음 나온 개념 ──
249export const TERMS = [
250  { id: 'transient', term: '일시적인 실패', en: 'transient failure', def: '잠시 뒤 다시 시도하면 성공할 수 있는 실패. 네트워크 끊김, 서버 과부하(503) 등.' },
251  { id: 'backoff', term: '지수 백오프', en: 'exponential backoff', def: '재시도할 때마다 기다리는 시간을 두 배로 늘리는 방식. 여기서는 200ms 다음 400ms.' },
252  { id: 'idempotent', term: '멱등성', en: 'idempotency', def: '같은 요청을 여러 번 보내도 한 번 보낸 것과 결과가 같은 성질. 재시도해도 안전한지 판단하는 기준.' },
253  { id: '5xx', term: '5xx', en: 'server error', def: '서버 쪽 문제를 뜻하는 HTTP 상태 코드(500~599). 요청을 보낸 쪽의 잘못이 아닐 수 있음.' },
254]
255
256// ── 확인 퀴즈: 보기는 단어 수를 맞추고, 순서는 mod 코드가 섞는다. 첫 번째 보기가 정답 ──
257export const QUIZ = [
258  {
259    q: '서버가 401(인증 실패)을 돌려주면 login()은 어떻게 동작하나요?',
260    options: ['재시도 없이 바로 실패한다', '3회 재시도 후 실패한다', '한 번만 다시 시도한다'],
261    explain: 'isRetryable()이 401에 false를 돌려주므로 retry()가 바로 에러를 던집니다.',
262  },
263  {
264    q: '세 번째 호출 전에는 얼마나 기다리나요?',
265    options: ['400ms를 기다린다', '200ms를 기다린다', '800ms를 기다린다'],
266    explain: '첫 실패 뒤 200ms, 두 번째 실패 뒤 400ms를 기다립니다. 세 번째 호출은 두 번째 실패 뒤입니다.',
267  },
268  {
269    q: '재시도를 HTTP 클라이언트 전체에 걸지 않은 이유는?',
270    options: ['중복되면 안 되는 요청이 있어서', '인터셉터가 비동기 요청을 지원하지 않아서', '로그인 요청은 클라이언트를 거치지 않아서'],
271    explain: '결제처럼 멱등하지 않은 요청이 재시도로 두 번 실행될 수 있기 때문입니다.',
272  },
273]
274
275// ── Wait, what?: 섹션을 더 쉬운 말로, 빠진 전제를 채워 다시 설명 (wait-what 스킬) ──
276export const EASY = {
277  summary: '로그인할 때 서버가 잠깐 바빠서 실패하면, 프로그램이 알아서 조금 기다렸다가 다시 시도하게 만들었습니다. 비밀번호가 틀린 경우는 다시 시도해도 소용없으니 바로 알려 줍니다.',
278  background: '식당에 전화했는데 통화 중이면 바로 포기하지 않고 잠시 뒤 다시 거는 것과 같습니다. 원래 코드는 통화 중이면 바로 포기했습니다.',
279  walk: '새 함수 retry()가 "실패하면 기다렸다가 다시"를 맡고, isRetryable()이 "다시 해 볼 만한 실패인지"를 판단합니다. login()은 이 둘을 조합해서 쓰기만 합니다.',
280  flow: '위에서 아래로 읽으면 됩니다. 성공하면 끝, 다시 해 볼 만한 실패면 기다렸다가 2번으로 돌아가고, 아니면 에러로 끝납니다.',
281  seq: '왼쪽에서 오른쪽으로 요청이 가고, 오른쪽에서 왼쪽으로 응답이 옵니다. retry()가 중간에서 실패를 받아 한 번 더 보내는 것이 이번 변경입니다.',
282  ba: '바뀐 것은 "서버가 잠깐 바쁠 때"뿐입니다. 비밀번호가 틀린 경우는 전과 똑같습니다.',
283  impact: 'login()을 부르는 곳은 코드를 고치지 않아도 되지만, 응답이 늦어질 수 있으니 로딩 표시와 토큰 갱신 쪽을 한 번 확인하라는 뜻입니다.',
284  terms: '모르는 용어가 있으면 [Known]을 누르지 말고 두세요. 다음 해설에서도 계속 풀어서 설명합니다.',
285  quiz: '정답을 고르면 이유가 바로 나옵니다. 틀려도 괜찮습니다. 이유를 읽고 다시 풀면 됩니다.',
286}
287
288// 같은 섹션에서 Wait, what?을 또 누르면: 회차마다 다른 비유로, 막힌 전제를 하나씩 짚어 다시 설명.
289// 실제 mod에서는 앞의 설명들을 모델에 함께 넘겨 "이것들과 다르게" 새로 만든다
290export const EASY_AGAIN = {
291  background: [
292    '이번에는 전제부터 짚어 보겠습니다. ① 서버도 바쁠 때가 있습니다. ② 바쁠 때 온 요청은 실패로 돌아옵니다. ③ 그런데 몇백 ms 뒤에는 대개 한가해집니다. 그래서 "조금 기다렸다 다시"가 효과가 있습니다.',
293    '숫자로 보면: 서버가 1초에 100개를 처리할 수 있는데 순간 150개가 몰리면 50개는 실패합니다. 0.2초 뒤에는 몰렸던 요청이 빠져서 다시 보내면 대부분 처리됩니다.',
294  ],
295  walk: [
296    '역할로 나눠 보면 쉽습니다. retry()는 "몇 번, 얼마나 기다렸다 다시 할지"만 압니다. isRetryable()은 "이 실패가 다시 해 볼 만한지"만 압니다. login()은 둘에게 일을 맡기는 관리자입니다.',
297    '실행 순서로 따라가 보면: login()이 retry()를 부름 → retry()가 api.post를 부름 → 503 실패 → isRetryable()에게 물어봄(예) → 200ms 쉼 → 다시 api.post → 성공 → login()에게 결과 전달.',
298  ],
299}
300// 준비된 설명이 떨어졌을 때 (샘플 전용). 같은 글을 반복하지 않는다
301export const EASY_AGAIN_DEFAULT = '(샘플) 준비된 설명은 여기까지입니다. 실제 mod에서는 앞의 설명들과 겹치지 않게 새로 만듭니다.'
302// 이 회차부터는 더 설명하기보다 어디가 막히는지 묻는다
303export const EASY_ASK_AFTER = 3
304
305// 섹션 안에서 추가 질문을 했을 때의 샘플 답변
306export const SECTION_ANSWER = '(샘플 답변) 실제 mod에서는 이 섹션의 내용과 앞의 쉬운 설명을 바탕으로, 현재 대화를 fork해서 답합니다.'
307
hooks/views.js 659 lines
1// 해설 창의 각 화면을 그리는 함수들. `el`은 $.ui.resolve(e)가 준 요소 생성자 묶음.
2//
3// 그리기 규칙
4// - 한 줄에 여러 색이 필요하면 Text 하나 안에 Text를 넣는다. 조각을 Box 가로줄로
5//   늘어놓으면 좁은 창에서 조각마다 따로 줄바꿈되어 글자가 뒤섞인다.
6// - 다이어그램과 코드 줄은 줄바꿈 대신 잘라낸다(wrap: 'truncate-end').
7// - 한글은 2칸을 차지하므로 다이어그램 줄에서 한글은 줄 끝에만 둔다.
8
9import { BACKGROUND, BEFORE_AFTER, C, D, FILES, FLOW, HUNKS, KIND_COLOR, LANES, MSGS, QUIZ, TERMS, WALK } from './data.js'
10
11// ── 공통 ─────────────────────────────────────────────────────
12
13// 한 줄(또는 한 문단) 안의 색 조각들: parts = ['plain' | [text, color, bold?], ...]
14export function rich(el, parts, opts) {
15  return el.Text({
16    color: C.fg,
17    ...(opts || {}),
18    children: parts.map((p) => (typeof p === 'string' ? p : el.Text({ color: p[1], bold: !!p[2], children: [p[0]] }))),
19  })
20}
21
22// 버튼: Claude Code의 기본 버튼. 터미널에서는 [ d  diff 보기 ], Desktop 앱에서는 네이티브 버튼으로 그려진다.
23// 대괄호 버튼은 단축키를 따로 표시하지 않으므로 라벨 앞에 키를 넣는다.
24// primary는 화면마다 하나만 두는 주요 버튼(터미널에서 강조색)
25export function btn(el, { key, hotkey, label, onPress, primary, dim }) {
26  const props = { key, label: hotkey ? hotkey + ' ' + label : label, onPress }
27  if (hotkey) props.hotkey = hotkey
28  if (primary) props.variant = 'primary'
29  if (dim) props.dimColor = true
30  return el.Button(props)
31}
32
33// 대괄호 없는 글자 링크: 목차처럼 여러 개를 늘어놓을 때. 누를 수 있고, 흐리게 하면 보조 항목
34export function link(el, { key, label, onPress, dim }) {
35  const props = { key, label, plain: true, onPress }
36  if (dim) props.dimColor = true
37  return el.Button(props)
38}
39
40// 버튼들을 한 줄에 늘어놓고, 넘치면 다음 줄로
41export function buttonRow(el, children) {
42  return el.Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 0, children })
43}
44
45// 터미널 표시 폭: 한글·한자·전각 문자는 2칸
46export function widthOf(s) {
47  let w = 0
48  for (const ch of s) {
49    const c = ch.codePointAt(0)
50    w += (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
51      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) ? 2 : 1
52  }
53  return w
54}
55
56// 색 조각들의 앞에서 n글자를 버린다 (가로 스크롤)
57function shift(parts, n) {
58  const out = []
59  let rest = n
60  for (const p of parts) {
61    const t = p[0]
62    if (rest >= t.length) { rest -= t.length; continue }
63    out.push(rest ? [t.slice(rest), p[1], p[2], p[3]] : p)
64    rest = 0
65  }
66  return out
67}
68
69export function rule(el, cols) {
70  return el.Text({ color: C.rule, wrap: 'truncate-end', children: ['─'.repeat(Math.max(10, cols))] })
71}
72
73function heading(el, text) {
74  return el.Text({ bold: true, color: C.accent, children: [text] })
75}
76
77function block(el, title, children) {
78  return el.Box({
79    flexDirection: 'column',
80    children: [heading(el, title), el.Box({ flexDirection: 'column', paddingLeft: 2, children })],
81  })
82}
83
84// ── 요약 ─────────────────────────────────────────────────────
85
86// onFile(i): 파일을 누르면 그 파일의 diff를 연다
87export function summaryView(el, onFile) {
88  return el.Box({
89    flexDirection: 'column',
90    rowGap: 1,
91    children: [
92      block(el, '무엇을 바꿨나', [
93        ...FILES.map((f, i) =>
94          el.Box({
95            flexDirection: 'column',
96            children: [
97              btn(el, { key: 'file-' + i, label: f.path + '  →  diff', onPress: () => onFile(i) }),
98              rich(el, [
99                '  ',
100                [f.tag, f.tagColor],
101                '  ',
102                ['+' + f.add, C.green],
103                f.del ? ' ' : '',
104                f.del ? ['−' + f.del, C.red] : '',
105                f.agent ? '  ' : '',
106                f.agent ? ['서브에이전트', C.purple] : '',
107                '  ',
108                [f.desc, C.dim],
109              ]),
110            ],
111          }),
112        ),
113        el.Text({ color: C.faint, children: ['파일을 누르면 그 파일의 diff로 이동합니다.'] }),
114      ]),
115      block(el, '왜 이렇게 했나', [
116        el.Text({ children: ['일시적인 실패(네트워크 오류, 5xx)만 재시도하고, 401 같은 인증 실패는 즉시 실패시켰습니다.'] }),
117        el.Text({ color: C.dim, children: ['잘못된 비밀번호를 세 번 보내면 계정 잠금 정책에 걸릴 수 있기 때문입니다.'] }),
118      ]),
119      block(el, '어떻게 동작하나', [
120        rich(el, [['1 ', C.accent], ['retry(fn, { times: 3, when })', C.blue]]),
121        el.Text({ color: C.dim, children: ['  실패하면 200ms, 400ms 기다렸다가 다시 호출합니다.'] }),
122        rich(el, [['2 ', C.accent], ['isRetryable(err)', C.blue]]),
123        el.Text({ color: C.dim, children: ['  네트워크 오류이거나 status가 500 이상이면 재시도 대상입니다.'] }),
124        rich(el, [['3 ', C.accent], ['마지막까지 실패하면', C.fg]]),
125        el.Text({ color: C.dim, children: ['  원래 에러를 그대로 던집니다. 호출부의 에러 처리는 바뀌지 않습니다.'] }),
126      ]),
127      block(el, '검토할 점', [
128        rich(el, [['! ', C.red, true], '최악의 경우 로그인 응답이 약 1.4초 늦어집니다. 로딩 표시를 확인하세요.']),
129        rich(el, [['? ', C.blue, true], '타임아웃 값 자체는 그대로입니다. 원인이 긴 타임아웃이면 별도 조치가 필요합니다.']),
130      ]),
131    ],
132  })
133}
134
135// ── 배경 ─────────────────────────────────────────────────────
136
137export function backgroundView(el) {
138  return el.Box({
139    flexDirection: 'column',
140    rowGap: 1,
141    children: [
142      block(el, '무슨 문제가 있었나', [el.Text({ children: [BACKGROUND.problem] })]),
143      block(el, '왜 그랬나', [
144        el.Text({ children: [BACKGROUND.cause] }),
145        el.Text({ color: C.blue, children: [BACKGROUND.causeAt] }),
146      ]),
147      block(el, '이번 변경의 목표', [el.Text({ children: [BACKGROUND.goal] })]),
148      block(el, '이번에 다루지 않은 것', BACKGROUND.outOfScope.map((x) => rich(el, [['- ', C.faint], [x, C.dim]]))),
149    ],
150  })
151}
152
153// ── 코드 따라가기 ────────────────────────────────────────────
154// 단계마다: 제목과 위치 → 코드 조각 → 하는 일 → 이유 → 검토한 다른 방법
155
156function labeled(el, label, color, text) {
157  return el.Box({
158    flexDirection: 'column',
159    children: [
160      el.Text({ bold: true, color, children: [label] }),
161      el.Box({ paddingLeft: 2, children: [el.Text({ children: [text] })] }),
162    ],
163  })
164}
165
166export function walkView(el) {
167  return el.Box({
168    flexDirection: 'column',
169    rowGap: 1,
170    children: [
171      el.Text({ color: C.dim, children: ['실제 코드를 순서대로 따라가며 각 줄이 하는 일과 그렇게 한 이유를 설명합니다.'] }),
172      ...WALK.map((st, i) =>
173        el.Box({
174          flexDirection: 'column',
175          rowGap: 1,
176          children: [
177            el.Box({
178              flexDirection: 'column',
179              children: [
180                rich(el, [[(i + 1) + '단계  ', C.accent, true], [st.title, C.title, true]]),
181                el.Text({ color: C.blue, wrap: 'truncate-start', children: [st.at] }),
182              ],
183            }),
184            el.Box({
185              flexDirection: 'column',
186              paddingLeft: 1,
187              children: st.code.map((line) =>
188                rich(el, [['┃ ', C.faint], [line, C.fg]], { wrap: 'truncate-end' }),
189              ),
190            }),
191            labeled(el, '하는 일', C.green, st.does),
192            labeled(el, '이유', C.accent, st.why),
193            labeled(el, '검토한 다른 방법', C.purple, st.alt),
194          ],
195        }),
196      ),
197    ],
198  })
199}
200
201// ── 용어 ─────────────────────────────────────────────────────
202// known: 이미 아는 용어 id 목록. 아는 용어는 한 줄로 접고, 다음 해설에서도 길게 풀지 않는다
203
204export function termsView(el, known, onToggle) {
205  const rows = TERMS.map((t) => {
206    const isKnown = known.includes(t.id)
207    const button = btn(el, { key: 'term-' + t.id, label: isKnown ? 'Undo' : 'Known', onPress: () => onToggle(t.id) })
208    if (isKnown) {
209      return el.Box({
210        flexDirection: 'row',
211        flexWrap: 'wrap',
212        columnGap: 2,
213        children: [rich(el, [['✓ ', C.green], [t.term, C.dim]]), button],
214      })
215    }
216    return el.Box({
217      flexDirection: 'column',
218      children: [
219        el.Box({
220          flexDirection: 'row',
221          flexWrap: 'wrap',
222          columnGap: 2,
223          children: [rich(el, [[t.term, C.title, true], ['  ' + t.en, C.faint]]), button],
224        }),
225        el.Box({ paddingLeft: 2, children: [el.Text({ children: [t.def] })] }),
226      ],
227    })
228  })
229  return el.Box({
230    flexDirection: 'column',
231    rowGap: 1,
232    children: [
233      el.Text({ color: C.dim, children: ['이번 변경에 처음 나온 개념입니다. 이미 아는 것은 [Known]을 누르면 다음 해설부터 짧게 넘어갑니다.'] }),
234      ...rows,
235    ],
236  })
237}
238
239// ── 확인 퀴즈 ────────────────────────────────────────────────
240// st = { order: [[보기 위치...] 문제별], picked: [고른 보기 원래 위치 | null] }. 원래 위치 0이 정답
241
242export function quizView(el, st, on) {
243  const answered = st.picked.filter((x) => x !== null).length
244  const correct = st.picked.filter((x) => x === 0).length
245  const done = answered === QUIZ.length
246  return el.Box({
247    flexDirection: 'column',
248    rowGap: 1,
249    children: [
250      el.Text({ color: C.dim, children: ['기억에서 떠올리며 풀어 보세요. 다 맞히면 이 턴이 이해함으로 표시됩니다.'] }),
251      ...QUIZ.map((qz, i) => {
252        const picked = st.picked[i]
253        const options = st.order[i].map((orig, j) => {
254          const label = String.fromCharCode(65 + j) + '  ' + qz.options[orig]
255          if (picked === null) {
256            return btn(el, { key: 'q' + i + '-o' + orig, label, onPress: () => on.pick(i, orig) })
257          }
258          const mark = orig === 0 ? '✓ ' : orig === picked ? '✗ ' : '  '
259          const color = orig === 0 ? C.green : orig === picked ? C.red : C.faint
260          return el.Text({ color, children: [mark + label] })
261        })
262        return el.Box({
263          flexDirection: 'column',
264          children: [
265            rich(el, [['Q' + (i + 1) + '  ', C.accent, true], [qz.q, C.title, true]]),
266            el.Box({ flexDirection: 'column', paddingLeft: 2, children: options }),
267            picked === null ? el.Text({ children: [' '] }) : el.Box({
268              paddingLeft: 2,
269              children: [rich(el, [[picked === 0 ? '맞았습니다. ' : '틀렸습니다. ', picked === 0 ? C.green : C.red, true], [qz.explain, C.fg]])],
270            }),
271          ],
272        })
273      }),
274      done
275        ? el.Box({
276            flexDirection: 'row',
277            flexWrap: 'wrap',
278            columnGap: 2,
279            children: [
280              rich(el, [[correct + '/' + QUIZ.length + ' 정답', correct === QUIZ.length ? C.green : C.accent, true],
281                [correct === QUIZ.length ? '  이 턴을 이해함으로 표시했습니다.' : '  틀린 문제의 이유를 읽고 다시 풀어 보세요.', C.dim]]),
282              correct === QUIZ.length ? el.Text({ children: [''] }) : btn(el, { key: 'quiz-retry', label: '다시 풀기', onPress: on.retry }),
283            ],
284          })
285        : el.Text({ color: C.faint, children: [answered + '/' + QUIZ.length + ' 답함'] }),
286    ],
287  })
288}
289
290// ── 흐름도 (카드) ────────────────────────────────────────────
291
292function card(el, item, width) {
293  const color = KIND_COLOR[item.kind]
294  const titleColor = item.kind === 'err' ? C.red : item.kind === 'ok' ? C.green : C.title
295  const rows = [
296    rich(el, [item.n ? [item.n + '  ', C.accent, true] : '', [item.title, titleColor, true]]),
297    el.Text({ color: C.dim, children: [item.desc] }),
298  ]
299  if (item.tag) rows.push(el.Text({ color: C.accent, children: [item.tag] }))
300  const props = { flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, children: rows }
301  if (width) props.width = width
302  return el.Box(props)
303}
304
305function connector(el, width, label) {
306  return el.Box({
307    paddingLeft: Math.floor(width / 2),
308    children: [rich(el, [['│', C.faint], label ? ['  ' + label, C.dim] : ''])],
309  })
310}
311
312export function flowView(el, cols) {
313  const cardW = Math.min(34, cols - 2)
314  // 옆 가지 카드를 나란히 둘 공간이 없으면 카드 아래로 내린다
315  const wide = cols >= cardW + 34
316  const out = [
317    rich(el, [
318      ['■ ', C.accent], ['이번에 추가  ', C.dim],
319      ['■ ', C.blue], ['조건  ', C.dim],
320      ['■ ', C.green], ['정상 종료  ', C.dim],
321      ['■ ', C.red], ['에러', C.dim],
322    ]),
323    el.Text({ children: [' '] }),
324  ]
325  FLOW.forEach((item, i) => {
326    const main = card(el, item, cardW)
327    if (item.branch) {
328      const arrow = el.Text({ color: KIND_COLOR[item.branch.kind], children: [' ─ ' + item.branch.label + ' ─> '] })
329      const side = card(el, item.branch)
330      out.push(
331        wide
332          ? el.Box({ flexDirection: 'row', alignItems: 'center', children: [main, arrow, side] })
333          : el.Box({
334              flexDirection: 'column',
335              children: [
336                main,
337                el.Box({ flexDirection: 'row', alignItems: 'center', paddingLeft: 3, children: [el.Text({ color: C.faint, children: ['└'] }), arrow, side] }),
338              ],
339            }),
340      )
341    } else {
342      out.push(main)
343    }
344    if (i < FLOW.length - 1) out.push(connector(el, cardW, item.next))
345  })
346  return el.Box({ flexDirection: 'column', children: out })
347}
348
349// ── 시퀀스 ───────────────────────────────────────────────────
350// 각 호출은 두 줄: 위 줄에 번호와 라벨, 아래 줄에 화살표. 설명은 그 아래 흐린 줄.
351// 왼쪽 번호 칸은 고정이고, 나머지는 left만큼 가로로 밀어서 그린다.
352
353const SEQ_GUTTER = 3
354
355function seqGeometry(cols) {
356  const laneW = Math.max(12, Math.min(20, Math.floor((cols - SEQ_GUTTER) / LANES.length)))
357  const center = (i) => i * laneW + Math.floor(laneW / 2)
358  const width = laneW * LANES.length
359  // 가장 긴 줄: 라벨 또는 설명이 생명선 오른쪽으로 뻗는 끝
360  let longest = width
361  MSGS.forEach(([from, to, label, , note]) => {
362    const start = Math.min(center(from), center(to)) + 2
363    longest = Math.max(longest, start + widthOf(label), start + widthOf(note || ''))
364  })
365  return { laneW, center, width, longest }
366}
367
368// 가로로 밀 수 있는 최대 칸 수
369export function seqMaxLeft(cols) {
370  const g = seqGeometry(cols)
371  return Math.max(0, g.longest - (cols - SEQ_GUTTER))
372}
373
374export function seqView(el, cols, left) {
375  const { laneW, center, width } = seqGeometry(cols)
376  const lifeline = () => {
377    const a = new Array(width).fill(' ')
378    LANES.forEach((_, i) => { a[center(i)] = '│' })
379    return a
380  }
381  // 번호 칸(고정) + 가로로 민 나머지
382  const row = (gutter, parts) =>
383    rich(el, [[gutter, C.accent, true], ...shift(parts, left)], { wrap: 'truncate-end' })
384  const paint = (cells, start, end, color) => [
385    [cells.slice(0, start).join(''), C.faint],
386    [cells.slice(start, end).join(''), color],
387    [cells.slice(end).join(''), C.faint],
388  ]
389  // col까지 생명선 + 글. 글 뒤로는 생명선을 그리지 않는다(한글 폭 때문에)
390  const textAt = (col, parts) => [[lifeline().slice(0, col).join(''), C.faint], ...parts]
391
392  // 참여자 머리 상자 세 줄을 글자로 그린다 (가로 스크롤이 되도록)
393  const boxLine = (fn) => LANES.map((l) => [fn(l).padEnd(laneW, ' '), l.color, true])
394  const inner = laneW - 3
395  const fit = (name) => (name.length > inner ? name.slice(0, inner - 1) + '…' : name)
396  const centerText = (name) => {
397    const t = fit(name)
398    const pad = inner - t.length
399    return ' '.repeat(Math.floor(pad / 2)) + t + ' '.repeat(pad - Math.floor(pad / 2))
400  }
401  const rows = [
402    row('   ', boxLine(() => '╭' + '─'.repeat(inner) + '╮')),
403    row('   ', boxLine((l) => '│' + centerText(l.name) + '│')),
404    // 아래 테두리의 ┬가 생명선과 같은 칸(레인 안 laneW/2)에 오도록
405    row('   ', boxLine(() => '╰' + '─'.repeat(Math.floor(laneW / 2) - 1) + '┬' + '─'.repeat(inner - Math.floor(laneW / 2)) + '╯')),
406  ]
407  MSGS.forEach(([from, to, label, color, note], i) => {
408    const l = Math.min(center(from), center(to))
409    const r = Math.max(center(from), center(to))
410    rows.push(row(String(i + 1).padStart(2, ' ') + ' ', textAt(l + 2, [[label, color, true]])))
411    const a = lifeline()
412    if (from === to) {
413      // 자기 자신을 부르는 호출: 오른쪽으로 나갔다가 돌아오는 고리
414      const c = center(from)
415      const b = lifeline()
416      a[c] = '├'; a[c + 1] = '─'; a[c + 2] = '╮'
417      b[c] = '│'; b[c + 1] = '<'; b[c + 2] = '╯'
418      rows.push(row('   ', paint(a, c, c + 3, color)))
419      rows.push(row('   ', paint(b, c + 1, c + 3, color)))
420    } else {
421      for (let k = l + 1; k < r; k++) a[k] = '─'
422      if (from < to) a[r - 1] = '>'
423      else a[l + 1] = '<'
424      rows.push(row('   ', paint(a, l + 1, r, color)))
425    }
426    if (note) rows.push(row('   ', textAt(l + 2, [[note, C.dim]])))
427  })
428  rows.push(row('   ', [[lifeline().join(''), C.faint]]))
429  return el.Box({ flexDirection: 'column', children: rows })
430}
431
432// ── 전후 비교 ────────────────────────────────────────────────
433
434export function baView(el) {
435  return el.Box({
436    flexDirection: 'column',
437    rowGap: 1,
438    children: BEFORE_AFTER.map((r) =>
439      el.Box({
440        flexDirection: 'column',
441        children: [
442          el.Text({ bold: true, color: C.title, children: [r.when] }),
443          rich(el, ['  ', [r.before, C.dim], ['  →  ', C.faint], [r.after, r.color, true]]),
444        ],
445      }),
446    ),
447  })
448}
449
450// ── 영향 범위 ────────────────────────────────────────────────
451
452export function impactView(el) {
453  const item = (path, desc, flag) =>
454    el.Box({
455      flexDirection: 'column',
456      children: [
457        el.Text({ color: C.blue, wrap: 'truncate-start', children: [path] }),
458        rich(el, ['  ', [desc, C.fg], flag ? ['  ' + flag, C.red, true] : '']),
459      ],
460    })
461  return el.Box({
462    flexDirection: 'column',
463    rowGap: 1,
464    children: [
465      block(el, 'login()을 부르는 곳 2', [
466        item('src/pages/LoginPage.tsx:41', '로딩 표시 시간이 길어질 수 있습니다.'),
467        item('src/auth/session.ts:88', '토큰 갱신에도 재시도가 적용됩니다.', '의도했는지 확인 필요'),
468      ]),
469      block(el, '새로 생긴 함수', [item('retry(), isRetryable()', '아직 다른 사용처는 없습니다.')]),
470      block(el, '확인할 테스트', [
471        item('src/auth/login.test.ts', '이번에 추가된 테스트'),
472        item('src/auth/session.test.ts', '기존 테스트, 재시도 영향을 받음'),
473      ]),
474    ],
475  })
476}
477
478// ── diff (인텔리제이식) ──────────────────────────────────────
479// 위쪽 안내·버튼과 아래 설명은 고정하고, 가운데 코드 영역만 위아래(top)·좌우(left)로 민다.
480
481// 강조할 부분 문자열들로 한 줄을 조각낸다: [[글, 글자색, 굵게, 배경색], ...]
482function segments(text, highlights, hi, fg) {
483  if (!text) return [[' ', fg]]
484  const out = []
485  let rest = text
486  ;(highlights || []).forEach((h) => {
487    const i = rest.indexOf(h)
488    if (i < 0) return
489    if (i > 0) out.push([rest.slice(0, i), fg])
490    out.push([h, fg, false, hi])
491    rest = rest.slice(i + h.length)
492  })
493  if (rest) out.push([rest, fg])
494  return out
495}
496
497// 줄 표시 칸(고정) + 가로로 민 코드
498function codeLine(el, mark, num, parts, left) {
499  return el.Text({
500    wrap: 'truncate-end',
501    children: [
502      el.Text({ color: C.accent, bold: true, children: [mark] }),
503      el.Text({ color: C.faint, children: [num.padStart(4, ' ') + ' '] }),
504      ...shift(parts, left).map(([t, fg, bold, bg]) => el.Text(bg ? { color: fg, bold: !!bold, backgroundColor: bg, children: [t] } : { color: fg, bold: !!bold, children: [t] })),
505    ],
506  })
507}
508
509function filled(el, width, bg, child) {
510  const props = { width, flexShrink: 0, children: [child] }
511  if (bg) props.backgroundColor = bg
512  return el.Box(props)
513}
514
515// 보고 있는 변경 블록의 파일을 줄 목록으로 만든다 (그리기 전 데이터)
516export function diffModel(cols, pos) {
517  const cur = HUNKS[pos]
518  const file = FILES[cur.f]
519  const side = cols >= 96
520  const mark = (r) => (r[0] !== 'eq' && r[5] === cur.h ? '>' : ' ')
521  const lines = []
522  let hunkStart = -1
523  let longest = 0
524  const note = (r) => { if (hunkStart < 0 && r[5] === cur.h && r[0] !== 'eq') hunkStart = lines.length }
525  file.rows.forEach((r) => {
526    if (r[0] === 'fold') { lines.push({ fold: r[1] }); return }
527    const k = r[0]
528    longest = Math.max(longest, (r[2] || '').length + 2, (r[4] || '').length + 2)
529    if (side) {
530      note(r)
531      lines.push({
532        mark: mark(r),
533        l: {
534          num: r[1] == null ? '' : String(r[1]),
535          parts: segments(r[2], r[6], k === 'mod' ? D.modHi : null, k === 'del' ? C.dim : C.fg),
536          bg: k === 'eq' ? null : k === 'add' ? D.fill : k === 'del' ? D.del : r[2] ? D.mod : D.modFill,
537        },
538        r: {
539          num: r[3] == null ? '' : String(r[3]),
540          parts: segments(r[4], r[7], k === 'mod' ? D.modHi : k === 'add' ? D.addHi : null, C.fg),
541          bg: k === 'eq' ? null : k === 'add' ? D.add : k === 'del' ? D.fill : D.mod,
542        },
543      })
544      return
545    }
546    // 통합 보기: 지운 줄(−) 다음에 추가한 줄(+)
547    if (k === 'eq') {
548      lines.push({ mark: ' ', num: String(r[3]), parts: [['  ' + r[4], C.dim]], bg: null })
549      return
550    }
551    note(r)
552    if (r[2] || k === 'del') lines.push({ mark: mark(r), num: String(r[1]), parts: [['− ', C.red], ...segments(r[2], r[6], D.remHi, C.fg)], bg: D.rem })
553    if (k !== 'del') lines.push({ mark: mark(r), num: String(r[3]), parts: [['+ ', C.green], ...segments(r[4], r[7], D.addHi, C.fg)], bg: D.add })
554  })
555  const codeCols = side ? Math.floor((cols - 1) / 2) - 6 : cols - 6
556  return { cur, file, side, lines, hunkStart: Math.max(0, hunkStart), maxLeft: Math.max(0, longest - codeCols) }
557}
558
559// 코드 영역에 보일 줄 수: 창 높이에서 고정된 위아래 영역을 뺀 만큼
560export function diffCodeRows(bodyRows) {
561  return Math.max(6, bodyRows - 15)
562}
563
564// st = { pos, top, left }. on = { prev, next, nextFile, pickFile, back, up, down, leftward, rightward }
565export function diffView(el, cols, bodyRows, st, on) {
566  const m = diffModel(cols, st.pos)
567  const codeRows = diffCodeRows(bodyRows)
568  const top = Math.min(st.top, Math.max(0, m.lines.length - codeRows))
569  const left = Math.min(st.left, m.maxLeft)
570  const half = Math.floor((cols - 1) / 2)
571
572  const tabs = buttonRow(el, FILES.map((f, i) =>
573    btn(el, { key: 'tab-' + i, label: f.name, primary: i === m.cur.f, dim: i !== m.cur.f, onPress: () => on.pickFile(i) }),
574  ))
575
576  const nav = buttonRow(el, [
577      btn(el, { key: 'prev', hotkey: 'p', label: '이전 변경', dim: st.pos === 0, onPress: on.prev }),
578      btn(el, { key: 'next', hotkey: 'n', label: '다음 변경', primary: true, dim: st.pos === HUNKS.length - 1, onPress: on.next }),
579      btn(el, { key: 'next-file', hotkey: 'f', label: '다음 파일', onPress: on.nextFile }),
580      btn(el, { key: 'back', hotkey: 'b', label: '해설로', onPress: on.back }),
581      el.Text({ color: C.accent, children: [' 변경 ' + (st.pos + 1) + '/' + HUNKS.length] }),
582  ])
583
584  const scrollBar = buttonRow(el, [
585      btn(el, { key: 'up', hotkey: 'k', label: '위', dim: top === 0, onPress: on.up }),
586      btn(el, { key: 'down', hotkey: 'j', label: '아래', dim: top + codeRows >= m.lines.length, onPress: on.down }),
587      btn(el, { key: 'left', hotkey: 'h', label: '왼쪽', dim: left === 0, onPress: on.leftward }),
588      btn(el, { key: 'right', hotkey: 'l', label: '오른쪽', dim: left >= m.maxLeft, onPress: on.rightward }),
589      el.Text({
590        color: C.faint,
591        children: [' 줄 ' + (m.lines.length ? top + 1 : 0) + '–' + Math.min(top + codeRows, m.lines.length) + ' / ' + m.lines.length + (left ? '  ·  가로 +' + left : '') + '  ·  휠로도 스크롤'],
592      }),
593  ])
594
595  const meta = rich(el, [
596    [m.file.path, C.blue],
597    '  ',
598    [m.file.tag, m.file.tagColor],
599    '  ',
600    ['+' + m.file.add, C.green],
601    m.file.del ? ' ' : '',
602    m.file.del ? ['−' + m.file.del, C.red] : '',
603    m.file.agent ? ['  ' + m.file.agent, C.purple] : '',
604    ['  ·  ' + (m.side ? '좌우 보기' : '통합 보기 (창이 좁음)'), C.faint],
605  ], { wrap: 'truncate-end' })
606
607  const code = []
608  if (m.side) {
609    code.push(el.Box({
610      flexDirection: 'row',
611      children: [
612        filled(el, half, null, el.Text({ color: C.faint, children: ['      변경 전 · 턴 시작 시점'] })),
613        el.Text({ color: C.rule, children: ['│'] }),
614        filled(el, half, null, el.Text({ color: C.faint, children: ['      변경 후 · 턴 종료 시점'] })),
615      ],
616    }))
617  }
618  m.lines.slice(top, top + codeRows).forEach((ln) => {
619    if (ln.fold) {
620      code.push(el.Box({ justifyContent: 'center', children: [el.Text({ color: C.faint, children: ['··· ' + ln.fold + ' ···'] })] }))
621    } else if (m.side) {
622      code.push(el.Box({
623        flexDirection: 'row',
624        children: [
625          filled(el, half, ln.l.bg, codeLine(el, ln.mark, ln.l.num, ln.l.parts, left)),
626          el.Text({ color: C.rule, children: ['│'] }),
627          filled(el, half, ln.r.bg, codeLine(el, ln.mark, ln.r.num, ln.r.parts, left)),
628        ],
629      }))
630    } else {
631      code.push(filled(el, cols, ln.bg, codeLine(el, ln.mark, ln.num, ln.parts, left)))
632    }
633  })
634
635  return el.Box({
636    flexDirection: 'column',
637    children: [
638      rich(el, [['diff  ', C.accent, true], ['파일을 고르거나 n/p로 변경 사이를 이동합니다.', C.dim]]),
639      tabs,
640      nav,
641      scrollBar,
642      rule(el, cols),
643      meta,
644      ...code,
645      rule(el, cols),
646      el.Box({
647        borderStyle: 'round',
648        borderColor: C.accent,
649        paddingX: 1,
650        flexDirection: 'column',
651        children: [
652          el.Text({ bold: true, color: C.accent, children: ['변경 ' + (st.pos + 1) + ' 설명'] }),
653          el.Text({ color: C.title, children: [m.cur.note] }),
654        ],
655      }),
656    ],
657  })
658}
659