SLOPSHOPPER

learn-trail

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

newpaneguardcommandstatusprompt
v0.3.1MITupdated 2026-10-16UlongChaS2/learn-trail-mods/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · learn-trail
│ ┃ 학습 경로 ✕ › fix the failing auth test and add an audit log call │ ┃ 아직 목차가 없습니다. │ ┃ 설명이 여러 갈래로 나뉠 때 자동으로 ⏺ Read(src/auth.ts) │ ┃ 채워집니다. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /trail │ ⎿ learn-trail: 학습 경로를 열었습니다. 설명이 여러 갈래로 나뉘면 목차가 쌓입니다 (그만하려면 /trail │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 학습 경로
아직 목차가 없습니다. 설명이 여러 갈래로 나뉠 때 자동으로 채워집니다.
README

learn-trail

English | 한국어

License: MIT Claude Code Version

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>

Features

  • Outline pane — topics, sections, the current position and side questions, at a glance
  • Verbatim replay — /recall returns the saved text, not a regenerated answer
  • Pick what to replay — a whole topic, one section, a list or a range
  • Persistent — kept in plugin storage across sessions; stale topics are offered for cleanup
  • Zero ceremony — once turned on, Claude lays out the outline by itself

Installation

Installing 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
  1. Answer y to Add marketplace?
  2. Pick a scope — the user scope (first) makes it available in every session
  3. You should see 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.

Quick start

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

Commands

CommandWhat it does
/trailOpen the pane and start recording
/trail closeClose the pane (recording continues)
/trail offStop recording (saved notes stay)
/recallCurrent section of the current topic
/recall 1All of topic 1
/recall 1.2Section 2 of topic 1
/recall 1.1,3 · /recall 1.2-4A list · a range (mix them: 1.1,3-5)
/recall 2a · /recall 2a.3Side question a of topic 2 (whole · section 3)
/recall hikari · /recall hikari 2By title prefix when you forget the number (whole topic · one section)
/trail drop 1 · /trail drop 2aDelete a topic or side question (no number = current); its own side questions go with it
/trail drop allDelete 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).

Side questions

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

Changing the pane key

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" } }] }
When the pane key stops working under a non-Latin input source

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

How it works

  • Outline — the mod registers a 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.
  • Capture — Claude's reply is picked up as it enters the conversation (session.append) and split on its numbered headings. Claude never writes the same text twice.
  • A reply from a turn that did not call trail (set · focus · next) is not saved, so unrelated chat can't overwrite a saved section.
  • After push, replies are saved to the side question, never over the parent's section. Text written after pop is not saved.
  • Storage — plugin storage ($.store), local to you, kept across sessions.

Limitations

  • Pressing Enter on a section row fills /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.
  • Number shortcuts go up to 9. An outline longer than nine sections is a sign the explanation is too long anyway.
  • UI text and the instructions given to Claude are Korean only, for now.

Development

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

Contributing

Issues and pull requests are welcome. Please run claude plugin test and claude plugin validate before opening a PR.

License

MIT © UlongChaS2

Source 2 files
hooks/register.tsx 1181 lines
1import { 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}
1181
types/index.d.ts 78 lines
1/** 설명 한 묶음 — 목차와 지금 어디인지, 그리고 곁가지 스택 */
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