설명의 목차·현재 위치·곁가지를 패널에 띄우고, 항목별 설명을 저장해 /recall 로 그대로 다시 본다

English | 한국어
A table of contents for long explanations in Claude Code — every section is saved exactly as written, so you can replay it later with
/recall.
Long explanations branch like a tree, but a conversation is a single line. Follow one branch and you lose your way back.
learn-trail shows the outline of what Claude is explaining and where you are in it in a side pane, and saves each section verbatim. Type /recall 1.3 days later and you get the exact explanation back — not a summary. It survives across sessions.
▾ 1. Hikari connection pool ✕
✓ 1.1 Why a connection pool ●
✓ 1.2 What Hikari actually does ●
▸ 1.3 Why a clean boot doesn't mean the DB is reachable ● ← now
1.4 So how do you check
Topics untouched for 7+ days — delete?
✕ 2. Wiring up Vertica (9 days ago) keep
▸ 3. Jasypt keys ✕
────────────────────────────────
recording
/trail off stop recording
/recall 1.2 replay a section
/recall 1 replay a whole topic
/recall 1.1,3 1.2-4 pick sections · ranges
/trail drop 1 delete a topic
/trail close close the pane
ctrl+x, tab focus the pane
Enter fold · switch topic · replay section
<sub>Illustration translated for this README. The pane, commands' messages and the instructions given to Claude are currently in Korean.</sub>
/recall returns the saved text, not a regenerated answerInstalling for the first time, or walking a teammate through it? See the install guide: prerequisites, three steps with copy buttons, common problems, and updating or removing, on one page.
In a Claude Code terminal session, type:
/plugin install learn-trail --marketplace UlongChaS2/learn-trail-mods
y to Add marketplace?Installed learn-trail. Plugin is now active. No restart needed.Requirements: a Claude Code build that supports plugin hooks modules (mods). Built and tested on 2.1.296. The desktop app's Code tab has no /plugin command — install from a terminal; a user-scope install also loads in the desktop app.
Uninstall: open /plugin, select learn-trail, choose uninstall.
/trail ← open the pane and start recording
Explain the basics of backend, section by section
/recall 1 ← replay all of topic 1
/recall 1.3 ← just section 3
Nothing happens until you type /trail. While it is on, Claude lays out an outline before a multi-part answer and writes each part under a ## 1. / ## 2. heading; learn-trail splits the answer on those headings and saves one note per section.
| Command | What it does |
|---|---|
/trail | Open the pane and start recording |
/trail close | Close the pane (recording continues) |
/trail off | Stop recording (saved notes stay) |
/recall | Current section of the current topic |
/recall 1 | All of topic 1 |
/recall 1.2 | Section 2 of topic 1 |
/recall 1.1,3 · /recall 1.2-4 | A list · a range (mix them: 1.1,3-5) |
/recall 2a · /recall 2a.3 | Side question a of topic 2 (whole · section 3) |
/recall hikari · /recall hikari 2 | By title prefix when you forget the number (whole topic · one section) |
/trail drop 1 · /trail drop 2a | Delete a topic or side question (no number = current); its own side questions go with it |
/trail drop all | Delete everything |
Type the number the pane shows. See 1.3, type /recall 1.3. Deleted topic numbers are never reused (deleting everything starts again from 1).
A "wait, what's that?" detour becomes a side question. It doesn't take a topic number: it gets the parent's number plus a letter — 2a, 2b from topic 2, 2aa for a side question of 2a. Deleted letters are reused (delete 2b, the next one is 2b again). Side answers are saved verbatim too, and stay replayable with /recall 2a after you return.
▾ 2. nginx and reverse proxies ✕
✓ 2.5 Reverse proxy in nginx config ●
▸ 2.6 Applying it to our servers ● ← now
↳ 2a. Why can't Tomcat be a reverse proxy (5)
The pane draws one outline at a time. Press Enter on a side-question row to step in: the header becomes a path (2. nginx and rever… › 2a. Why can't Tomcat…) and a ↰ row at the bottom takes you back. However deep you go, the pane keeps the same shape. The status line shows where you are (학습 2a.3).
Everyone can pick their own key for focusing the pane. The default ctrl+x, tab takes two presses; if you want something shorter, or ctrl+x clashes with your terminal, tmux or another tool, bind any key to abovePrompt:focus in ~/.claude/keybindings.json. The help box in the pane reads it and shows your key (the default ctrl+x, tab if you haven't changed it). Takes effect in a new session.
{ "bindings": [{ "context": "Chat", "bindings": { "alt+g": "abovePrompt:focus" } }] }
With a Korean (or any non-Latin) input source active, alt+g may not focus the pane, and you have to switch back to English first. If toggling the input source every time gets old, read on.
Why. Under a non-Latin input source the terminal sends the character the key types (ㅎ), not "the G key". Claude doesn't read alt+ㅎ as alt+g. It isn't only alt: through tmux, herdr and the like, ctrl combinations break the same way. Keybindings alone can't fix it; the terminal has to send the Latin key by physical key position. In Ghostty, for example:
keybind = alt+key_g=esc:g
Terminals and multiplexers differ from person to person, so paste the prompt below into Claude. It finds the cause first, then fixes it. Afterwards the same key focuses the pane whichever input source is on.
The key that focuses the learn-trail pane (abovePrompt:focus in ~/.claude/keybindings.json)
doesn't work while a non-Latin input source (e.g. Korean) is active. Fix it in this order.
1. Find out which terminal I use and anything in between (tmux, herdr, zellij, ...).
2. Before changing anything, have me run learn-trail's scripts/keycap.py
(if installed: ~/.claude/plugins/marketplaces/learn-trail/scripts/keycap.py) to see the
exact bytes that key produces. I'll press it with each input source and paste the output.
3. If the non-Latin input source doesn't send the Latin key code, make the terminal send that
key by physical key position. If something in between drops it, look there too.
4. Back up any config file, show me what you'll change, and wait for my OK.
5. Afterwards, run keycap.py again and confirm the Latin key code arrives.
Step 2 is the point: measure before changing anything. The bytes tell you the cause right away (under Korean, alt+ㅎ shows up as ESC[12622;3u; alt+g as ESC[103;3u).
trail tool and, only while on, adds a short paragraph to the system prompt telling Claude to call set with an outline before a multi-part answer.session.append) and split on its numbered headings. Claude never writes the same text twice.trail (set · focus · next) is not saved, so unrelated chat can't overwrite a saved section.push, replies are saved to the side question, never over the parent's section. Text written after pop is not saved.$.store), local to you, kept across sessions./recall 1.3 into the prompt box rather than sending it. Sending it straight away would start a model turn that rewrites the explanation; one more Enter replays the saved text verbatim.git clone https://github.com/UlongChaS2/learn-trail-mods.git
claude --plugin-dir ./learn-trail-mods/plugin # load it for this session only
claude plugin validate ./learn-trail-mods/plugin # see what the engine would refuse
claude plugin test ./learn-trail-mods/plugin # run the tests
Point at plugin/, not the repo root. The root holds the marketplace manifest; pointing at it makes the engine read the folder as a marketplace and the plugin never loads (commands come back as Unknown command).
learn-trail-mods/ repo = marketplace
├── .claude-plugin/
│ └── marketplace.json → "source": "./plugin"
└── plugin/ ← the plugin
├── .claude-plugin/plugin.json
├── hooks/ register.tsx (the mod) · trail.test.ts
└── types/ $.state contract
Changes take effect after restarting the session. Design decisions are recorded, with dates, in the comments of register.tsx (in Korean).
Issues and pull requests are welcome. Please run claude plugin test and claude plugin validate before opening a PR.
MIT © UlongChaS2
hooks/register.tsx 1181 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Trail, Trails } from '../types'
5
6const PANE = 'learn-trail'
7
8/** 패널 안내 상자의 왼쪽(명령) 칸 폭. 가장 긴 "/recall 1.1,3 1.2-4" 가 20칸이다 */
9const HELP_HOW = 22
10
11/** 패널로 들어오는 엔진 동작. 기본 키는 ctrl+x tab 이다 */
12const FOCUS_ACTION = 'abovePrompt:focus'
13const FOCUS_DEFAULT = 'ctrl+x tab'
14
15/**
16 * 패널로 들어오는 키 — 사람이 keybindings.json 에 바꿔 둔 것이 있으면 그것.
17 *
18 * 안내 상자에 기본 키만 적어 두면, 키를 바꾼 사람(ctrl+x 가 터미널 키와 겹쳐 alt+g 로
19 * 옮긴 경우, 2026-10-14)에게는 틀린 안내가 된다. 엔진에 "이 동작의 키" 를 묻는 길이
20 * 없어 설정 파일을 직접 읽는다. 못 읽으면 기본 키다.
21 *
22 * @returns 화면에 적을 모양 — 연속 키(chord)는 쉼표로 띄운다: `ctrl+x, tab`
23 */
24export const focusKeyOf = (config: unknown): string => {
25 const blocks = (config as { bindings?: unknown })?.bindings
26 const mine: string[] = []
27 if (Array.isArray(blocks)) {
28 for (const b of blocks) {
29 const keys = (b as { bindings?: Record<string, unknown> })?.bindings ?? {}
30 for (const [key, action] of Object.entries(keys)) {
31 if (action === FOCUS_ACTION) mine.push(key)
32 }
33 }
34 }
35 return (mine[0] ?? FOCUS_DEFAULT).split(' ').join(', ')
36}
37
38/** 이 세션에서 읽은 키. 모듈을 다시 불러오면 session.start 가 다시 읽는다 */
39let focusKey = focusKeyOf(undefined)
40const STORE_KEY = 'trails'
41
42/** 이 날수보다 오래 손 안 댄 주제는 켤 때 "지울까요" 하고 묻는다 */
43const STALE_DAYS = 7
44const DAY = 24 * 60 * 60 * 1000
45
46const EMPTY: Trails = { on: false, byTopic: {}, current: '', nextNo: 1, collapsed: [], kept: [] }
47
48/**
49 * 제목에서 부르기 쉬운 이름을 뽑는다 — "Hikari 커넥션 풀" → `hikari`.
50 *
51 * 영문·숫자 토막이 있으면 그 첫 번째를 쓴다. 치기 쉽고 기억에 남는 쪽이 대개 거기다.
52 * 없으면(순한글 제목) 첫 낱말을 그대로 쓴다.
53 */
54const slugOf = (title: string): string => {
55 const ascii = title.toLowerCase().match(/[a-z][a-z0-9]+/)
56 if (ascii) return ascii[0]
57 const word = title.trim().split(/\s+/)[0] ?? ''
58 return word.slice(0, 12) || 'topic'
59}
60
61const trails = atom({ plugin: 'learn-trail', key: 'trails' } as const, EMPTY)
62
63/**
64 * 이번 턴에 모델이 trail 로 목차를 깔거나 항목을 옮겼는가 — 그래야 답을 본문으로 담는다.
65 *
66 * 사람이 말을 걸 때마다 내린다. 세션에만 두면 된다 — 턴을 넘어 남을 값이 아니다.
67 */
68const armed = atom({ plugin: 'learn-trail', key: 'armed' } as const, false)
69
70/**
71 * 도구를 이 중 하나로 부른 턴의 답만 본문이다.
72 *
73 * push 도 든다 — 그 뒤의 답은 곁가지 본문이다. pop 은 반대로 내린다. pop 뒤에 덧붙이는
74 * 마무리 말이 부모 항목 본문을 덮어쓰면 안 된다.
75 */
76const ARMING = ['set', 'focus', 'next', 'push']
77
78/**
79 * state 와 store 양쪽에 쓴다.
80 *
81 * atom 은 $.state 에 얹히는데 그건 세션 단위다 — 창을 닫으면 사라진다. 이 mod 의 쓸모가
82 * "나중에 다시 보기" 라서 그걸로는 반쪽이다(2026-10-08 실제로 날렸다). store 가 실패해도
83 * 화면은 돌아가야 하므로 그쪽만 삼킨다.
84 */
85const save = async ($: EngineInterface, next: Trails) => {
86 await update($, trails, () => next)
87 // 상태줄이 안 되는 곳(테스트·일부 화면)이어도 저장은 계속된다
88 try {
89 $.ui.status(statusOf(next))
90 } catch {}
91 await $.store.set(STORE_KEY, next).catch(() => {})
92}
93
94/** 곁가지 글자. 지운 자리는 다시 채운다 — 2b 를 지우고 새로 열면 다시 2b 다 */
95const LETTERS = 'abcdefghijklmnopqrstuvwxyz'
96
97/**
98 * 주제 이름 순서 — 큰 주제는 번호로, 곁가지는 그 뒤에 글자 순으로.
99 *
100 * 그냥 문자열로 비교하면 "10" 이 "2" 앞에 온다.
101 */
102export const byId = (a: Trail, b: Trail): number => {
103 const na = Number.parseInt(a.id, 10)
104 const nb = Number.parseInt(b.id, 10)
105 return na !== nb ? na - nb : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
106}
107
108/**
109 * 이 주제에서 새 곁가지가 받을 이름 — 부모 이름 뒤에 비어 있는 첫 글자.
110 *
111 * 큰 주제 번호는 다시 쓰지 않지만 곁가지 글자는 채운다. 곁가지는 부모 안에 묶여 있어
112 * 다른 주제와 헷갈릴 일이 없고, 지운 자리를 건너뛰면 2a 다음이 2c 라 왜 b 가 없는지 묻게
113 * 된다(2026-10-14 요청). 26개를 다 쓰면 undefined.
114 */
115export const nextBranchId = (all: Trails, parentId: string): string | undefined => {
116 for (const l of LETTERS) {
117 if (!all.byTopic[parentId + l]) return parentId + l
118 }
119 return undefined
120}
121
122/**
123 * 옛 모양으로 저장된 것을 지금 모양으로 끌어올린다.
124 *
125 * 세 번 바뀌었다. 처음엔 이름(slug)으로 불렀고, 다음엔 번호(`no`)로, 지금은 이름(`id`)으로
126 * 부른다 — 큰 주제는 "2", 곁가지는 "2a". 곁가지도 번호를 받던 때는 곁가지가 큰 주제
127 * 번호를 잡아먹어 1, 2 다음 주제가 4 가 됐다. 그때 저장된 것을 버리지 않고 옮긴다 —
128 * 쌓인 것을 지키는 게 이 mod 의 일이다.
129 */
130export const migrate = (all: Trails): Trails => {
131 type Old = Trail & { no?: number; branches?: string[]; parent?: { id?: string; no?: number; at: number } }
132 const olds = Object.entries(all.byTopic) as [string, Old][]
133 if (olds.every(([, t]) => typeof t.id === 'string' && !('no' in t) && !('branches' in t))) return all
134
135 // 1) 옛 번호를 정한다. 번호도 없던 것(이름으로 부르던 때)에는 뒤에 이어 매긴다
136 let spare = Math.max(1, all.nextNo, ...olds.map(([, t]) => (typeof t.no === 'number' ? t.no + 1 : 1)))
137 // 이미 이름(id)이 있는 것은 그 이름을 지킨다 — 옛 칸(branches)만 걷어 내면 된다
138 const named = (t: Old) => typeof t.id === 'string' && typeof t.no !== 'number'
139 const numbered = olds.map(([key, t]) => ({
140 key,
141 t,
142 n: typeof t.no === 'number' ? t.no : named(t) ? Number.parseInt(t.id, 10) : spare++,
143 }))
144 numbered.sort((x, y) => x.n - y.n)
145
146 // 2) 부모가 먼저 오도록 번호 순으로 이름을 붙인다. 곁가지는 부모 이름 + 글자
147 const idOf = new Map<number, string>()
148 const byTopic: Record<string, Trail> = {}
149 const keyToId = new Map<string, string>()
150 for (const { key, t, n } of numbered) {
151 const { branches: _b, no: _n, parent, ...rest } = t
152 const parentId = parent ? (parent.id ?? (parent.no !== undefined ? idOf.get(parent.no) : undefined)) : undefined
153 const draft = { ...all, byTopic }
154 const id = named(t)
155 ? t.id
156 : parentId && byTopic[parentId]
157 ? (nextBranchId(draft, parentId) ?? String(n))
158 : String(n)
159 if (!named(t)) idOf.set(n, id)
160 keyToId.set(key, id)
161 byTopic[id] = {
162 ...rest,
163 id,
164 slug: rest.slug ?? '',
165 ...(parentId && id !== String(n) ? { parent: { id: parentId, at: parent?.at ?? 0 } } : {}),
166 // 옛것에는 손댄 때가 없다. 지금으로 치면 바로 "묵었다" 고 묻지 않는다
167 touchedAt: typeof rest.touchedAt === 'number' ? rest.touchedAt : Date.now(),
168 }
169 }
170
171 // 3) 큰 주제 번호는 남은 큰 주제 다음부터. 곁가지가 잡아먹던 번호를 돌려받는다
172 const roots = Object.values(byTopic).filter(t => !t.parent).map(t => Number.parseInt(t.id, 10))
173 const map = (k: string) => keyToId.get(k) ?? idOf.get(Number.parseInt(k, 10))
174 return {
175 ...all,
176 byTopic,
177 current: map(all.current) ?? Object.keys(byTopic)[0] ?? '',
178 nextNo: roots.length > 0 ? Math.max(...roots) + 1 : 1,
179 collapsed: all.collapsed.map(map).filter((k): k is string => !!k),
180 kept: all.kept.map(map).filter((k): k is string => !!k),
181 }
182}
183
184/**
185 * 주제 하나를 뺀 상태. 큰 주제가 하나도 안 남으면 번호를 1 로 되돌린다.
186 *
187 * 번호를 다시 쓰지 않는 건 남은 주제와 겹치지 않게 하려는 것이다. 남은 게 없으면 겹칠
188 * 것도 없다 — 다 지웠는데 다음 주제가 7번으로 시작하면 왜 7인지 묻게 된다(2026-10-13).
189 */
190export const without = (all: Trails, key: string): Trails => {
191 // 곁가지는 그 주제의 일부다. 부모만 지우면 갈 곳 없는 곁가지가 맨 위로 떠오른다
192 const gone = new Set([key])
193 let grew = true
194 while (grew) {
195 grew = false
196 for (const t of Object.values(all.byTopic)) {
197 if (t.parent && gone.has(t.parent.id) && !gone.has(t.id)) {
198 gone.add(t.id)
199 grew = true
200 }
201 }
202 }
203 const rest = { ...all.byTopic }
204 for (const k of gone) delete rest[k]
205 const empty = Object.keys(rest).length === 0
206
207 // 지운 것이 지금 보던 곁가지면 부모로 돌아간다. 엉뚱한 첫 주제로 튀면 길을 잃는다
208 const parentId = all.byTopic[key]?.parent?.id
209 const back = parentId !== undefined && rest[parentId] ? parentId : (Object.keys(rest)[0] ?? '')
210 return {
211 ...all,
212 byTopic: rest,
213 current: gone.has(all.current) ? back : all.current,
214 nextNo: empty ? 1 : all.nextNo,
215 collapsed: all.collapsed.filter(k => !gone.has(k)),
216 kept: all.kept.filter(k => !gone.has(k)),
217 }
218}
219
220/** 곁가지를 따라 올라가 맨 위 주제를 찾는다 */
221export const rootOf = (all: Trails, t: Trail): Trail => {
222 let at = t
223 const seen = new Set<string>()
224 while (at.parent && !seen.has(at.id)) {
225 seen.add(at.id)
226 const up = all.byTopic[at.parent.id]
227 if (!up) break
228 at = up
229 }
230 return at
231}
232
233/** 이 주제의 이 항목에서 갈라진 곁가지들 */
234const childrenAt = (all: Trails, id: string, at: number): Trail[] =>
235 Object.values(all.byTopic)
236 .filter(t => t.parent?.id === id && t.parent.at === at)
237 .sort(byId)
238
239/** 이 길이를 넘는 제목은 경로에서 줄인다. 마지막(지금) 주제만 다 적는다 */
240const CRUMB_TITLE = 12
241
242/**
243 * 맨 위 주제부터 지금 주제까지의 경로 — `2. nginx와 리버스 프… › 2a. 톰캣은 왜 리버스 프록시가 안 되나`.
244 *
245 * 네 단계를 넘으면 가운데를 `…` 로 줄인다. 맨 위(무슨 얘기였나)와 끝 둘(어디서 왔고
246 * 지금 어디인가)이 남으면 길을 잃지 않는다.
247 */
248export const crumbOf = (all: Trails, t: Trail): string => {
249 const chain: Trail[] = []
250 for (let at: Trail | undefined = t; at; at = at.parent ? all.byTopic[at.parent.id] : undefined) {
251 if (chain.includes(at)) break
252 chain.unshift(at)
253 }
254 const short = (x: Trail) =>
255 x === t || x.title.length <= CRUMB_TITLE ? `${x.id}. ${x.title}` : `${x.id}. ${x.title.slice(0, CRUMB_TITLE)}…`
256 const parts = chain.map(short)
257 return (parts.length > 4 ? [parts[0], '…', ...parts.slice(-2)] : parts).join(' › ')
258}
259
260/**
261 * 상태줄 한 줄 — 패널을 닫아 두어도 지금 어디인지 보인다. 꺼져 있거나 목차가 없으면 지운다.
262 * 곁가지 이름(2a)에 이미 어디서 왔는지가 들어 있어 경로를 따로 적지 않는다.
263 */
264export const statusOf = (all: Trails): string | undefined => {
265 const cur = all.byTopic[all.current]
266 if (!all.on || !cur) return undefined
267 const where = cur.cursor >= 0 ? `.${cur.cursor + 1}` : ''
268 return `학습 ${cur.id}${where}`
269}
270
271/** 곁가지면 어디서 갈라졌는지 한마디. 맨 위 주제면 빈 문자열 */
272const fromOf = (t: Trail): string => (t.parent ? ` (${t.parent.id}.${t.parent.at + 1} 의 곁가지)` : '')
273
274/** 지금 보고 있는 주제. 없으면 undefined */
275const currentOf = (all: Trails): Trail | undefined => all.byTopic[all.current]
276
277/**
278 * 항목 번호 묶음을 읽는다 — `3` · `1,3,5` · `2-4` · `1,3-5`.
279 *
280 * @returns 0부터 센 번호를 오름차순·중복 없이. 읽을 수 없는 조각이 있으면 undefined —
281 * 읽히는 것만 골라 보여 주면 빠진 줄 모르고 그게 전부인 줄 안다
282 */
283export const parsePicks = (spec: string): number[] | undefined => {
284 const picks = new Set<number>()
285 for (const part of spec.split(',')) {
286 const one = part.match(/^(\d+)$/)
287 const range = part.match(/^(\d+)-(\d+)$/)
288 if (one) {
289 picks.add(Number.parseInt(one[1] ?? '', 10) - 1)
290 } else if (range) {
291 const from = Number.parseInt(range[1] ?? '', 10)
292 const to = Number.parseInt(range[2] ?? '', 10)
293 if (from > to) return undefined
294 for (let n = from; n <= to; n++) picks.add(n - 1)
295 } else {
296 return undefined
297 }
298 }
299 return [...picks].sort((a, b) => a - b)
300}
301
302type RecallArgs = { topic?: string; name?: string; picks?: number[]; bad?: string }
303
304/**
305 * `/recall` 뒤에 적은 것을 읽는다.
306 *
307 * `2` 는 2번 주제 전체, `2.3` 은 2번 주제의 3번, `2.1,3` · `2.2-4` 는 여러 항목,
308 * `2a` · `2a.3` 은 2번에서 갈라진 곁가지 전체 · 그 3번,
309 * `hikari` 는 이름으로 찾은 주제 전체, `hikari 3` 은 그 주제의 3번. 사람이 보는 번호는
310 * 1부터, 안에서는 0부터다.
311 *
312 * 맨 숫자 하나는 주제 번호다. 예전에는 "지금 주제의 항목" 이었는데, 패널에 `1.3` 처럼
313 * 주제.항목으로 찍히니 `/recall 3` 이 3번 주제인지 3번 항목인지 헷갈렸다(2026-10-13).
314 * 이름과 함께 오거나 `1,3` 처럼 묶음이면 항목이다 — 그때는 헷갈릴 게 없다.
315 */
316export const parseArgs = (args: string): RecallArgs => {
317 const out: RecallArgs = {}
318 let bare: string | undefined
319 let spec: string | undefined
320 for (const w of args.trim().split(/\s+/).filter(Boolean)) {
321 const dotted = w.toLowerCase().match(/^(\d+[a-z]*)\.(.*)$/)
322 if (dotted) {
323 out.topic = dotted[1] ?? ''
324 if (dotted[2]) spec = dotted[2]
325 continue
326 }
327 // 곁가지 이름(2a)은 숫자로 시작해도 항목 번호일 수 없다 — 바로 주제다
328 if (/^\d+[a-z]+$/i.test(w)) {
329 out.topic = w.toLowerCase()
330 continue
331 }
332 if (/^\d+$/.test(w)) {
333 bare = w
334 continue
335 }
336 if (/^[\d,-]+$/.test(w)) {
337 spec = w
338 continue
339 }
340 out.name = w.toLowerCase()
341 }
342
343 if (bare !== undefined) {
344 if (out.topic !== undefined || out.name !== undefined) spec = bare
345 else out.topic = bare
346 }
347 if (spec !== undefined) {
348 const picks = parsePicks(spec)
349 if (picks) out.picks = picks
350 else out.bad = spec
351 }
352 return out
353}
354
355/** 손댄 것으로 표시한다 — 오래된 주제를 고르는 기준이 된다 */
356const touch = (t: Trail): Trail => ({ ...t, touchedAt: Date.now() })
357
358/**
359 * 되감아 보여 줄 글. 어디서 부르든 같은 글이어야 한다.
360 *
361 * 번호가 범위 밖이면 1번으로 떨어뜨린다. 목차는 있는데 커서가 없다고 아무것도 안
362 * 보여 주는 것보다, 첫 항목이라도 보여 주는 쪽이 쓸모 있다.
363 */
364const recallText = (t: Trail | undefined, at?: number): string => {
365 if (!t || !Array.isArray(t.items) || t.items.length === 0) {
366 return '목차가 비어 있습니다. 설명을 먼저 들어야 되감을 것이 생깁니다.'
367 }
368 const want = at ?? t.cursor
369 const i = want >= 0 && want < t.items.length ? want : 0
370 const head = `${t.title}${fromOf(t)} — ${i + 1}. ${t.items[i]}`
371 const saved = t.notes?.[String(i)]
372 return saved ? `${head}\n\n${saved}` : `${head}\n\n(이 항목은 아직 설명이 저장되지 않았습니다.)`
373}
374
375/**
376 * 여러 항목을 한 번에 — `/recall 1` 이면 전체, `/recall 1.1,3` 이면 고른 것만.
377 *
378 * 번호는 이미 범위를 확인하고 들어온다. 저장 안 된 항목도 빼지 않고 그렇다고 적는다 —
379 * 빠져 있으면 원래 없던 항목인 줄 안다.
380 */
381const recallManyText = (t: Trail, picks: number[]): string => {
382 const whole = picks.length === t.items.length
383 const head = whole
384 ? `${t.title}${fromOf(t)} — 전체 ${t.items.length}개`
385 : `${t.title}${fromOf(t)} — ${picks.map(i => i + 1).join(', ')}번`
386 const parts = picks.map(i => {
387 const saved = t.notes?.[String(i)]
388 return `${i + 1}. ${t.items[i]}\n\n${saved || '(이 항목은 아직 설명이 저장되지 않았습니다.)'}`
389 })
390 return [head, ...parts].join('\n\n---\n\n')
391}
392
393/** 목차를 새로 깔면 그 주제의 진행 상태는 초기화된다 — 반쯤 남은 표시가 섞이면 더 헷갈린다 */
394const startTrail = (title: string, id: string, items: string[]): Trail => ({
395 title,
396 id,
397 slug: slugOf(title),
398 items,
399 cursor: items.length > 0 ? 0 : -1,
400 done: [],
401 notes: {},
402 touchedAt: Date.now(),
403})
404
405/** 지금 항목을 끝낸 것으로 치고 다음으로 */
406const advance = (t: Trail): Trail => {
407 if (t.cursor < 0) return t
408 const done = t.done.includes(t.cursor) ? t.done : [...t.done, t.cursor]
409 const next = t.cursor + 1
410 return { ...t, done, cursor: next < t.items.length ? next : -1 }
411}
412
413/**
414 * 오래 손 안 댔고 아직 "두기" 를 고르지 않은 주제들.
415 *
416 * 주제는 번호로 가리킨다(kept · current 둘 다 번호 문자열). 주소를 이름에서 번호로 바꿀 때
417 * 여기만 이름(slug)으로 비교하던 것이 남아, "두기" 를 눌러도 계속 묻고 지금 보는 주제까지
418 * 묵은 목록에 떴다.
419 */
420export const staleOf = (all: Trails, now: number = Date.now()): Trail[] => {
421 const line = now - STALE_DAYS * DAY
422 return Object.values(all.byTopic).filter(
423 // 곁가지는 묻지 않는다. 지우면 그 주제와 함께 지워진다
424 t => !t.parent && t.touchedAt < line && !all.kept.includes(t.id) && t.id !== all.current,
425 )
426}
427
428/**
429 * 한 번에 쓴 답을 번호 머리말로 잘라 항목별 본문으로 나눈다.
430 *
431 * 처음에는 항목마다 도구(focus)를 부르게 했다. 그러면 답이 도구 호출 사이로 여덟 토막이
432 * 나고, 모델은 그 틈에 본문 대신 "~를 정리했어요, 다음으로 넘어갈게요" 한 줄만 남겼다
433 * (2026-10-13 실제 화면 — 1~7번이 전부 한 줄 요약, 8번만 본문). 설명이 화면에 안 남았다.
434 *
435 * 그래서 답은 한 번에 끝까지 쓰게 하고, 여기서 `## 1. …` · `**2. …**` · `3. …` 같은
436 * 머리말을 찾아 자른다. 머리말 줄부터 다음 머리말 직전까지가 그 항목의 본문이다.
437 * 목차 범위 밖 번호(본문 속 번호 매긴 목록 등)는 머리말로 치지 않는다.
438 *
439 * @returns 항목 번호(0부터) → 본문. 머리말을 둘 이상 못 찾으면 빈 맵이다 — 한 덩어리 답은
440 * 쪼갤 근거가 없으니 지금 항목 하나에 담는 쪽으로 돌린다
441 */
442export const splitSections = (text: string, itemCount: number): Map<number, string> => {
443 const lines = text.split('\n')
444
445 // 표시가 붙은 머리말("## 3." · "**3.**")을 먼저 믿는다. 표시 없는 "3." 는 본문 속 번호
446 // 목록과 구별이 안 된다 — "1. 정합성 / 2. 확장성" 같은 목록의 2 가 다음 항목 머리말 자리에
447 // 맞아 떨어지면 항목이 엉뚱하게 잘린다. 표시 있는 머리말이 없을 때만 맨 번호로 자른다
448 const marked = /^\s*(?:#{1,4}\s*(?:\*\*)?|\*\*)\s*(\d{1,2})[.)]\s+\S/
449 const plain = /^\s*(\d{1,2})[.)]\s+\S/
450
451 const find = (head: RegExp) => {
452 const starts: { line: number; idx: number }[] = []
453 let expect = 1
454 lines.forEach((line, n) => {
455 const m = line.match(head)
456 if (!m) return
457 const no = Number.parseInt(m[1] ?? '', 10)
458 // 머리말은 1, 2, 3 … 순서로 나온다. 순서를 벗어난 번호는 머리말이 아니다
459 if (no === expect && no <= itemCount) {
460 starts.push({ line: n, idx: no - 1 })
461 expect += 1
462 }
463 })
464 return starts
465 }
466
467 const markedStarts = find(marked)
468 const starts = markedStarts.length >= 2 ? markedStarts : find(plain)
469
470 const out = new Map<number, string>()
471 if (starts.length < 2) return out
472 starts.forEach((st, k) => {
473 const end = k + 1 < starts.length ? (starts[k + 1]?.line ?? lines.length) : lines.length
474 const body = lines.slice(st.line, end).join('\n').trim()
475 if (body !== '') out.set(st.idx, body)
476 })
477 return out
478}
479
480/**
481 * 모델의 답을 지금 항목의 본문으로 담아도 되는가.
482 *
483 * 곁가지에 들어가 있으면 지금 주제가 그 곁가지다. 답은 곁가지 본문으로 담기고 부모 항목은
484 * 건드리지 않는다. 예전에는 부모를 덮어쓸까 봐 곁가지 답을 아예 버렸다(2026-10-14).
485 *
486 * 이번 턴에 도구를 안 불렀으면(`armed` 가 아니면) 담지 않는다. 그 답은 목차와 상관없는
487 * 대화다 — `/recall 1.1` 로 커서를 1번에 옮긴 뒤 다른 얘기를 하니, 그 답이 1번 본문을
488 * 덮어썼다(2026-10-13).
489 */
490export const captureTarget = (all: Trails, armed: boolean): Trail | undefined => {
491 if (!all.on || !armed) return undefined
492 const cur = all.byTopic[all.current]
493 if (!cur || cur.cursor < 0) return undefined
494 return cur
495}
496
497/** 며칠 전인지 — 사람이 읽는 말로 */
498const agoOf = (at: number): string => {
499 const days = Math.floor((Date.now() - at) / DAY)
500 return days >= 1 ? `${days}일 전` : '오늘'
501}
502
503export const register: Register = on => {
504 on('session.start', async ($, e, next) => {
505 try {
506 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
507 focusKey = focusKeyOf(JSON.parse(await $.fs.read(`${dir}/keybindings.json`)))
508 } catch {
509 // 파일이 없거나 못 읽으면 기본 키 그대로
510 }
511 await $.command.register({
512 name: 'trail',
513 description: '학습 경로 패널을 열고 쌓기 시작한다 (close 닫기 · off 그만 · drop N 지움)',
514 })
515 await $.command.register({
516 name: 'recall',
517 description: '지난 설명을 그대로 다시 본다 — /recall 1.2 (패널에 찍힌 번호)',
518 })
519
520 // 지난 세션에서 쌓아 둔 것을 되살린다
521 try {
522 const saved = (await $.store.get(STORE_KEY)) as Trails | undefined
523 if (saved && saved.byTopic) {
524 await update($, trails, () => migrate({ ...EMPTY, ...saved }))
525 }
526 } catch {
527 // 복원 실패는 조용히 넘긴다 — 빈 목차로 시작하면 된다
528 }
529
530 // 모델이 설명하면서 직접 부른다. 사람이 챙길 일이 없어야 한다
531 await $.tool.register({
532 name: 'trail',
533 description: [
534 '설명의 목차와 현재 위치를 패널에 표시한다.',
535 '여러 항목으로 나눠 설명할 때 set 으로 목차를 깔고,',
536 '항목을 옮길 때 focus, 곁가지 질문이 나오면 push,',
537 '그 질문에 답하고 원래 자리로 돌아올 때 pop 을 부른다.',
538 ].join(' '),
539 inputSchema: {
540 type: 'object',
541 properties: {
542 action: {
543 type: 'string',
544 enum: ['set', 'focus', 'next', 'push', 'pop', 'clear'],
545 description:
546 'set=목차 깔기, focus=항목으로 이동, next=다음 항목, push=곁가지 추가, pop=곁가지 닫기, clear=지금 주제 지우기',
547 },
548 title: { type: 'string', description: 'set 일 때 설명 전체 제목' },
549 items: {
550 type: 'array',
551 items: { type: 'string' },
552 description: 'set 일 때 목차 항목들 (순서가 번호). push 일 때는 곁가지 답이 여러 항목이면 그 목차',
553 },
554 index: { type: 'number', description: 'focus 일 때 항목 번호 (1부터)' },
555 label: { type: 'string', description: 'push 일 때 곁가지 질문 한 줄 (곁가지 제목이 된다)' },
556 },
557 required: ['action'],
558 },
559 })
560
561 return next(e)
562 })
563
564 /**
565 * 모델에게 이 도구가 있다는 걸 알려 준다.
566 *
567 * 도구만 등록해 두면 모델이 알아서 부르지 않는다 — 도구 설명은 "부를지 말지" 고민할
568 * 때나 읽히고, 설명을 쓰기 시작하면 그냥 쓴다. 실제로 그래서 패널이 빈 채로 있었다.
569 *
570 * `session` 쪽에 둔다. 이 문단은 이 사람의 세션에서만 읽히니 공용 캐시에 얹을 값이 아니다.
571 */
572 on('prompt.compose', async ($, e, next) => {
573 const result = await next(e)
574 // 꺼져 있으면 모델에게 아무 말도 하지 않는다. 그래야 끼어들지 않는다
575 if (!(await read($, trails)).on) return result
576 return {
577 sections: [
578 ...result.sections,
579 {
580 id: 'learn-trail:guide',
581 scope: 'session' as const,
582 text: [
583 '설명을 여러 항목으로 나눠 답할 때는 trail 도구로 목차를 남긴다.',
584 '1) 답을 쓰기 전에 trail 의 set 을 한 번 불러 목차를 깐다.',
585 '2) 그다음 답은 한 번에 끝까지 쓴다. 항목 사이에 도구를 부르지 않는다 —',
586 ' 부르면 답이 토막 나 설명이 화면에 온전히 남지 않는다.',
587 '3) 각 항목은 "## 1. 제목" 처럼 번호 머리말로 시작한다. 그 머리말로 항목이 나뉘어 저장된다.',
588 '4) 각 항목은 요약이 아니라 실제 설명을 쓴다. "정리했다" 같은 한 줄로 넘기지 않는다.',
589 '5) 중간에 멈추고 "다음으로 넘어갈까요?" 묻지 않는다. 사람이 "다음" 이라고 해야 넘어가는 게 아니라',
590 ' 목차 끝까지 한 번에 이어서 쓴다. 더 파고들지는 사람이 다 읽고 정한다.',
591 '6) 이미 깐 목차의 한 항목을 다시·더 설명할 때는 focus 로 그 항목을 짚은 뒤 쓴다.',
592 ' trail 을 부르지 않은 턴의 답은 저장되지 않는다.',
593 '사람이 중간에 다른 걸 물어 곁가지로 빠질 때는 답을 쓰기 전에 push, 그 답을 마치면 pop 을 부른다.',
594 ' 곁가지 답이 여러 항목이면 push 에 items 도 넘기고 각 항목을 "## 1. 제목" 으로 쓴다.',
595 ' 한 덩어리면 label 만 넘긴다. pop 뒤에 쓴 말은 저장되지 않는다.',
596 '한 갈래로 끝나는 짧은 답에는 쓰지 않는다.',
597 ].join('\n'),
598 },
599 ],
600 }
601 })
602
603 /**
604 * 패널 열기, 그리고 지우기.
605 *
606 * `/trail` · `/trail drop 2` · `/trail drop all`. 지우는 길이 패널의 ✕ 뿐이면
607 * 묵지 않은 주제는 버릴 수가 없다 — 끝난 주제는 바로 버리고 싶을 때가 있다.
608 */
609 /**
610 * 모델이 쓴 답을 지금 항목의 본문으로 담는다.
611 *
612 * 처음에는 도구의 `note` 로 받았다. 모델이 방금 쓴 글을 도구 인자에 통째로 다시 적는
613 * 방식인데, 길이가 길어지면 응답이 안전 분류기에 걸려 통째로 끊겼다(2026-10-08,
614 * `[reasoning_extraction]`). 같은 글을 두 번 쓰게 하는 설계부터 잘못이었다.
615 *
616 * 답이 대화에 들어오는 길목에서 그대로 주워 담는다. 모델은 아무것도 더 쓰지 않는다.
617 */
618 on('session.append', async ($, e, next) => {
619 const result = await next(e)
620 try {
621 // 사람이 새로 말을 걸면 새 턴이다. 이 턴의 답은 도구를 다시 불러야 본문이 된다
622 if (e.door === 'prompt') {
623 await update($, armed, () => false)
624 return result
625 }
626 // 모델이 쓴 답만. 사람의 말·도구 결과·알림은 본문이 아니다.
627 // 하위 에이전트의 답도 아니다 — 그건 사람에게 한 설명이 아니다
628 if (e.door !== 'response' || e.agentId) return result
629
630 const text = (e.message?.content ?? [])
631 .map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
632 .filter(Boolean)
633 .join('\n')
634 .trim()
635 // 한두 줄짜리 맞장구는 본문이 아니다
636 if (text.length < 120) return result
637
638 const all = await read($, trails)
639 const cur = captureTarget(all, await read($, armed))
640 if (!cur) return result
641
642 // 한 번에 쓴 답이면 머리말로 잘라 번호마다 담는다
643 const sections = splitSections(text, cur.items.length)
644 if (sections.size >= 2) {
645 const notes = { ...cur.notes }
646 sections.forEach((body, idx) => {
647 notes[String(idx)] = body
648 })
649 const written = [...sections.keys()].sort((a, b) => a - b)
650 const last = written[written.length - 1] ?? cur.cursor
651 await save($, {
652 ...all,
653 byTopic: {
654 ...all.byTopic,
655 [cur.id]: touch({
656 ...cur,
657 notes,
658 // 쓴 항목은 끝낸 것으로, 마지막으로 쓴 곳을 "지금" 으로 둔다
659 done: [...new Set([...cur.done, ...written.slice(0, -1)])],
660 cursor: last,
661 }),
662 },
663 })
664 return result
665 }
666
667 // 머리말이 없는 한 덩어리 답 — 지금 항목 하나에 담는다.
668 // 한 항목에 여러 번 답할 수 있다. 마지막 것이 가장 다듬어진 글이다
669 await save($, {
670 ...all,
671 byTopic: {
672 ...all.byTopic,
673 [cur.id]: touch({
674 ...cur,
675 notes: { ...cur.notes, [String(cur.cursor)]: text },
676 }),
677 },
678 })
679 } catch {
680 // 본문을 못 담아도 대화는 그대로 간다
681 }
682 return result
683 })
684
685 on('command.run', { command: 'trail' }, async ($, e) => {
686 const words = (e.args ?? '').trim().split(/\s+/).filter(Boolean)
687
688 // 패널만 닫는다. 쌓기는 그대로 — 화면이 좁을 때 치워 두는 것이지 그만두는 게 아니다.
689 // 그만두려면 /trail off. 둘을 한 명령에 묶으면 "잠깐 치우기" 를 할 수 없다
690 if (words[0] === 'close') {
691 await $.ui.close({ id: PANE }).catch(() => {})
692 return { text: '패널을 닫았습니다. 기록은 계속됩니다 (다시 열기 /trail · 그만 쌓기 /trail off).' }
693 }
694
695 if (words[0] === 'off') {
696 const all = await read($, trails)
697 await save($, { ...all, on: false })
698 return { text: '그만 쌓습니다. 쌓인 것은 /recall 로 그대로 볼 수 있습니다.' }
699 }
700
701 if (words[0] === 'drop') {
702 const all = await read($, trails)
703 const what = words[1] ?? ''
704
705 if (what === 'all') {
706 // 다 지웠으면 1번부터 다시 센다. 번호를 안 되살리는 건 남은 주제와 겹치지 않게
707 // 하려는 것인데, 남은 게 없으면 겹칠 것도 없다. 켜짐 상태는 그대로 둔다
708 await save($, { ...EMPTY, on: all.on })
709 return { text: '주제를 전부 지웠습니다. 다음 주제는 1번부터 시작합니다.' }
710 }
711
712 // 번호를 안 주면 지금 주제. 가장 흔한 경우다
713 const key = what === '' ? all.current : what
714 const gone = all.byTopic[key]
715 if (!gone) return { text: `${key || '지금'} 주제가 없습니다.` }
716
717 await save($, without(all, key))
718 return { text: `${gone.id}. ${gone.title} — 지웠습니다.` }
719 }
720
721 // 패널을 여는 것 자체가 "쓰겠다" 는 뜻이다. 따로 켜게 하면, 열어 놓고 안 쌓이는
722 // 걸 보며 왜 비었는지 묻게 된다 — 실제로 그랬다. 끄는 것만 따로 둔다
723 const all = await read($, trails)
724 if (!all.on) await save($, { ...all, on: true })
725 await $.ui.open({ id: PANE, title: '학습 경로' }).catch(() => {})
726 return {
727 text: all.on
728 ? '학습 경로 패널을 열었습니다.'
729 : '학습 경로를 열었습니다. 설명이 여러 갈래로 나뉘면 목차가 쌓입니다 (그만하려면 /trail off).',
730 }
731 }).catch(($, e, next) => {
732 if (next.called) return next(e)
733 return { text: `패널 열기 실패 — ${String(next.error?.message ?? next.error)}` }
734 })
735
736 /**
737 * 지난 설명을 대화로 받는다.
738 *
739 * 패널에서 눌러 대화에 글을 띄우는 길은 없었다 — `$.command.run` 은 결과를 부른 자리로
740 * 돌려줄 뿐이고, `$.session.append` 는 행을 넣어도 화면에 그려지지 않는다(system 도
741 * user 도 그랬다). 그래서 대화로 받는 길은 이 명령 하나다.
742 *
743 * `/recall hikari 1` · `/recall 1` · `/recall hikari` · `/recall` 을 모두 받는다.
744 * 어느 쪽이 주제고 어느 쪽이 번호인지는 생김새로 가른다 — 순서를 외우게 하지 않는다.
745 */
746 on('command.run', { command: 'recall' }, async ($, e) => {
747 const all = await read($, trails)
748 const { topic, name, picks, bad } = parseArgs(e.args ?? '')
749
750 if (bad !== undefined) {
751 return { text: `"${bad}" 은 읽을 수 없습니다. 예: /recall 1 · 1.2 · 1.1,3 · 1.2-4` }
752 }
753
754 const list = () =>
755 Object.values(all.byTopic)
756 .sort(byId)
757 .map(x => `${x.id}. ${x.title}${fromOf(x)} (/recall ${x.id}.1)`)
758
759 let t = currentOf(all)
760 if (topic !== undefined) {
761 t = all.byTopic[topic]
762 if (!t) {
763 return { text: `${topic}${/^\d+$/.test(topic) ? '번' : ''} 주제가 없습니다.\n\n들고 있는 것:\n- ${list().join('\n- ')}` }
764 }
765 } else if (name !== undefined) {
766 // 번호가 기억 안 날 때를 위해 이름도 받는다. 앞부분만 쳐도 찾는다
767 const hit = Object.values(all.byTopic).find(
768 x => x.slug.startsWith(name) || x.title.toLowerCase().includes(name),
769 )
770 if (!hit) {
771 return {
772 text:
773 list().length > 0
774 ? `"${name}" 에 맞는 주제가 없습니다.\n\n들고 있는 것:\n- ${list().join('\n- ')}`
775 : '아직 들고 있는 주제가 없습니다.',
776 }
777 }
778 t = hit
779 }
780
781 if (!t) return { text: '아직 들고 있는 주제가 없습니다.' }
782
783 // 없는 번호면 그렇다고 말한다. 조용히 다른 항목을 보여 주면 그게 맞는 글인 줄 안다
784 const missing = (picks ?? []).filter(i => i < 0 || i >= t.items.length)
785 if (missing.length > 0) {
786 const nos = missing.map(i => i + 1).join(', ')
787 return { text: `${t.id}번 주제에는 ${nos}번 항목이 없습니다 (1 ~ ${t.items.length}).` }
788 }
789
790 // 주제만 짚으면 전체다. 아무것도 안 짚으면 지금 자리 하나
791 const named = topic !== undefined || name !== undefined
792 const want = picks ?? (named ? t.items.map((_, i) => i) : undefined)
793
794 // 여럿을 보여 줄 때는 패널의 "지금" 을 그대로 둔다. 어느 하나로 옮길 근거가 없다
795 if (want && want.length > 1) {
796 await save($, { ...all, current: t.id, byTopic: { ...all.byTopic, [t.id]: touch(t) } })
797 return { text: recallManyText(t, want) }
798 }
799
800 // 부른 자리로 패널의 "지금" 도 옮긴다. 보여 준 것과 패널이 다르면 그게 더 헷갈린다.
801 // 위치와 글은 같은 번호에서 나와야 한다
802 const i = want?.[0] ?? t.cursor
803 await save($, {
804 ...all,
805 current: t.id,
806 byTopic: { ...all.byTopic, [t.id]: touch({ ...t, cursor: i }) },
807 })
808 return { text: recallText(t, i) }
809 }).catch(($, e, next) => {
810 if (next.called) return next(e)
811 return { text: `다시 보기 실패 — ${String(next.error?.message ?? next.error)}` }
812 })
813
814 on('tool.call', { tool: 'mcp__learn-trail__trail' }, async ($, e) => {
815 // 도구 인자는 e 안에 그대로 실린다 (tool · tool_use_id 같은 예약 키와 나란히).
816 // e.input 이 아니다 — 그걸로 읽으면 action 이 undefined 로 온다
817 const input = e as unknown as {
818 action?: string
819 title?: string
820 items?: string[]
821 index?: number
822 label?: string
823 text?: string
824 }
825
826 let line = ''
827
828 // 상태 갱신이 실패해도 호출 자체는 성공으로 돌려준다.
829 // 패널이 안 그려지는 것보다 설명이 끊기는 게 나쁘다
830 try {
831 const all = await read($, trails)
832 if (!all.on) {
833 return { result: '학습 경로가 꺼져 있다 (/trail 로 켠다)', text: '학습 경로가 꺼져 있다 (/trail 로 켠다)' }
834 }
835 const cur = currentOf(all)
836
837 /** 그 주제를 지금 주제로 세우고 저장할 모양을 만든다 */
838 const put = (t: Trail): Trails => ({
839 ...all,
840 current: t.id,
841 byTopic: { ...all.byTopic, [t.id]: touch(t) },
842 })
843
844 switch (input.action) {
845 case 'set': {
846 const title = input.title ?? ''
847 const items = input.items ?? []
848 const no = all.nextNo
849 line = `목차 ${items.length}개 — ${title} (/recall ${no}.1)`
850 await save($, {
851 ...put(startTrail(title, String(no), items)),
852 // 지운 번호는 다시 쓰지 않는다. 같은 번호가 다른 주제를 가리키면 헷갈린다
853 nextNo: no + 1,
854 })
855 break
856 }
857 case 'focus': {
858 if (!cur) {
859 line = '목차가 없다'
860 break
861 }
862 // 사람이 보는 번호는 1부터, 안에서는 0부터
863 const i = Math.max(0, Math.min((input.index ?? 1) - 1, cur.items.length - 1))
864 const done =
865 cur.cursor >= 0 && cur.cursor < i && !cur.done.includes(cur.cursor)
866 ? [...cur.done, cur.cursor]
867 : cur.done
868 line = `${i + 1}번으로 — ${cur.items[i] ?? ''}`
869 await save($, put({ ...cur, cursor: i, done }))
870 break
871 }
872 case 'next': {
873 if (!cur) {
874 line = '목차가 없다'
875 break
876 }
877 const moved = advance(cur)
878 line =
879 moved.cursor < 0
880 ? '목차 끝'
881 : `${moved.cursor + 1}번으로 — ${moved.items[moved.cursor]}`
882 await save($, put(moved))
883 break
884 }
885 case 'note': {
886 if (!cur) {
887 line = '본문을 붙일 목차가 없다'
888 break
889 }
890 // 번호를 안 주면 지금 항목에 남긴다
891 const i = input.index !== undefined ? input.index - 1 : cur.cursor
892 if (i < 0 || i >= cur.items.length) {
893 line = '본문을 붙일 항목이 없다'
894 break
895 }
896 line = `본문 저장 (/recall ${cur.id}.${i + 1})`
897 await save($, put({ ...cur, notes: { ...cur.notes, [String(i)]: input.text ?? '' } }))
898 break
899 }
900 case 'push': {
901 if (!cur) {
902 line = '목차가 없다'
903 break
904 }
905 // 곁가지도 주제 하나다. 목차를 안 주면 질문 한 줄이 한 칸짜리 목차가 된다
906 const label = input.label ?? '곁가지'
907 const items = input.items && input.items.length > 0 ? input.items : [label]
908 // 큰 주제 번호는 건드리지 않는다. 곁가지는 부모 이름 뒤 빈 글자를 받는다
909 const id = nextBranchId(all, cur.id)
910 if (!id) {
911 line = `${cur.id} 의 곁가지가 26개를 넘었다 — 하나를 지우거나 새 주제로 연다`
912 break
913 }
914 const at = Math.max(0, cur.cursor)
915 line = `곁가지 열림 — ${label} (/recall ${id})`
916 await save($, put({ ...startTrail(label, id, items), parent: { id: cur.id, at } }))
917 break
918 }
919 case 'pop': {
920 const up = cur?.parent ? all.byTopic[cur.parent.id] : undefined
921 if (!cur || !up) {
922 line = '닫을 곁가지 없음'
923 break
924 }
925 // 곁가지는 지우지 않는다. 지금 자리만 부모로 돌린다 — 나중에 /recall 로 다시 본다
926 line = `곁가지 닫음 — ${cur.title} (/recall ${cur.id})`
927 await save($, put(up))
928 await update($, armed, () => false)
929 break
930 }
931 case 'clear': {
932 if (!cur) {
933 line = '지울 것 없음'
934 break
935 }
936 line = `"${cur.title}" 지움`
937 await save($, without(all, cur.id))
938 break
939 }
940 default: {
941 line = `모르는 action: ${input.action}`
942 }
943 }
944
945 // 목차를 깔거나 항목을 옮긴 턴이다 — 이 턴의 답은 그 항목의 본문이다
946 if (ARMING.includes(input.action ?? '')) await update($, armed, () => true)
947 } catch (err) {
948 line = `패널 갱신 실패 — ${String(err)}`
949 }
950
951 // 목차를 깔면 패널이 없을 때 열어 준다. 설명이 시작되는 순간이 열기 좋은 때다.
952 // 여는 데 실패해도 삼킨다 — 패널이 안 떠도 설명은 계속돼야 한다
953 if (input.action === 'set') {
954 void $.ui.open({ id: PANE, title: '학습 경로' }).catch(() => {})
955 }
956
957 // result 는 문자열·배열만 받는다. 객체를 돌려주면 호출이 통째로 거부된다
958 return { result: line, text: line }
959 }).catch(($, e, next) => {
960 // 이 훅은 호출을 스스로 답한다(next 를 부르지 않는다). 훅이 터지면 엔진이 그 자리를
961 // 대신 메우는데, 그때 "패널이 됐다"고 오해할 답이 가면 안 된다 — 안 됐다고 말한다.
962 // 설명은 계속돼야 하므로 거부가 아니라 성공으로, 사유만 실어 돌려준다
963 if (next.called) return next(e)
964 const why = `패널 갱신 실패 — ${String(next.error?.message ?? next.error ?? '알 수 없음')}`
965 return { result: why, text: why }
966 })
967
968 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
969 const { Box, Text, Button } = $.ui.resolve(e)
970 const all = await read($, trails)
971 const cur = currentOf(all)
972 const stale = staleOf(all)
973 // 곁가지는 지금 주제 안에 한 줄씩 그려진다. 아래 목록에는 맨 위 주제만 둔다
974 const top = cur ? rootOf(all, cur) : undefined
975 const others = Object.values(all.byTopic)
976 .filter(t => !t.parent && t.id !== top?.id)
977 .sort(byId)
978
979 if (!cur && others.length === 0) {
980 return (
981 <Box flexDirection="column">
982 <Text dimColor>아직 목차가 없습니다.</Text>
983 {all.on ? (
984 <Text dimColor>설명이 여러 갈래로 나뉠 때 자동으로 채워집니다.</Text>
985 ) : (
986 <Text dimColor>/trail 하면 쌓이기 시작합니다.</Text>
987 )}
988 </Box>
989 )
990 }
991
992 /** 주제 하나를 버린다. 지운 번호는 다시 쓰지 않는다 */
993 const drop = async (key: string) => {
994 await save($, without(await read($, trails), key))
995 }
996
997 const folded = cur ? all.collapsed.includes(cur.id) : false
998
999 /**
1000 * 입력칸에 `/recall 번호` 를 채운다. 보내는 건 사람이 Enter 로 한다.
1001 * 칸이 안 받으면(대화상자가 떠 있거나 입력칸이 없는 세션) 칠 말을 알려 준다
1002 */
1003 const ask = async (no: string) => {
1004 const text = `/recall ${no}`
1005 const r = await $.prompt.fill({ text }).catch(() => ({ isFilled: false }))
1006 if (!r.isFilled) void $.ui.notify(`${text} 를 치면 그 설명을 다시 봅니다`).catch(() => {})
1007 }
1008
1009 /** 그 주제로 옮긴다 */
1010 const go = async (id: string) => {
1011 const now = await read($, trails)
1012 await save($, { ...now, current: id })
1013 }
1014
1015 /** 접거나 편다 */
1016 const fold = async (key: string) => {
1017 const now = await read($, trails)
1018 const was = now.collapsed.includes(key)
1019 await save($, {
1020 ...now,
1021 collapsed: was ? now.collapsed.filter(k => k !== key) : [...now.collapsed, key],
1022 })
1023 }
1024
1025 const up = cur?.parent ? all.byTopic[cur.parent.id] : undefined
1026
1027 return (
1028 <Box flexDirection="column">
1029 {cur && (
1030 <Box flexDirection="column">
1031 {/* 지금 있는 목차 하나만 그린다. 부모와 곁가지를 같이 펼치면 한 단계 들어갈
1032 때마다 패널이 길어지고 들여쓰기만큼 글이 잘렸다(2026-10-14). 어디서 왔는지는
1033 머리줄 경로가 말한다 — 몇 단계를 들어가도 패널 모양은 같다.
1034 항목 줄은 누르면 입력칸에 `/recall 1.3` 을 채운다. Enter 한 번 더로 저장본을 받는다.
1035 바로 보내지(prompt.submit) 않는다 — 그건 모델에게 가는 새 턴이라 설명을 다시 쓰고,
1036 다시 쓰면 말이 달라진다. 입력칸까지만 가면 /recall 이 저장본을 그대로 꺼낸다
1037 (2026-10-16. 그전에는 버튼에서 대화로 가는 길이 없는 줄 알고 글로만 뒀다) */}
1038 <Box>
1039 <Button
1040 key={`fold-${cur.id}`}
1041 plain
1042 autoFocus
1043 label={`${folded ? '▸' : '▾'} ${crumbOf(all, cur)}`}
1044 onPress={() => fold(cur.id)}
1045 />
1046 <Button
1047 key={`x-${cur.id}`}
1048 plain
1049 dimColor
1050 label=" ✕"
1051 onPress={() => drop(cur.id)}
1052 />
1053 </Box>
1054
1055 {!folded &&
1056 cur.items.flatMap((item, i) => {
1057 const isNow = i === cur.cursor
1058 const isDone = cur.done.includes(i)
1059 const mark = isDone ? '✓' : isNow ? '▸' : ' '
1060 // 점(●)은 "본문이 저장돼 있다" — /recall 로 꺼낼 것이 있다는 표시다
1061 const dot = cur.notes?.[String(i)] ? ' ●' : ''
1062 const row = (
1063 <Button
1064 key={`i-${cur.id}-${i}`}
1065 plain
1066 dimColor={isDone && !isNow}
1067 label={`${mark} ${cur.id}.${i + 1} ${item}${dot}${isNow ? ' ← 지금' : ''}`}
1068 onPress={() => ask(`${cur.id}.${i + 1}`)}
1069 />
1070 )
1071 // 여기서 갈라진 곁가지는 제목 한 줄씩만. 펼치지 않는다 — 들어가서 본다
1072 const kids = childrenAt(all, cur.id, i).map(c => (
1073 <Button
1074 key={`b-${c.id}`}
1075 plain
1076 dimColor
1077 label={` ↳ ${c.id}. ${c.title}${c.items.length > 1 ? ` (${c.items.length})` : ''}`}
1078 onPress={() => go(c.id)}
1079 />
1080 ))
1081 return [row, ...kids]
1082 })}
1083
1084 {!folded && up && (
1085 <Button
1086 key={`up-${up.id}`}
1087 plain
1088 dimColor
1089 label={`↰ ${up.id}.${(cur.parent?.at ?? 0) + 1} 로 돌아가기`}
1090 onPress={() => go(up.id)}
1091 />
1092 )}
1093 </Box>
1094 )}
1095
1096 {/* 오래 묵은 주제는 한 번 묻는다. 안 물으면 패널이 길어지기만 한다.
1097 "두기" 를 고르면 다시 묻지 않는다 — 켤 때마다 같은 걸 물으면 그게 성가시다 */}
1098 {stale.length > 0 && (
1099 <Box flexDirection="column" paddingTop={1}>
1100 <Text dimColor>{`${STALE_DAYS}일 넘게 안 본 주제 — 지울까요?`}</Text>
1101 {stale.map(t => (
1102 <Box key={`stale-${t.id}`}>
1103 <Button
1104 key={`drop-${t.id}`}
1105 plain
1106 label={` ✕ ${t.id}. ${t.title} (${agoOf(t.touchedAt)})`}
1107 onPress={() => drop(t.id)}
1108 />
1109 <Button
1110 key={`keep-${t.id}`}
1111 plain
1112 dimColor
1113 label=" 두기"
1114 onPress={async () => {
1115 const now = await read($, trails)
1116 await save($, { ...now, kept: [...now.kept, t.id] })
1117 }}
1118 />
1119 </Box>
1120 ))}
1121 </Box>
1122 )}
1123
1124 {/* 지금 주제와 같은 모양으로 세운다 — 다르게 생기면 "주석" 처럼 읽혀서
1125 눌러도 되는 줄인지 모른다. 색만 죽인다. 누르면 그 주제가 열린다 */}
1126 {others.length > 0 && (
1127 <Box flexDirection="column" paddingTop={1}>
1128 {others.map(t => (
1129 <Box key={`row-${t.id}`}>
1130 <Button
1131 key={`go-${t.id}`}
1132 plain
1133 dimColor
1134 label={`▸ ${t.id}. ${t.title}`}
1135 onPress={() => go(t.id)}
1136 />
1137 <Button
1138 key={`gx-${t.id}`}
1139 plain
1140 dimColor
1141 label=" ✕"
1142 onPress={() => drop(t.id)}
1143 />
1144 </Box>
1145 ))}
1146 </Box>
1147 )}
1148
1149 {/* 쓸 수 있는 말을 상자에 모은다. 목차 줄들과 섞여 흐르면 안내인지 항목인지
1150 구분이 안 된다 — 테두리가 그 경계를 긋는다 */}
1151 {/* 명령과 설명을 두 칸으로 나눈다. 공백으로 줄을 맞추면 한글·"—"·"·" 의 폭을
1152 사람이 세야 하는데, 터미널마다 달라 어긋났다(2026-10-13). 왼쪽 칸 폭을 고정해
1153 엔진이 재게 한다.
1154 명령(영문)을 왼쪽에 둔다. 설명을 왼쪽에 두니 오른쪽 칸이 "/" 로 시작하는 줄과
1155 한글로 시작하는 줄이 섞였고, 칸은 맞아도 글자 모양 탓에 한글 줄이 한 칸 당겨져
1156 보였다. 오른쪽 칸을 전부 한글로 시작하게 하면 그런 줄이 없다 */}
1157 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
1158 <Text dimColor>{all.on ? '쌓는 중' : '멈춤'}</Text>
1159 {[
1160 all.on ? ['/trail off', '그만 쌓기'] : ['/trail', '다시 쌓기'],
1161 ['/recall 1.2', '설명 하나 받기'],
1162 ['/recall 1', '주제 전체 받기'],
1163 ['/recall 1.1,3 1.2-4', '여러 항목 · 범위'],
1164 ['/trail drop 1', '주제 지우기'],
1165 ['/trail close', '패널 닫기'],
1166 [focusKey, '패널로 들어오기'],
1167 ['Enter', '주제 접기 · 바꾸기 · 항목 다시 보기'],
1168 ].map(([how, what]) => (
1169 <Box key={how} flexDirection="row">
1170 <Box width={HELP_HOW} flexShrink={0}>
1171 <Text dimColor>{how}</Text>
1172 </Box>
1173 <Text dimColor>{what}</Text>
1174 </Box>
1175 ))}
1176 </Box>
1177 </Box>
1178 )
1179 })
1180}
1181types/index.d.ts 78 lines1/** 설명 한 묶음 — 목차와 지금 어디인지, 그리고 곁가지 스택 */
2export type Trail = {
3 /** 설명 전체의 제목 (예: "Hikari 커넥션 풀") */
4 title: string
5 /**
6 * 주제 이름. 큰 주제는 "1" · "2" … 번호, 곁가지는 부모 이름 뒤에 글자를 붙인 "2a" · "2b".
7 *
8 * `/recall 2.3` 의 그 `2`, `/recall 2a.3` 의 그 `2a` 다. 제목에서 이름을 뽑아 봤지만
9 * ("Hikari 커넥션 풀" → hikari) 제목에 따라 들쭉날쭉하고, 순한글 제목에서는 칠 만한 것이
10 * 안 나온다. 곁가지도 번호를 받게 했더니 곁가지가 큰 주제 번호를 잡아먹어 1, 2 다음이
11 * 4 가 됐다(2026-10-14). 큰 주제는 번호로 이어지고, 곁가지는 어디 붙었는지가 이름에 보인다.
12 */
13 id: string
14 /** 제목에서 뽑은 이름. 번호 대신 쳐도 찾아 준다 — 기억나는 쪽으로 부르면 된다 */
15 slug: string
16 /** 목차 항목들. 순서가 곧 번호다 */
17 items: string[]
18 /** 지금 설명 중인 항목 (0부터). 아직 안 들어갔으면 -1 */
19 cursor: number
20 /** 끝낸 항목 번호들 */
21 done: number[]
22 /**
23 * 항목별 본문 — 설명을 끝낼 때 그 글을 그대로 남긴다.
24 * 나중에 그 번호를 부르면 다시 쓰지 않고 이걸 꺼낸다. 다시 쓰면 말이 달라진다.
25 * 키는 항목 번호(0부터)를 문자열로 쓴다.
26 */
27 notes: Record<string, string>
28 /**
29 * 곁가지면 어디서 갈라졌는지 — 그 주제 이름과 항목(0부터).
30 *
31 * 곁가지도 주제 하나로 둔다. 예전에는 이름표만 쌓고 답은 버렸다(덮어쓸까 봐). 그러니
32 * 곁가지 답은 다시 볼 길이 없었고, 닫으면 이름표마저 사라졌다(2026-10-14). 주제로 두면
33 * 목차·본문 저장·/recall 이 고칠 것 없이 그대로 돈다.
34 */
35 parent?: { id: string; at: number }
36 /** 마지막으로 손댄 때 (epoch ms). 오래된 주제를 골라내는 기준 */
37 touchedAt: number
38}
39
40/**
41 * 들고 있는 주제 전부.
42 *
43 * 하나만 들고 있으면 새 설명이 시작될 때마다 지난 것이 날아간다. 이 mod 의 쓸모가
44 * "나중에 다시 보기" 라서 그건 반쪽이다 — 주제별로 따로 둔다.
45 */
46export type Trails = {
47 /**
48 * 자동으로 쌓을지. 기본은 꺼짐이다.
49 *
50 * 켜 두면 설명이 갈래질 때마다 끼어든다. 늘 필요한 것이 아니라 — 뭘 배우려고 들어온
51 * 자리에서만 켜면 된다. 꺼져 있어도 이미 쌓인 것은 /recall 로 그대로 본다.
52 */
53 on: boolean
54 /** 주제 이름("2" · "2a") → 그 주제의 목차 */
55 byTopic: Record<string, Trail>
56 /** 지금 보고 있는 주제 이름. 없으면 빈 문자열 */
57 current: string
58 /**
59 * 다음에 내줄 큰 주제 번호. 지운 번호는 다시 쓰지 않는다 — 같은 번호가 다른 주제를 가리키면
60 * 헷갈린다. 곁가지 글자는 여기서 세지 않고 지운 자리를 채운다(nextBranchId).
61 */
62 nextNo: number
63 /** 목차를 접어 둔 주제 이름들 — 제목만 남기고 항목을 숨긴다 */
64 collapsed: string[]
65 /**
66 * 오래됐다고 물어본 주제 이름들.
67 *
68 * 한 번 "두기" 를 고르면 다시 묻지 않는다. 켤 때마다 같은 것을 물으면 그게 더 성가시다.
69 */
70 kept: string[]
71}
72
73declare module 'claude-code' {
74 interface PluginState {
75 'learn-trail': { trails: Trails; armed: boolean }
76 }
77}
78