에이전트가 계획·진행상황·종료조건을 탭별 노트로 기록하고, 사용자가 옆 창에서 바로 확인하는 모드

에이전트가 작업 계획·진행 상황·종료 조건을 탭별 노트로 적고, 사용자는 옆 창에서 바로 확인한다. 대화를 위로 올려 "아까 거 어디까지 했지?"를 찾지 않아도 되게 하는 것이 목적이다.
⛔ 배포): 그 탭을 마지막으로 다룬 세션이 일하는 중이면 초록 ●, 허락 창·질문 창을 띄웠으면 청록 💬, 모든 단계가 끝나면 흐린 ✅. 겹치면 ⛔ → 💬 → ⏳ → ● → ✅ 순서로 하나만. 세션 상태는 <.claude>/notes-data/live/<세션 id>.json 에 따로 쓰고, 3분 넘게 소식이 없으면 무시한다./notes 를 입력할 때마다 창이 열리고 닫힌다. 창 안에는 닫기 버튼을 두지 않는다([탭 지우기]와 헷갈리지 않게). 우상단 × 로도 닫힌다./config 의 「노트 공유 범위」로 전체(기본) / 같은 폴더 / 세션마다(공유 안 함) 중 고른다(새 세션부터).somegee-skills 마켓플레이스에서 설치한다.
claude plugin marketplace add somegee/somegee-skills
claude plugin install notes@somegee-skills --scope user
이미 켜져 있는 세션에서는 /reload-plugins, 그다음 /notes 로 옆 창을 연다. 여러 PC가 같은 판을 쓰려면 /plugin → Marketplaces → somegee-skills → 자동 업데이트를 켠다. 코드를 고칠 때마다 .claude-plugin/plugin.json 의 version 을 올려야 다른 PC에 반영된다.
~/.claude/notes-data/ 아래에 있다(플러그인 버전이 바뀌어도 유지): 전체 = _all.json, 같은 폴더 = <작업폴더>.json, 세션마다 = sessions/<세션 id>.json. PC마다 따로이고 외부로 아무것도 보내지 않는다.전체 로 쓸 때 _all.json 이 없으면 지금까지 폴더별로 쓰던 노트 탭을 모아 만든다(같은 이름의 탭은 최근 것, 최대 30개). 폴더별 파일은 그대로 남으므로 같은 폴더 로 되돌리면 예전 노트가 다시 보인다./config 에서 notes 의 「언어 / Language」를 한국어(기본) 또는 English 로 고른다. 창 문구·도구 설명·에이전트 사용 규칙이 함께 바뀌고, 새 세션부터 적용된다./config 에서 notes 의 「창 테마」를 기본(터미널 색 그대로) / 어둡게 / 밝게 / 고대비 중 고른다. 「창 배경색 직접 지정」에 #1e2230 처럼 색 코드를 넣으면 배경만 그 색으로 바뀐다(글자색은 테마를 따름). 터미널 창에만 적용되고 새 세션부터 반영된다./config 에서 notes 의 「세션 시작 시 notes 창 자동 열기」를 끈다.claude -p·SDK 같은 헤드리스 세션은 기본으로 노트를 건드리지 않는다. 그런 세션도 노트를 쓰게 하려면 /config 에서 notes 의 「헤드리스 세션에서도 노트 쓰기」를 켠다(새 세션부터)./notes 로 연다.claude -p(무인 실행)에서는 창이 그려지지 않는다.claude --plugin-dir <이 폴더> 로 띄우고 고친 뒤 /reload-plugins.claude plugin uninstall notes@somegee-skills
notes is a Claude Code mod: the agent writes its plan, progress and finish line into per-task tabs, and you watch them in a side pane — no more scrolling back to find "where were we?".
claude plugin marketplace add somegee/somegee-skills then claude plugin install notes@somegee-skills --scope user./config, find notes → "언어 / Language" and pick English. The pane, tool descriptions and the agent guide switch to English from the next session.기본 = terminal colors, 어둡게 dark, 밝게 light, 고대비 high contrast) in /config; "창 배경색 직접 지정" takes a hex color such as #1e2230 to override just the background. Terminal pane only, from the next session.claude -p, SDK) leave the notes alone by default. To let them write notes, turn on "Use notes in headless sessions" under notes in /config (applies from the next session).같은 폴더 (same project folder only) or 세션마다 (no sharing, one note per session) under "노트 공유 범위 / Who shares the notes" in /config (applies from the next session). A tab whose open steps are blocked turns red with ⛔; waiting only, yellow with ⏳. A green ● means the session that last touched the tab is working, a cyan 💬 that it shows a permission or question dialog, and a dim ✅ that every step is done.claude -p, cron, SDK) and subagents never touch the notes.hooks/register.tsx 919 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import { callSummary, toCallDisplay } from './call-summary'
5import { TEXT, toLang, type Strings } from './i18n'
6import { BASE_THEME, toTheme, type NotesTheme } from './theme'
7import { mergeTabs, noteFileName, SHARED_FILE, toShareScope, type ShareScope } from './share-scope'
8import type { LiveState, NoteChange, NoteChangePart, NoteItem, NoteItemStatus, NotesBook, NoteSession, NoteTab } from '../types'
9
10const PANE = 'notes'
11// 화면·도구 설명·사용 규칙의 문구. register 가 설정 language 로 고른다(기본 한국어).
12let L: Strings = TEXT.ko
13const MAX_TABS = 30
14const MAX_HISTORY = 20
15const book = atom({ plugin: 'notes', key: 'book' } as const, { root: '', tabs: [], history: [], future: [] } as NotesBook)
16const scroll = atom({ plugin: 'notes', key: 'scroll' } as const, 0)
17const tick = atom({ plugin: 'notes', key: 'tick' } as const, 0)
18// 접어 둔 구역. 키 = '<탭 이름>|<구역>'. 이 세션에서만 유지된다(다른 세션·파일과 공유하지 않음).
19const live = atom({ plugin: 'notes', key: 'live' } as const, {} as Record<string, LiveState>)
20const folded = atom({ plugin: 'notes', key: 'folded' } as const, {} as Record<string, boolean>)
21
22const SYNC_MS = 3000
23// 'N분 전 갱신' 이 흐르도록 다시 그리는 간격.
24const TICK_MS = 30000
25// 터미널 창 색(theme.ts). register 가 설정 theme·background 로 고른다.
26let theme: NotesTheme = BASE_THEME
27// 터미널 옆 창 왼쪽 여백(칸).
28const PANE_GUTTER = 1
29// 결과물 문구에서 링크로 만들 주소.
30const URL_RE = /https?:\/\/[^\s)>\]]+/
31
32// 이 세션의 id 와 이름. 이름은 /rename 한 것, 없으면 Claude Code 가 붙인 제목.
33// 공식 API 가 없어 Claude Code 가 남기는 파일을 읽는다(형식이 바뀌면 세션 번호로 보인다). $.fs.read 는 4MB 까지라
34// 작은 파일부터 본다: ① <기록 폴더>/<id>/custom-title.json ② <.claude>/sessions/*.json 의 name ③ 기록(.jsonl)의 마지막 제목 줄.
35let sessionId = ''
36let sessionTitle: string | undefined
37let transcriptPath: string | undefined
38const sessionTag = (): NoteSession => ({ id: sessionId, title: sessionTitle })
39const lastTitle = (text: string, field: 'customTitle' | 'aiTitle') => {
40 const re = new RegExp(`"${field}":"((?:[^"\\\\]|\\\\.)*)"`, 'g')
41 let found: string | undefined
42 for (const m of text.matchAll(re)) found = m[1]
43 if (found === undefined) return undefined
44 try {
45 return (JSON.parse(`"${found}"`) as string).trim() || undefined
46 } catch {
47 return found
48 }
49}
50const readText = async ($: EngineInterface, path: string) => {
51 try {
52 return await $.fs.read(path)
53 } catch {
54 return undefined
55 }
56}
57// ① rename 한 이름: 기록 파일 옆 <id>/custom-title.json
58async function customTitle($: EngineInterface, transcript: string) {
59 const text = await readText($, `${transcript.replace(/\.jsonl$/, '')}/custom-title.json`)
60 return text === undefined ? undefined : lastTitle(text, 'customTitle')
61}
62// ② 실행 중인 세션 목록(<.claude>/sessions/<pid>.json)에서 이 세션의 name
63async function listedName($: EngineInterface, claudeDir: string) {
64 try {
65 for (const f of await $.fs.list(`${claudeDir}/sessions`)) {
66 if (!f.name.endsWith('.json')) continue
67 const text = await readText($, `${claudeDir}/sessions/${f.name}`)
68 if (text === undefined || !text.includes(sessionId)) continue
69 const name = (JSON.parse(text) as { sessionId?: string; name?: unknown }).name
70 if (typeof name === 'string' && name.trim() !== '') return name.trim()
71 }
72 } catch {
73 // 목록을 못 읽으면 다음 방법으로.
74 }
75 return undefined
76}
77// 기록 파일 위치: classic 훅이 준 경로, 없으면 <.claude>/projects/<작업 폴더를 - 로 바꾼 이름>/<세션 id>.jsonl
78// isQuick: 3초마다 부를 때는 작은 파일(①②)만 본다. 큰 기록 파일(③)은 시작·프롬프트·30초마다.
79async function refreshTitle($: EngineInterface, isQuick = false) {
80 const claudeDir = $.plugin.root.match(/^(.*?[\\/]\.claude)[\\/]/)?.[1]
81 const transcript =
82 transcriptPath ?? (claudeDir && sessionId ? `${claudeDir}/projects/${(await $.session.root()).replace(/[^A-Za-z0-9]/g, '-')}/${sessionId}.jsonl` : undefined)
83 let title = transcript === undefined ? undefined : await customTitle($, transcript)
84 if (title === undefined && claudeDir && sessionId) title = await listedName($, claudeDir)
85 if (title === undefined && transcript !== undefined && !isQuick) {
86 // ③ 기록이 4MB 를 넘으면 읽지 못한다(그때는 세션 번호로 보인다).
87 const text = await readText($, transcript)
88 if (text !== undefined) title = lastTitle(text, 'customTitle') ?? lastTitle(text, 'aiTitle')
89 }
90 if (title === undefined || title === sessionTitle) return
91 sessionTitle = title
92 // 이 세션이 다룬 탭의 이름표도 새 이름으로(파일엔 다음 변경 때 저장된다). 화면은 다시 그리게 한다.
93 await update($, book, b => ({ ...b, tabs: b.tabs.map(t => (t.session?.id === sessionId ? { ...t, session: sessionTag() } : t)) }))
94}
95const sessionLabel = (s: NoteSession | undefined) =>
96 (s?.id !== undefined && s.id === sessionId ? sessionTitle : undefined) ?? s?.title ?? (s?.id ? L.session(s.id.slice(0, 8)) : undefined)
97
98// 마지막 갱신이 지금부터 얼마 전인지 사람 말로.
99const ago = (iso: string, nowMs: number) => {
100 const sec = Math.max(0, Math.round((nowMs - Date.parse(iso)) / 1000))
101 if (!Number.isFinite(sec) || sec < 60) return L.justNow
102 if (sec < 3600) return L.minAgo(Math.floor(sec / 60))
103 if (sec < 86400) return L.hourAgo(Math.floor(sec / 3600))
104 return L.dayAgo(Math.floor(sec / 86400))
105}
106
107const statusMark = (s: NoteItemStatus) => (s === 'blocked' ? `⛔ ${L.blocked}` : `⏳ ${L.waiting}`)
108// 탭 줄 표시: 안 끝난 단계에 막힘이 하나라도 있으면 빨강, 대기만 있으면 노랑. 다른 탭에서 일하는 중에도 한눈에 보이게.
109// 세션 표시: 그 탭을 마지막으로 다룬 세션이 일하는 중이면 초록 ●, 질문·허락 창을 띄웠으면 청록 💬. 모든 단계가 끝나면 ✅.
110// 여러 개가 겹치면 하나만: ⛔ → 💬 → ⏳ → ● → ✅.
111type TabMark = NoteItemStatus | 'ask' | 'working' | 'done'
112const TAB_MARK: Record<TabMark, string> = { blocked: '⛔', ask: '💬', waiting: '⏳', working: '●', done: '✅' }
113const tabMark = (t: NoteTab, states: Record<string, LiveState>): TabMark | undefined => {
114 const open = t.items.filter(i => !i.isDone)
115 const state = t.session === undefined ? undefined : states[t.session.id]
116 if (open.some(i => i.status === 'blocked')) return 'blocked'
117 if (state === 'ask') return 'ask'
118 if (open.some(i => i.status === 'waiting')) return 'waiting'
119 if (state === 'working') return 'working'
120 return t.items.length > 0 && open.length === 0 ? 'done' : undefined
121}
122
123// 세션 상태는 세션마다 <notes-data>/live/<세션 id>.json 한 파일에 { state, at } 로 쓴다(노트 파일과 따로라 서로 덮어쓰지 않는다).
124// 일하는 동안은 1분마다 at 을 새로 쓰고, 3분 넘게 소식이 없으면 꺼진 세션으로 보고 무시한다.
125const LIVE_BEAT_MS = 60000
126const LIVE_STALE_MS = 180000
127let myLive: { state: LiveState; at: number } = { state: 'idle', at: 0 }
128const livePath = ($: EngineInterface, id: string) => `${dataDir($.plugin.root)}/live/${id.replace(/[^A-Za-z0-9-]/g, '')}.json`
129async function setLive($: EngineInterface, state: LiveState) {
130 if (sessionId === '' || state === myLive.state) return
131 myLive = { state, at: Date.now() }
132 await update($, live, m => ({ ...m, [sessionId]: state }))
133 await writeLive($)
134}
135const writeLive = ($: EngineInterface) =>
136 $.fs.write(livePath($, sessionId), JSON.stringify({ state: myLive.state, at: new Date(myLive.at).toISOString() })).catch(() => undefined)
137// 3초 동기화 때: 내 상태가 일하는 중이면 1분마다 at 갱신, 탭에 걸린 다른 세션들의 상태 파일을 읽는다.
138async function syncLive($: EngineInterface) {
139 if (sessionId === '') return
140 if (myLive.state !== 'idle' && Date.now() - myLive.at >= LIVE_BEAT_MS) {
141 myLive = { ...myLive, at: Date.now() }
142 await writeLive($)
143 }
144 const ids = [...new Set((await read($, book)).tabs.map(t => t.session?.id).filter((id): id is string => !!id && id !== sessionId))]
145 const next: Record<string, LiveState> = { [sessionId]: myLive.state }
146 for (const id of ids) {
147 try {
148 const v = JSON.parse(await $.fs.read(livePath($, id))) as { state?: unknown; at?: unknown }
149 const isFresh = typeof v.at === 'string' && Date.now() - Date.parse(v.at) < LIVE_STALE_MS
150 if (isFresh && (v.state === 'working' || v.state === 'ask')) next[id] = v.state
151 } catch {
152 // 상태 파일이 없으면(그 세션은 이 기능 이전 버전이거나 아직 일하지 않음) 표시하지 않는다.
153 }
154 }
155 const cur = await read($, live)
156 if (JSON.stringify(cur) !== JSON.stringify(next)) await update($, live, () => next)
157}
158// 노트는 <.claude>/notes-data/ 아래 한 파일에 두고, 그 파일을 쓰는 세션들이 함께 읽고 쓴다(공유 범위는 share-scope.ts).
159// 전체 = _all.json, 같은 폴더 = <작업 폴더>.json, 세션마다 = sessions/<세션 id>.json.
160// 마켓플레이스 설치본은 버전마다 폴더가 바뀌므로 모드 폴더 밖에 둔다. .claude 아래가 아니면 모드 폴더의 data/.
161let shareScope: ShareScope = '전체'
162const dataDir = (pluginRoot: string) => {
163 const claudeDir = pluginRoot.match(/^(.*?[\\/]\.claude)[\\/]/)?.[1]
164 return claudeDir === undefined ? `${pluginRoot}/data` : `${claudeDir}/notes-data`
165}
166const dataPath = ($: EngineInterface, root: string) => `${dataDir($.plugin.root)}/${noteFileName(shareScope, root, sessionId)}`
167const now = () => new Date().toISOString()
168const asText = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined)
169const asList = (v: unknown) =>
170 Array.isArray(v) ? v.map(asText).filter((s): s is string => s !== undefined) : []
171
172const findTab = (b: NotesBook, title: string | undefined) =>
173 title === undefined ? undefined : b.tabs.find(t => t.title === title)
174
175const describeTab = (tab: NoteTab) => {
176 const done = tab.items.filter(i => i.isDone).length
177 const lines = [`# ${tab.title} (${done}/${tab.items.length})`]
178 if (tab.goal) lines.push(`${L.goal}: ${tab.goal}`)
179 if (tab.doneWhen) lines.push(`${L.doneWhen}: ${tab.doneWhen}`)
180 tab.items.forEach((i, n) => {
181 lines.push(`#${n + 1} [${i.isDone ? 'x' : ' '}] ${i.text}`)
182 if (i.status) lines.push(` ${statusMark(i.status)}${i.note ? `: ${i.note}` : ''}`)
183 if (i.resolved) lines.push(` ${L.resolved}: ${i.resolved}`)
184 })
185 if (tab.results?.length) lines.push(`${L.results}: ${tab.results.join(' / ')}`)
186 if (tab.memo) lines.push(`${L.memo}: ${tab.memo}`)
187 return lines.join('\n')
188}
189
190// history = 되돌릴 수 있는 변경(오래된 것부터), future = 되돌린 뒤 다시 할 수 있는 변경(가장 최근에 되돌린 것이 끝).
191type Saved = { tabs: NoteTab[]; history: NoteChange[]; future: NoteChange[] }
192
193const asChanges = (v: unknown) => (Array.isArray(v) ? (v as NoteChange[]) : [])
194async function loadFile($: EngineInterface, root: string): Promise<Saved> {
195 try {
196 const saved = JSON.parse(await $.fs.read(dataPath($, root))) as Partial<Saved>
197 return { tabs: Array.isArray(saved.tabs) ? saved.tabs : [], history: asChanges(saved.history), future: asChanges(saved.future) }
198 } catch {
199 return { tabs: [], history: [], future: [] }
200 }
201}
202
203// 바뀐 탭만 골라 되돌리기 기록을 만든다. 보는 탭만 바꾼 경우처럼 내용이 그대로면 빈 목록.
204function diffTabs(before: NoteTab[], after: NoteTab[]): NoteChangePart[] {
205 const parts: NoteChangePart[] = []
206 before.forEach((o, index) => {
207 const n = after.find(t => t.title === o.title)
208 if (n === undefined) parts.push({ index, before: o })
209 else if (JSON.stringify(n) !== JSON.stringify(o)) parts.push({ index, before: o, afterTitle: o.title })
210 })
211 after.forEach((n, index) => {
212 if (!before.some(o => o.title === n.title)) parts.push({ index, afterTitle: n.title })
213 })
214 return parts
215}
216
217// 기록 한 건을 거꾸로 적용한다: 바뀐 뒤 탭을 빼고, 바뀌기 전 탭을 원래 자리에 되돌린다.
218function revert(tabs: NoteTab[], entry: NoteChange): NoteTab[] {
219 const gone = new Set(entry.parts.flatMap(p => (p.afterTitle === undefined ? [] : [p.afterTitle])))
220 const back = entry.parts.flatMap(p => (p.before === undefined ? [] : [p])).sort((x, y) => x.index - y.index)
221 const result = tabs.filter(t => !gone.has(t.title) && !back.some(p => p.before!.title === t.title))
222 for (const p of back) result.splice(Math.min(p.index, result.length), 0, p.before!)
223 return result
224}
225
226async function save($: EngineInterface, root: string, saved: Saved) {
227 await $.fs.write(dataPath($, root), JSON.stringify({ root, ...saved }, null, 1))
228}
229
230// 전체 공유 파일이 아직 없으면(처음 전체로 바꾼 PC) 지금까지 폴더별로 쓰던 노트 탭을 모아 만든다.
231async function seedShared($: EngineInterface, root: string) {
232 const dir = dataDir($.plugin.root)
233 if ((await readText($, `${dir}/${SHARED_FILE}`)) !== undefined) return
234 const lists: NoteTab[][] = []
235 try {
236 for (const f of await $.fs.list(dir)) {
237 if (!f.name.endsWith('.json') || f.name === SHARED_FILE) continue
238 try {
239 const tabs = (JSON.parse((await readText($, `${dir}/${f.name}`)) ?? '{}') as Partial<Saved>).tabs
240 if (Array.isArray(tabs)) lists.push(tabs)
241 } catch {
242 // 깨진 파일은 건너뛴다.
243 }
244 }
245 } catch {
246 // 폴더가 없으면 빈 노트로 시작한다.
247 }
248 const tabs = mergeTabs(lists, MAX_TABS)
249 if (tabs.length > 0) await save($, root, { tabs, history: [], future: [] })
250}
251
252// /clear 하면 세션 상태가 처음 값(root '')으로 돌아가는데 session.start 는 다시 오지 않는다.
253// 그때 root 가 비어 있으면 엔진에 다시 물어 채운다(빈 root 로는 이름 없는 노트 파일 .json 에 쓰게 된다).
254async function projectRoot($: EngineInterface) {
255 const { root } = await read($, book)
256 if (root !== '') return root
257 const fresh = await $.session.root()
258 sessionId = await $.session.id()
259 await update($, book, c => ({ ...c, root: fresh }))
260 return fresh
261}
262
263// 다른 세션이 고친 내용을 덮어쓰지 않도록, 고치기 직전에 파일의 최신본을 다시 읽어 그 위에 반영한다.
264// label 은 되돌리기 버튼에 보일 설명이다. 새로 고치면 앞으로가기 기록은 버린다.
265async function change($: EngineInterface, label: string, fn: (b: NotesBook) => NotesBook) {
266 const root = await projectRoot($)
267 const saved = await loadFile($, root)
268 const next = await update($, book, cur => fn({ ...cur, ...saved }))
269 const parts = diffTabs(saved.tabs, next.tabs)
270 if (parts.length === 0) return next
271 const history = [...saved.history, { label, at: now(), parts }].slice(-MAX_HISTORY)
272 await save($, root, { tabs: next.tabs, history, future: [] })
273 return update($, book, c => ({ ...c, history, future: [] }))
274}
275
276// 기록 한쪽(from)의 마지막 변경을 거꾸로 적용하고, 그 반대 방향 기록을 다른 쪽(to)에 쌓는다.
277// 되돌리기는 history → future, 앞으로가기는 future → history.
278async function step($: EngineInterface, from: 'history' | 'future') {
279 const to = from === 'history' ? 'future' : 'history'
280 const root = await projectRoot($)
281 const saved = await loadFile($, root)
282 const entry = saved[from].at(-1)
283 if (entry === undefined) return
284 const tabs = revert(saved.tabs, entry)
285 const back: NoteChange = { label: entry.label, at: now(), parts: diffTabs(saved.tabs, tabs) }
286 const next: Saved = { tabs, [from]: saved[from].slice(0, -1), [to]: [...saved[to], back].slice(-MAX_HISTORY) } as Saved
287 await save($, root, next)
288 await update($, book, c => ({ ...c, ...next, active: tabs.some(t => t.title === c.active) ? c.active : tabs.at(-1)?.title }))
289}
290const undo = ($: EngineInterface) => step($, 'history')
291const redo = ($: EngineInterface) => step($, 'future')
292
293// 다른 세션의 변경을 이 세션 화면에 반영한다.
294async function sync($: EngineInterface) {
295 const root = await projectRoot($)
296 const cur = await read($, book)
297 const saved = await loadFile($, root)
298 if (JSON.stringify(saved) === JSON.stringify({ tabs: cur.tabs, history: cur.history, future: cur.future ?? [] })) return
299 await update($, book, c => ({ ...c, ...saved, active: saved.tabs.some(t => t.title === c.active) ? c.active : saved.tabs.at(-1)?.title }))
300}
301
302// 하단 버튼 줄: 상단 탭 줄과 같은 둥근 테두리 상자.
303const buttonBar = (Box: ElementConstructor<BoxProps>, buttons: RenderChildren[], border: string) => (
304 <Box flexDirection="row" flexWrap="wrap" columnGap={1} borderStyle="round" borderColor={border} paddingX={1}>
305 {buttons.filter(Boolean)}
306 </Box>
307)
308
309// 터미널에서 글자가 차지하는 칸 수. 한글·한자·전각 문자와 체크 칸은 두 칸으로 센다.
310const WIDE = /[ᄀ-ᅟ☐-☒⺀-가-힣豈-︰-﹏-⦆¢-₩]/
311const cellWidth = (s: string) => [...s].reduce((w, ch) => w + (WIDE.test(ch) ? 2 : 1), 0)
312// 폭 cols 에 접혀 들어갈 때 몇 줄이 되는지(대략).
313const textRows = (s: string, cols: number) => Math.max(1, Math.ceil(cellWidth(s) / Math.max(1, cols)))
314
315// 마지막으로 그린 탭에서 본문을 내릴 수 있는 최대 줄 수. 휠이 끝을 넘지 않게 그릴 때마다 갱신한다.
316let maxScroll = 0
317
318const ok = (text: string) => ({ result: text })
319const failed = () => ({ deny: L.failed })
320// 노트는 사람이 보는 메인 에이전트만 쓴다. 서브에이전트·팀원 루프에서 부르면 거절한다.
321const mainOnly = () => ({ deny: L.mainOnly })
322
323// 매 세션 시스템 프롬프트 끝에 붙는 사용 규칙(i18n.ts 의 guide). 고정 문구라 프롬프트 캐시를 깨지 않는다.
324
325export const register: Register = (on, options) => {
326 L = TEXT[toLang(options.language)]
327 // 헤드리스(크론·-p·SDK)에서도 노트를 쓸지. /config 의 notes.headless, 기본은 끔.
328 const useHeadless = options.headless === true
329 // 노트를 어느 세션끼리 공유할지. /config 의 notes.shareScope, 기본은 이 PC 의 모든 세션.
330 shareScope = toShareScope(options.shareScope)
331 // 터미널 창 테마와 직접 고른 배경색. /config 의 notes.theme·notes.background.
332 theme = toTheme(options.theme, options.background)
333 on('prompt.compose', async ($, e, next) => {
334 const composed = await next(e)
335 // 헤드리스(-p·SDK)는 그릴 곳이 없다. 설정으로 켜지 않았으면 보는 사람이 없으니 규칙을 넣지 않는다.
336 if (e.surfaces.length === 0 && !useHeadless) return composed
337 return { sections: [...composed.sections, { id: 'notes:guide', text: L.guide, scope: 'session' }] }
338 })
339
340 // 세션 이름은 classic 훅 입력의 session_title 로만 알 수 있다. 시작할 때와 프롬프트마다 갱신한다(/rename 반영).
341 // classic 훅은 기록 파일 경로를 준다. 시작할 때와 프롬프트를 보낼 때마다 이름을 다시 읽는다(/rename 반영).
342 on('classic.SessionStart', async ($, e, next) => {
343 if (typeof e.transcript_path === 'string' && e.transcript_path !== '') transcriptPath = e.transcript_path
344 // /clear 는 세션 상태를 비운다. 3초 주기 동기화를 기다리지 않고 노트 파일을 바로 다시 읽는다.
345 if (e.source === 'clear') await sync($)
346 await refreshTitle($)
347 return next(e)
348 })
349 on('classic.UserPromptSubmit', async ($, e, next) => {
350 if (typeof e.transcript_path === 'string' && e.transcript_path !== '') transcriptPath = e.transcript_path
351 await refreshTitle($)
352 return next(e)
353 })
354
355 // 세션 상태: 턴이 시작되면 일하는 중, 끝나면 쉼. 허락 창·질문 창이 뜨면 답 필요, 그 도구 호출이 끝나면 다시 일하는 중.
356 on('turn.start', async ($, e, next) => {
357 await setLive($, 'working')
358 return next(e)
359 })
360 on('turn.complete', async ($, e, next) => {
361 // 서브에이전트의 턴 끝은 메인 세션이 쉬는 게 아니다.
362 if (e.agentId === undefined) await setLive($, 'idle')
363 return next(e)
364 })
365 on('classic.PermissionRequest', async ($, e, next) => {
366 await setLive($, 'ask')
367 return next(e)
368 })
369 on('tool.call', { tool: /^(AskUserQuestion|ExitPlanMode)$/ }, async ($, e, next) => {
370 if (e.agentId === undefined) await setLive($, 'ask')
371 return next(e)
372 })
373 on('tool.call', async ($, e, next) => {
374 const result = await next(e)
375 if (myLive.state === 'ask') await setLive($, 'working')
376 return result
377 })
378 on('session.end', async ($, e, next) => {
379 await setLive($, 'idle')
380 return next(e)
381 })
382
383 on('session.start', async ($, e, next) => {
384 // 헤드리스(크론·-p·SDK)는 볼 사람이 없다. 설정으로 켜지 않았으면 도구를 등록하지 않아 노트 파일에 쓰지 못하게 한다.
385 if (!e.isInteractive && !useHeadless) return next(e)
386 const root = await $.session.root()
387 sessionId = await $.session.id()
388 if (shareScope === '전체') await seedShared($, root)
389 const saved = await loadFile($, root)
390 await update($, book, () => ({ root, ...saved, active: saved.tabs.at(-1)?.title }))
391 // 3초마다: 다른 세션이 고친 노트 반영 + /rename 한 이름 반영.
392 $.clock.every(SYNC_MS, () => void sync($).then(() => Promise.all([refreshTitle($, true), syncLive($)])))
393 $.clock.every(TICK_MS, () => void update($, tick, n => (n + 1) % 100000).then(() => refreshTitle($)))
394 void refreshTitle($)
395 // 기본은 세션을 열면 창도 연다. /config 의 notes.autoOpen 을 끄면 /notes 로만 연다.
396 if (options.autoOpen !== false && e.isInteractive) void $.ui.open({ id: PANE, title: 'notes' })
397
398 await $.command.register({ name: 'notes', description: L.command })
399 await $.tool.register({
400 name: 'notes_write',
401 isDeferred: false,
402 description: L.tools.write,
403 inputSchema: {
404 type: 'object',
405 properties: {
406 tab: { type: 'string', description: L.tools.writeTab },
407 goal: { type: 'string', description: L.tools.writeGoal },
408 done_when: { type: 'string', description: L.tools.writeDoneWhen },
409 items: { type: 'array', items: { type: 'string' }, description: L.tools.writeItems },
410 add_items: { type: 'array', items: { type: 'string' }, description: L.tools.writeAddItems },
411 memo: { type: 'string', description: L.tools.writeMemo },
412 results: { type: 'array', items: { type: 'string' }, description: L.tools.writeResults },
413 add_results: { type: 'array', items: { type: 'string' }, description: L.tools.writeAddResults },
414 },
415 required: ['tab'],
416 },
417 })
418 await $.tool.register({
419 name: 'notes_status',
420 isDeferred: false,
421 description: L.tools.status,
422 inputSchema: {
423 type: 'object',
424 properties: {
425 tab: { type: 'string' },
426 item: { type: 'integer', description: L.tools.statusItem },
427 status: { type: 'string', enum: ['blocked', 'waiting', 'clear'], description: L.tools.statusStatus },
428 note: { type: 'string', description: L.tools.statusNote },
429 },
430 required: ['tab', 'item', 'status'],
431 },
432 })
433 await $.tool.register({
434 name: 'notes_check',
435 isDeferred: false,
436 description: L.tools.check,
437 inputSchema: {
438 type: 'object',
439 properties: {
440 tab: { type: 'string' },
441 items: { type: 'array', items: { type: 'integer' }, description: L.tools.checkItems },
442 done: { type: 'boolean', description: L.tools.checkDone },
443 },
444 required: ['tab', 'items'],
445 },
446 })
447 await $.tool.register({
448 name: 'notes_read',
449 isDeferred: false,
450 description: L.tools.read,
451 inputSchema: { type: 'object', properties: { tab: { type: 'string' } } },
452 })
453 await $.tool.register({
454 name: 'notes_close',
455 isDeferred: false,
456 description: L.tools.close,
457 inputSchema: { type: 'object', properties: { tab: { type: 'string' } }, required: ['tab'] },
458 })
459
460 return next(e)
461 })
462
463 // /notes 는 열기·닫기를 오간다: 창이 보이고 있으면 닫고, 아니면 연다(닫기 버튼 대신).
464 on('command.run', { command: 'notes' }, async $ => {
465 const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
466 if (isShown) {
467 await $.ui.close({ id: PANE })
468 return { text: L.paneClosed }
469 }
470 await $.ui.open({ id: PANE, title: 'notes' })
471 const b = await read($, book)
472 return { text: L.opened(b.tabs.length) }
473 })
474
475 on('tool.call', { tool: 'mcp__notes__notes_write' }, async ($, e) => {
476 if (e.agentId !== undefined) return mainOnly()
477 const a = e as unknown as Record<string, unknown>
478 const title = asText(a.tab)?.slice(0, 40)
479 if (title === undefined) return { deny: L.needTab }
480 const isNew = findTab(await read($, book), title) === undefined
481
482 const b = await change($, isNew ? L.logNewTab(title) : L.logEdit(title), cur => {
483 const old = findTab(cur, title)
484 // 같은 문구의 단계는 체크·막힘·처리 내역을 그대로 이어 간다.
485 const items: NoteItem[] =
486 a.items === undefined
487 ? (old?.items ?? [])
488 : asList(a.items).map(text => old?.items.find(i => i.text === text) ?? { text, isDone: false })
489 const results = a.results === undefined ? (old?.results ?? []) : asList(a.results)
490 const tab: NoteTab = {
491 title,
492 goal: asText(a.goal) ?? old?.goal,
493 doneWhen: asText(a.done_when) ?? old?.doneWhen,
494 memo: asText(a.memo) ?? old?.memo,
495 items: [...items, ...asList(a.add_items).map(text => ({ text, isDone: false }))],
496 results: [...results, ...asList(a.add_results)],
497 session: sessionTag(),
498 updatedAt: now(),
499 }
500 const rest = cur.tabs.filter(t => t.title !== title)
501 return { ...cur, tabs: (old ? cur.tabs.map(t => (t.title === title ? tab : t)) : [...rest, tab]).slice(-MAX_TABS), active: title }
502 })
503 if (isNew) void $.ui.open({ id: PANE, title: 'notes' })
504
505 return ok(describeTab(findTab(b, title)!))
506 }).catch(failed)
507
508 on('tool.call', { tool: 'mcp__notes__notes_check' }, async ($, e) => {
509 if (e.agentId !== undefined) return mainOnly()
510 const a = e as unknown as Record<string, unknown>
511 const title = asText(a.tab)
512 const cur = await read($, book)
513 const tab = findTab(cur, title)
514 if (tab === undefined) return { deny: `${L.noTab(title)} ${L.tabList(cur.tabs.map(x => x.title).join(', '))}` }
515 const nums = Array.isArray(a.items) ? a.items.filter((n): n is number => Number.isInteger(n)) : []
516 const bad = nums.filter(n => n < 1 || n > tab.items.length)
517 if (bad.length > 0) return { deny: L.badItems(bad.join(', '), tab.items.length) }
518 const isDone = a.done !== false
519
520 const b = await change($, L.logCheck(tab.title), c => ({
521 ...c,
522 active: tab.title,
523 tabs: c.tabs.map(t =>
524 t.title !== tab.title
525 ? t
526 : { ...t, session: sessionTag(), updatedAt: now(), items: t.items.map((i, n) => (nums.includes(n + 1) ? { ...i, isDone } : i)) },
527 ),
528 }))
529
530 return ok(describeTab(findTab(b, tab.title)!))
531 }).catch(failed)
532
533 on('tool.call', { tool: 'mcp__notes__notes_status' }, async ($, e) => {
534 if (e.agentId !== undefined) return mainOnly()
535 const a = e as unknown as Record<string, unknown>
536 const title = asText(a.tab)
537 const cur = await read($, book)
538 const tab = findTab(cur, title)
539 if (tab === undefined) return { deny: `${L.noTab(title)} ${L.tabList(cur.tabs.map(x => x.title).join(', '))}` }
540 const n = Number.isInteger(a.item) ? (a.item as number) : 0
541 if (n < 1 || n > tab.items.length) return { deny: L.badItems(String(a.item), tab.items.length) }
542 const status = a.status
543 if (status !== 'blocked' && status !== 'waiting' && status !== 'clear') return { deny: L.badStatus }
544 const note = asText(a.note)
545 const apply = (i: NoteItem): NoteItem => {
546 if (status === 'clear') {
547 const { status: _s, note: _n, ...rest } = i
548 return { ...rest, resolved: note ?? i.resolved ?? L.resolvedDefault }
549 }
550 return { ...i, status, note, resolved: undefined }
551 }
552 const label = L.logStatus(n, status === 'clear' ? L.cleared : status === 'blocked' ? L.blocked : L.waiting)
553 const b = await change($, label, c => ({
554 ...c,
555 active: tab.title,
556 tabs: c.tabs.map(t =>
557 t.title !== tab.title ? t : { ...t, session: sessionTag(), updatedAt: now(), items: t.items.map((i, k) => (k === n - 1 ? apply(i) : i)) },
558 ),
559 }))
560 return ok(describeTab(findTab(b, tab.title)!))
561 }).catch(failed)
562
563 on('tool.call', { tool: 'mcp__notes__notes_read' }, async ($, e) => {
564 if (e.agentId !== undefined) return mainOnly()
565 const a = e as unknown as Record<string, unknown>
566 const b = await read($, book)
567 const title = asText(a.tab)
568 if (title !== undefined) {
569 const tab = findTab(b, title)
570 return tab ? ok(describeTab(tab)) : { deny: L.noTab(title) }
571 }
572 if (b.tabs.length === 0) return ok(L.noTabs)
573 return ok(b.tabs.map(describeTab).join('\n\n'))
574 }).catch(failed)
575
576 on('tool.call', { tool: 'mcp__notes__notes_close' }, async ($, e) => {
577 if (e.agentId !== undefined) return mainOnly()
578 const title = asText((e as unknown as Record<string, unknown>).tab)
579 const cur = await read($, book)
580 if (findTab(cur, title) === undefined) return { deny: L.noTab(title) }
581 await change($, L.logClose(title!), c => {
582 const tabs = c.tabs.filter(t => t.title !== title)
583 return { ...c, tabs, active: c.active === title ? tabs.at(-1)?.title : c.active }
584 })
585 return ok(L.closed(title))
586 }).catch(failed)
587
588 // 창을 움직이지 않고 본문 안의 위치만 줄 단위로 옮긴다(탭 줄 고정). 휠 한 칸 = 한 줄, Claude Code 기본과 같다.
589 // 키보드(방향키·페이지 키)로는 본문을 움직이지 않는다. 휠만 쓴다. 방향키는 초점 이동에만 쓰인다.
590 // 직접 스크롤은 터미널이 본문을 넘치게 그렸을 때만(maxScroll > 0). 데스크톱 등은 창이 스스로 스크롤하게 그대로 넘긴다.
591 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
592 if (maxScroll === 0) return next(e)
593 if (e.origin.kind === 'person' && e.pointer === undefined) return {}
594 const by = Number.isFinite(e.by) ? e.by : 0
595 await update($, scroll, s => Math.min(Math.max(0, s + by), maxScroll))
596 // 창은 맨 위에 둔다: 탭 줄이 항상 보이게.
597 return next({ ...e, offset: 0 })
598 })
599
600 // 대화창의 notes 도구 호출 줄. 설정 callDisplay: 요약(기본, 한 줄) / 숨김 / 원래대로(엔진이 그리던 대로).
601 // 결과 칸(탭 전체 내용)은 요약·숨김일 때 비운다. 에이전트가 받는 결과는 그대로다(화면만 바뀜).
602 const callDisplay = toCallDisplay(options.callDisplay)
603 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
604 const summary = callSummary(e.props.tool, e.props.input, L)
605 if (summary === undefined || callDisplay === '원래대로') return next(e)
606 const { Box, Text } = $.ui.resolve(e)
607 if (callDisplay === '숨김') return <Box key="notes-hidden" />
608 const { isRunning, isErrored, isInterrupted } = e.props
609 const tail = isErrored ? L.call.failed : isInterrupted ? L.call.interrupted : isRunning ? ' …' : ''
610 return (
611 <Box key="notes-call">
612 <Text dimColor={!isErrored} color={isErrored ? 'red' : undefined}>
613 {`${summary}${tail}`}
614 </Text>
615 </Box>
616 )
617 })
618 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
619 if (callSummary(e.props.tool, undefined) === undefined || callDisplay === '원래대로') return next(e)
620 const { Box } = $.ui.resolve(e)
621 return <Box key="notes-result" />
622 })
623
624 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
625 const { Box, Text, Button, Link, Markdown } = $.ui.resolve(e)
626 // 데스크톱 등은 목표·종료 조건·결과물·메모를 Markdown 으로 그려 드래그로 고를 수 있게 한다. 터미널은 Text.
627 const isRich = e.surface !== 'terminal'
628 // 터미널 옆 창은 왼쪽 구분선(│)에 글자가 붙지 않게 한 칸 띄운다. 데스크톱 등은 구분선이 없어 그대로.
629 const gutter = e.surface === 'terminal' ? PANE_GUTTER : 0
630 const md = (key: string, text: string, dim = false) => <Markdown key={key} text={text} dimColor={dim} />
631 // 테마는 터미널에만 칠한다. 데스크톱 등은 Markdown 글자색을 바꿀 수 없어 기본 색을 쓴다.
632 const th = e.surface === 'terminal' ? theme : BASE_THEME
633 const b = await read($, book)
634 const states = await read($, live)
635 const tab = findTab(b, b.active) ?? b.tabs.at(-1)
636 const last = b.history.at(-1)
637
638 const nextUp = (b.future ?? []).at(-1)
639 const short = (label: string) => (label.length > 16 ? `${label.slice(0, 16)}…` : label)
640 const undoText = last && `${L.undo}: ${short(last.label)}`
641 const redoText = nextUp && `${L.redo}: ${short(nextUp.label)}`
642 const undoButton = undoText && (
643 <Button key="undo" onPress={() => void undo($)}>
644 {undoText}
645 </Button>
646 )
647 const redoButton = redoText && (
648 <Button key="redo" onPress={() => void redo($)}>
649 {redoText}
650 </Button>
651 )
652
653 if (tab === undefined) {
654 return (
655 <Box key="pane-root" flexDirection="column" paddingLeft={gutter} backgroundColor={th.bg}>
656 <Text dimColor color={th.text}>{L.empty}</Text>
657 {(undoButton || redoButton) && buttonBar(Box, [undoButton, redoButton], th.barBorder)}
658 </Box>
659 )
660 }
661
662 const done = tab.items.filter(i => i.isDone).length
663 // 지금 하는 단계 = 아직 안 끝난 첫 단계. tick 을 읽어 두면 일정 간격으로 다시 그려져 갱신 시각이 흐른다.
664 await read($, tick)
665 const current = tab.items.findIndex(i => !i.isDone)
666 const select = (title: string) => () =>
667 void update($, book, c => ({ ...c, active: title })).then(() => update($, scroll, () => 0))
668 const toggle = (n: number) => () =>
669 void change($, L.logToggle(tab.title, n + 1), c => ({
670 ...c,
671 tabs: c.tabs.map(t =>
672 t.title !== tab.title ? t : { ...t, updatedAt: now(), items: t.items.map((i, k) => (k === n ? { ...i, isDone: !i.isDone } : i)) },
673 ),
674 }))
675 const remove = () =>
676 void change($, L.logRemove(tab.title), c => {
677 const tabs = c.tabs.filter(t => t.title !== tab.title)
678 return { ...c, tabs, active: tabs.at(-1)?.title }
679 })
680
681 // 구역마다 제목 줄을 두고 빈 줄로 나눈다: 목표·종료 조건 / 할 일 / 결과물 / 메모. 메모는 체크 칸 없이 흐린 글자로 단계와 구별한다.
682 const cols = Math.max(10, e.props.bodyColumns - 2 - gutter)
683 // 구역 제목을 누르면 접고 편다. 접으면 제목 줄에 요약(개수 등)만 남는다.
684 const foldMap = await read($, folded)
685 const isFolded = (id: string) => foldMap[`${tab.title}|${id}`] === true
686 const toggleFold = (id: string) => () =>
687 void update($, folded, m => ({ ...m, [`${tab.title}|${id}`]: !m[`${tab.title}|${id}`] }))
688 const header = (id: string, label: string, color: string, summary = '') => (
689 <Button key={`fold-${id}`} label={`${isFolded(id) ? '▸' : '▾'} ${label}${summary ? ` ${summary}` : ''}`} plain onPress={toggleFold(id)}>
690 <Text bold color={color}>
691 {`${isFolded(id) ? '▸' : '▾'} ${label}`}
692 </Text>
693 {summary !== '' && <Text dimColor>{` ${summary}`}</Text>}
694 </Button>
695 )
696 const section = (key: string, label: string, color: string, body: RenderChildren, summary = '') => {
697 const id = key.replace(/^b-/, '')
698 return (
699 <Box key={key} flexDirection="column" marginTop={1}>
700 {header(id, label, color, summary)}
701 {!isFolded(id) && (
702 <Box flexDirection="column" paddingLeft={2}>
703 {body}
704 </Box>
705 )}
706 </Box>
707 )
708 }
709 // 접힌 구역은 제목 줄(빈 줄 포함 2줄)만 차지한다.
710 const sectionRows = (key: string, bodyRows: number) => 2 + (isFolded(key.replace(/^b-/, '')) ? 0 : bodyRows)
711 const memoLines = tab.memo?.split('\n') ?? []
712 // 묶음마다 그릴 것과 차지할 줄 수(대략)를 함께 둔다.
713 const blocks: { el: RenderChildren; rows: number }[] = [
714 {
715 el: (
716 <Text key="b-title" bold color={th.text}>
717 {tab.title} ({done}/{tab.items.length})
718 </Text>
719 ),
720 rows: textRows(`${tab.title} (${done}/${tab.items.length})`, cols),
721 },
722 // 제목 바로 아래: 이 탭을 마지막으로 다룬 세션과 갱신 시각.
723 {
724 el: (
725 <Text key="b-session" dimColor color={th.text}>
726 {`${sessionLabel(tab.session) ?? L.noSession} · ${L.updated(ago(tab.updatedAt, Date.now()))}`}
727 </Text>
728 ),
729 rows: textRows(`${sessionLabel(tab.session) ?? L.noSession} · ${L.updated(L.agoSample)}`, cols),
730 },
731 tab.goal ? { el: section('b-goal', L.goal, th.section.goal, isRich ? md('goal-md', tab.goal) : <Text color={th.text}>{tab.goal}</Text>), rows: sectionRows('b-goal', textRows(tab.goal, cols - 2)) } : undefined,
732 tab.doneWhen
733 ? { el: section('b-doneWhen', L.doneWhen, th.section.doneWhen, isRich ? md('doneWhen-md', tab.doneWhen) : <Text color={th.text}>{tab.doneWhen}</Text>), rows: sectionRows('b-doneWhen', textRows(tab.doneWhen, cols - 2)) }
734 : undefined,
735 {
736 el: (
737 <Box key="b-items" marginTop={1}>
738 {header('items', L.todo, th.section.todo, `(${done}/${tab.items.length})`)}
739 </Box>
740 ),
741 rows: 2,
742 },
743 ...(isFolded('items') ? [] : tab.items).map((i, n) => {
744 const isCurrent = n === current
745 const mark = i.isDone ? '☑' : isCurrent ? '▶' : '☐'
746 // 막힘·대기 사유, 풀린 뒤 처리 내역은 단계 바로 아래 줄에.
747 const subs = [
748 i.status && { key: 'status', text: `${statusMark(i.status)}${i.note ? `: ${i.note}` : ''}`, color: th.mark[i.status] },
749 i.resolved && { key: 'resolved', text: `${L.resolved}: ${i.resolved}`, color: th.section.todo },
750 ].filter((x): x is { key: string; text: string; color: string } => Boolean(x))
751 return {
752 // 줄 어디를 눌러도 체크가 바뀐다. 데스크톱은 버튼을 label 로 그리므로 label 에도 표시를 넣는다.
753 el: (
754 <Box key={`item-${n}`} flexDirection="column" paddingLeft={2}>
755 <Button key={`chk-${n}`} label={`${mark} ${i.text}`} plain dimColor={i.isDone} onPress={toggle(n)}>
756 <Text color={th.text}>{`${mark} `}</Text>
757 {isCurrent ? (
758 <Text bold strikethrough={false} color={th.current.text} backgroundColor={th.current.bg}>
759 {i.text}
760 </Text>
761 ) : (
762 <Text strikethrough={i.isDone} dimColor={i.isDone} color={th.text}>
763 {i.text}
764 </Text>
765 )}
766 </Button>
767 {subs.map(x => (
768 <Text key={`item-${n}-${x.key}`} color={x.color}>
769 {` ${x.text}`}
770 </Text>
771 ))}
772 </Box>
773 ),
774 rows: textRows(`☐ ${i.text}`, cols - 2) + subs.reduce((sum, x) => sum + textRows(` ${x.text}`, cols - 2), 0),
775 }
776 }),
777 tab.results?.length
778 ? {
779 el: section(
780 'b-results',
781 L.results,
782 th.section.results,
783 isRich
784 ? md('results-md', tab.results.map(r => `- ${r.replace(URL_RE, u => `[${u}](${u})`)}`).join('\n'))
785 : tab.results.map((r, n) => {
786 // 주소가 들어 있으면 그 부분을 누를 수 있는 링크로. 앞뒤 글자는 그대로 둔다.
787 const url = r.match(URL_RE)?.[0]
788 if (url === undefined) return <Text key={`result-${n}`} color={th.text}>{`- ${r}`}</Text>
789 const [before, after] = [r.slice(0, r.indexOf(url)), r.slice(r.indexOf(url) + url.length)]
790 return (
791 <Box key={`result-${n}`} flexDirection="row" flexWrap="wrap">
792 <Text color={th.text}>{`- ${before}`}</Text>
793 <Link key={`result-link-${n}`} href={url} />
794 {after.trim() !== '' && <Text color={th.text}>{after}</Text>}
795 </Box>
796 )
797 }),
798 `(${tab.results.length})`,
799 ),
800 rows: sectionRows('b-results', tab.results.reduce((sum, r) => sum + textRows(`- ${r}`, cols - 2), 0)),
801 }
802 : undefined,
803 memoLines.length > 0
804 ? {
805 el: section(
806 'b-memo',
807 L.memo,
808 th.section.memo,
809 isRich
810 ? md('memo-md', memoLines.map(line => (line.trimStart().startsWith('-') ? line : `- ${line}`)).join('\n'), true)
811 : memoLines.map((line, n) => (
812 <Text key={`memo-${n}`} dimColor color={th.text}>
813 {line.trimStart().startsWith('-') ? line : `- ${line}`}
814 </Text>
815 )),
816 L.memoLines(memoLines.length),
817 ),
818 rows: sectionRows('b-memo', memoLines.reduce((sum, line) => sum + textRows(`- ${line}`, cols - 2), 0)),
819 }
820 : undefined,
821 ].filter((x): x is { el: RenderChildren; rows: number } => x !== undefined)
822 // 아래 버튼 줄([탭 지우기]·[되돌리기])은 본문과 따로 창 맨 아래에 고정한다.
823 // 창 닫기 버튼은 두지 않는다([탭 지우기]와 헷갈림). 창은 /notes 를 다시 입력하거나 우상단 × 로 닫는다.
824 const bar = buttonBar(Box, [
825 <Button key="remove" onPress={remove}>
826 {L.removeTab}
827 </Button>,
828 undoButton,
829 redoButton,
830 ], th.barBorder)
831 const barRows = 2 + textRows([L.removeTab, undoText, redoText].filter(Boolean).map(x => `[ ${x} ]`).join(' '), cols - 2)
832 // 창 높이에서 탭 줄(테두리 2줄 + 접힌 줄 수)·버튼 줄·여유 한 줄을 빼고 남은 만큼이 본문 자리다.
833 const tabWidth = b.tabs.reduce((w, t) => w + cellWidth(t.title) + 6, 0)
834 const tabRows = 2 + Math.max(1, Math.ceil(tabWidth / cols))
835 const avail = e.props.scroll.bodyRows - tabRows - barRows - 1
836 const total = blocks.reduce((sum, x) => sum + x.rows, 0)
837 // 다 들어가면 그대로 그린다.
838 // 안 들어가면 [▲ 위로] 줄·[▼ 아래로] 줄을 늘 남기고(자리가 들썩이지 않게), 그 사이 창에서
839 // 본문 전체를 위로 off 줄만큼 밀어 올려 보여 준다. 줄 수는 어림이라 끝에 한 줄 여유를 더 둔다.
840 const room = Math.max(1, avail - 2)
841 // 터미널만 줄 수를 세어 직접 스크롤한다. 데스크톱 등은 창이 알아서 스크롤하므로 그대로 이어 그린다.
842 const isTerminal = e.surface === 'terminal'
843 const isScrollable = isTerminal && total > avail
844 if (isTerminal) maxScroll = isScrollable ? total - room + 1 : 0
845 const off = Math.min(Math.max(0, await read($, scroll)), maxScroll)
846 const page = Math.max(1, room - 1)
847 const up = () => void update($, scroll, s => Math.max(0, Math.min(s, maxScroll) - page))
848 const down = () => void update($, scroll, s => Math.min(maxScroll, s + page))
849 // 위·아래 표시: 기울인 글자에 배경색을 칠해 본문과 구별한다. 없을 때도 빈 줄로 자리를 지킨다.
850 const pager = (key: string, text: string | undefined, onPress: () => void) => (
851 <Box key={`${key}-row`} flexDirection="row" justifyContent="center">
852 {text === undefined ? (
853 <Text> </Text>
854 ) : (
855 <Button key={key} label={text} plain onPress={onPress}>
856 <Text italic color={th.text} backgroundColor={th.pagerBg}>
857 {` ${text} `}
858 </Text>
859 </Button>
860 )}
861 </Box>
862 )
863 // 본문은 제 높이를 지킨다(flexShrink 0). 기본값(1)이면 창보다 길 때 상자들이 눌려 줄어,
864 // 구역 위 빈 줄에 앞 구역 글자가 겹쳐 그려지고 한 줄짜리 '할 일' 제목은 아예 사라진다.
865 const body = blocks.map(x => x.el)
866
867 // 옆에 붙은 창(dock)은 창 높이를 꽉 채워 버튼 줄이 늘 맨 아래에 오게 한다. 프롬프트 위 창(inline)은 내용 높이만큼.
868 const isDock = isTerminal && e.props.placement === 'dock'
869 return (
870 <Box key="pane-root" flexDirection="column" paddingLeft={gutter} height={isDock ? e.props.scroll.bodyRows - 1 : undefined} backgroundColor={th.bg}>
871 <Box flexDirection="row" flexWrap="wrap" columnGap={1} borderStyle="round" borderColor={th.tabBorder} paddingX={1}>
872 {b.tabs.map(t => {
873 const isActive = t.title === tab.title
874 const name = isActive ? `▶ ${t.title}` : t.title
875 const marked = tabMark(t, states)
876 // 지금 탭 앞 화살표는 탭 줄 테두리와 같은 청록으로 칠한다.
877 const arrow = <Text key="arrow" color={th.tabBorder}>▶ </Text>
878 // 지금 탭은 기울인 굵은 글씨로 다른 탭과 구별한다. 엔진은 테두리([ ]) 있는 버튼을 통째로 굵게 칠하므로,
879 // 다른 탭은 테두리 없는 버튼(plain)에 [ ]를 글자로 넣어 보통 굵기로 그린다.
880 if (marked === undefined)
881 return (
882 <Button key={`tab-${t.title}`} label={name} variant={isActive ? 'primary' : 'secondary'} plain={isActive ? undefined : true} dimColor={!isActive} onPress={select(t.title)}>
883 <Text bold={isActive} italic={isActive} color={th.text}>{isActive ? [arrow, t.title] : `[ ${name} ]`}</Text>
884 </Button>
885 )
886 // 표시가 붙은 탭은 제목 앞에 표시를 붙이고 색을 칠한다(색을 못 보는 화면에서도 표시로 구별). 다 끝난 탭(✅)만 흐리게.
887 const mark = TAB_MARK[marked]
888 const color = marked === 'done' ? th.text : th.mark[marked]
889 const markedTitle = `${mark} ${t.title}`
890 return (
891 <Button key={`tab-${t.title}`} label={isActive ? `▶ ${markedTitle}` : markedTitle} variant={isActive ? 'primary' : 'secondary'} plain={isActive ? undefined : true} dimColor={!isActive && marked === 'done'} onPress={select(t.title)}>
892 <Text bold={isActive} italic={isActive} color={color}>{isActive ? [arrow, markedTitle] : `[ ${markedTitle} ]`}</Text>
893 </Button>
894 )
895 })}
896 </Box>
897 <Box key="body" flexDirection="column" flexGrow={1}>
898 {isScrollable ? (
899 <Box flexDirection="column">
900 {pager('scroll-up', off > 0 ? L.scrollUp(off) : undefined, up)}
901 <Box key="viewport" flexDirection="column" height={room} overflow="hidden">
902 <Box key="viewport-body" flexDirection="column" flexShrink={0} marginTop={-off}>
903 {body}
904 </Box>
905 </Box>
906 {pager('scroll-down', off < maxScroll ? L.scrollDown(maxScroll - off) : undefined, down)}
907 </Box>
908 ) : (
909 <Box key="content" flexDirection="column" flexShrink={0}>
910 {body}
911 </Box>
912 )}
913 </Box>
914 {bar}
915 </Box>
916 )
917 })
918}
919hooks/call-summary.ts 45 lines1// 대화창에 그릴 notes 도구 호출 한 줄. 탭 전체를 펼치지 않고 "무슨 탭에 무엇을 했는지"만 적는다.
2import { TEXT, type Strings } from './i18n'
3
4export const NOTES_TOOL_PREFIX = 'mcp__notes__notes_'
5
6// 설정 callDisplay 값. 기본은 요약.
7export type CallDisplay = '요약' | '숨김' | '원래대로'
8export const CALL_DISPLAYS: readonly CallDisplay[] = ['요약', '숨김', '원래대로']
9export const toCallDisplay = (value: unknown): CallDisplay =>
10 CALL_DISPLAYS.includes(value as CallDisplay) ? (value as CallDisplay) : '요약'
11
12const statusWord = (t: Strings, status: unknown) =>
13 status === 'blocked' ? t.blocked : status === 'waiting' ? t.waiting : status === 'clear' ? t.cleared : String(status)
14
15const count = (value: unknown) => (Array.isArray(value) ? value.length : 0)
16const nums = (value: unknown) => (Array.isArray(value) ? value.map(n => `#${n}`).join(', ') : '')
17
18const writeAction = (input: Record<string, unknown>, c: Strings['call']) => {
19 const parts = [
20 count(input.items) > 0 && c.steps(count(input.items)),
21 count(input.add_items) > 0 && c.addSteps(count(input.add_items)),
22 typeof input.goal === 'string' && c.goal,
23 typeof input.done_when === 'string' && c.doneWhen,
24 (count(input.results) > 0 || count(input.add_results) > 0) && c.results,
25 typeof input.memo === 'string' && c.memo,
26 ].filter((x): x is string => typeof x === 'string')
27 return parts.length > 0 ? parts.join(' · ') : c.refresh
28}
29
30// 도구 이름(notes_ 뒤)과 입력으로 "탭 · 한 일" 을 만든다. notes 도구가 아니면 undefined.
31export const callSummary = (tool: string, raw: unknown, t: Strings = TEXT.ko): string | undefined => {
32 if (!tool.startsWith(NOTES_TOOL_PREFIX)) return undefined
33 const input = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
34 const tab = typeof input.tab === 'string' && input.tab !== '' ? input.tab : undefined
35 const kind = tool.slice(NOTES_TOOL_PREFIX.length)
36 const action =
37 kind === 'write' ? writeAction(input, t.call)
38 : kind === 'check' ? `${nums(input.items)} ${input.done === false ? t.call.uncheck : t.call.check}`
39 : kind === 'status' ? `#${String(input.item)} ${statusWord(t, input.status)}`
40 : kind === 'read' ? (tab ? t.call.read : t.call.readAll)
41 : kind === 'close' ? t.call.close
42 : kind
43 return `📝 notes · ${tab ? `${tab} · ` : ''}${action}`
44}
45hooks/i18n.ts 253 lines1// 창·도구 설명·사용 규칙·대화창 요약 줄에 쓰는 문구. 설정 language(한국어 / English)로 고른다.
2// 탭 이름·단계 문구처럼 에이전트가 쓴 내용은 번역하지 않는다(사용 규칙이 에이전트에게 어느 언어로 쓸지 알려 준다).
3
4export type Lang = 'ko' | 'en'
5export const LANGUAGES = ['한국어', 'English'] as const
6export const toLang = (value: unknown): Lang => (value === 'English' ? 'en' : 'ko')
7
8const GUIDE_KO = [
9 '# notes (사용자 옆 창 노트)',
10 '사용자는 옆 창의 notes 로 작업 진행 상황을 본다. 대화를 위로 올려 찾지 않아도 되게 하는 것이 목적이다.',
11 '',
12 '## 언제 탭을 만드나 — 아래 중 하나라도 해당하면 첫 작업(도구 호출) 전에 notes_write 로 탭부터 만든다',
13 '- 할 일이 2개 이상이다. 사용자가 단계를 직접 정해 준 요청(예: "A 고치고, 검사하고, 답변 달아줘")도 포함한다. 그 단계를 그대로 items 로 옮긴다.',
14 '- 파일 수정, 배포·발행, 슬랙·위키·JIRA 게시처럼 결과가 남는 작업이 들어 있다.',
15 '- 조사·확인 요청이라도 끝에 판단이나 선택지(A/B/C안 등)를 사용자에게 넘기게 된다. 이때는 조사 단계와 "사용자 결정 대기"를 items 로 적는다.',
16 '- 스킬 절차를 따라 여러 단계를 진행한다.',
17 '쓰지 않는 경우: 한 번 조회하고 답하면 끝나는 질문, 잡담, 설명 요청.',
18 '',
19 '## 쓰는 법',
20 '- 탭 이름은 작업을 알아보기 쉬운 짧은 한글(15자 이내)로 직접 짓는다. 이어지는 후속 요청이면 새 탭 대신 기존 탭에 add_items 로 덧붙인다.',
21 '- 단계 하나를 끝낼 때마다 바로 notes_check 로 체크한다. 몰아서 체크하지 않는다.',
22 '- 종료 조건·완료 기준이 정해지면 done_when 에 적는다.',
23 '- 사용자가 꼭 봐야 할 것(결정 사항, 주의점, 사용자에게 넘긴 확인 요청, 커밋·푸시 대기 같은 남은 일)은 memo 에 적는다.',
24 '- memo 는 할 일이 아니라 참고 사항이다. 여러 건이면 한 건씩 줄을 바꿔 쓴다. 해야 할 일은 memo 가 아니라 items 에 넣는다.',
25 '- 계획이 바뀌면 items 로 단계를 고친다.',
26 '- 단계가 막히거나(권한·오류 등) 사용자·외부 답을 기다리게 되면 notes_status 로 그 단계에 blocked / waiting 과 사유(note)를 단다. 사용자가 풀어 주거나 답이 오면 status: clear 와 처리 내역(note)을 남긴다. 그 내역은 해당 단계 바로 아래에 보인다.',
27 '- 커밋·PR·파일 경로·링크처럼 작업 결과물이 생기면 add_results 로 결과물 칸에 모은다. 메모에 섞지 않는다.',
28 '- 창은 단계에 번호를 붙이지 않는다. 순서가 중요하면 문구 앞에 번호를 직접 붙인다(예: "1. ...", 하위 단계는 "1-1. ..."). 결정·질문 같은 단계에는 번호 대신 [결정] 같은 머리표를 써도 된다. 번호를 쓰면 탭 안에서 빠지거나 겹치지 않게 1부터 차례로 맞춘다.',
29 '- notes_read 결과의 #N 은 위치 번호(notes_check 에 넘기는 값)이고 문구 속 번호와 다를 수 있다.',
30 '- 사용자가 다른 주제로 넘어가면 기존 탭은 그대로 두고 새 탭을 만든다.',
31 '- 사용자가 "아까 거 어디까지 했지" 처럼 이전 작업을 물으면 먼저 notes_read 로 확인하고 답한다.',
32 '- 탭 삭제(notes_close)는 사용자가 정리를 원할 때만 한다.',
33].join('\n')
34
35const GUIDE_EN = [
36 '# notes (side-pane notes for the user)',
37 'The user follows the progress of your work in the notes side pane, so they never have to scroll back through the conversation.',
38 '',
39 '## When to create a tab: if any of these apply, create the tab with notes_write before your first action (tool call)',
40 '- There are 2 or more things to do, including requests where the user spelled out the steps (e.g. "fix A, test it, then reply"). Copy those steps into items as given.',
41 '- The work leaves a lasting result: editing files, deploying or publishing, posting to Slack, a wiki or JIRA.',
42 '- Even a research or check request counts if it ends by handing the user a decision or options (plan A/B/C). Write the research steps plus "waiting for the user\'s decision" as items.',
43 '- You are following a skill procedure through several steps.',
44 'Skip notes for: a question answered with one lookup, small talk, or a request for an explanation.',
45 '',
46 '## How to write',
47 '- Name the tab yourself with a short English name (30 characters or fewer) that makes the work easy to recognize. For a follow-up request on the same work, append to the existing tab with add_items instead of creating a new one.',
48 '- Check each step with notes_check as soon as you finish it. Do not check several at once later.',
49 '- Once the finish line or acceptance criteria are clear, write them in done_when.',
50 '- Put what the user must see (decisions, caveats, confirmations you handed to the user, remaining work such as a pending commit or push) in memo.',
51 '- memo is for reference, not for tasks. Put each entry on its own line. Things to do go in items, not memo.',
52 '- When the plan changes, rewrite the steps with items.',
53 '- When a step is blocked (permissions, errors) or waiting for the user or an outside answer, mark it with notes_status as blocked / waiting and give the reason (note). Once the user unblocks it or the answer arrives, leave status: clear with what was done (note). That note shows right under the step.',
54 '- Collect work products such as commits, PRs, file paths and links in the results section with add_results. Do not mix them into memo.',
55 '- The pane does not number steps. If order matters, number them yourself in the text (e.g. "1. ...", sub-steps "1-1. ..."). Decision or question steps may use a tag such as [Decision] instead of a number. If you number steps, keep them sequential from 1 within the tab, with no gaps or duplicates.',
56 '- #N in notes_read results is the position number (the value you pass to notes_check) and may differ from any number in the step text.',
57 '- When the user moves to a different topic, leave the existing tabs as they are and create a new tab.',
58 '- When the user asks about earlier work ("where were we with that?"), call notes_read first and then answer.',
59 '- Delete a tab (notes_close) only when the user wants it cleaned up.',
60].join('\n')
61
62const ko = {
63 guide: GUIDE_KO,
64 // 갱신 시각
65 justNow: '방금',
66 minAgo: (n: number) => `${n}분 전`,
67 hourAgo: (n: number) => `${n}시간 전`,
68 dayAgo: (n: number) => `${n}일 전`,
69 agoSample: '59분 전',
70 updated: (ago: string) => `${ago} 갱신`,
71 session: (id: string) => `세션 ${id}`,
72 noSession: '세션 기록 없음',
73 // 상태
74 blocked: '막힘',
75 waiting: '대기',
76 cleared: '풀림',
77 resolvedDefault: '해결됨',
78 resolved: '↳ 해결',
79 // 구역
80 goal: '목표',
81 doneWhen: '종료 조건',
82 todo: '할 일',
83 results: '결과물',
84 memo: '메모',
85 memoLines: (n: number) => `(${n}줄)`,
86 // 창
87 empty: '아직 노트가 없습니다. 에이전트가 계획을 세우면 여기에 탭이 생깁니다.',
88 removeTab: '탭 지우기',
89 undo: '↶ 되돌리기',
90 redo: '↷ 앞으로',
91 scrollUp: (n: number) => `▲ 위로 (${n}줄)`,
92 scrollDown: (n: number) => `▼ 아래로 (${n}줄)`,
93 // 되돌리기 기록에 남는 이름
94 logNewTab: (tab: string) => `에이전트: 새 탭 ${tab}`,
95 logEdit: (tab: string) => `에이전트: ${tab} 수정`,
96 logCheck: (tab: string) => `에이전트: ${tab} 체크`,
97 logStatus: (n: number, word: string) => `에이전트: #${n} ${word}`,
98 logClose: (tab: string) => `에이전트: 탭 지움 ${tab}`,
99 logToggle: (tab: string, n: number) => `${tab} #${n} 체크`,
100 logRemove: (tab: string) => `탭 지움: ${tab}`,
101 // 도구 결과·거절
102 failed: 'notes 모드 처리 중 오류가 났습니다.',
103 mainOnly: 'notes 는 메인 에이전트만 씁니다. 진행 상황은 결과로 메인 에이전트에게 돌려주세요.',
104 needTab: 'tab(탭 이름)이 필요합니다.',
105 noTab: (tab: string | undefined) => `'${tab}' 탭이 없습니다.`,
106 tabList: (names: string) => `탭 목록: ${names || '(없음)'}`,
107 badItems: (bad: string, max: number) => `없는 단계 번호: ${bad} (1~${max})`,
108 badStatus: 'status 는 blocked, waiting, clear 중 하나입니다.',
109 noTabs: '노트 탭이 없습니다.',
110 closed: (tab: string | undefined) => `'${tab}' 탭을 지웠습니다.`,
111 opened: (n: number) => (n === 0 ? '노트 창을 열었습니다. 아직 탭이 없습니다.' : `노트 창을 열었습니다. 탭 ${n}개.`),
112 // 명령·도구 설명
113 command: '노트 옆 창 열기·닫기 (계획·진행상황·종료 조건)',
114 paneClosed: '노트 창을 닫았습니다. /notes 로 다시 엽니다.',
115 tools: {
116 write:
117 '사용자가 바로 볼 노트 탭을 만들거나 고친다(옆 창에 표시, 작업 폴더별 유지). ' +
118 '할 일이 2개 이상이거나(사용자가 단계를 정해 준 요청 포함) 파일 수정·게시처럼 결과가 남는 작업이면 첫 작업 전에 탭을 만들어 단계를 items 로 적고, 종료 조건이 있으면 done_when 에 적는다. ' +
119 '탭 이름(tab)은 작업 내용을 알아보기 쉬운 짧은 한글로 직접 짓는다(15자 이내). 같은 이름이 있으면 준 값만 고친다. ' +
120 'items 는 단계 전체를 바꾸고, add_items 는 끝에 덧붙인다.',
121 writeTab: '탭 이름',
122 writeGoal: '이 작업의 목표 한 줄',
123 writeDoneWhen: '종료 조건',
124 writeItems: '단계 목록 전체(기존 체크 상태는 같은 문구면 유지)',
125 writeAddItems: '끝에 덧붙일 단계',
126 writeMemo: '사용자가 바로 봐야 할 메모(결정 사항·주의점 등)',
127 writeResults: '결과물 목록 전체(커밋·PR·파일 경로·링크 등)',
128 writeAddResults: '결과물 칸 끝에 덧붙일 것',
129 status:
130 '단계에 막힘(blocked)·대기(waiting) 표시와 사유를 달거나, 풀렸을 때 status: clear 와 처리 내역을 남긴다(단계 바로 아래에 보임). ' +
131 'item 은 notes_read 결과의 #N 위치 번호(1부터).',
132 statusItem: '위치 번호 #N (1부터)',
133 statusStatus: 'blocked=막힘, waiting=대기, clear=풀림',
134 statusNote: 'blocked/waiting 이면 사유, clear 면 어떻게 풀었는지',
135 check: '노트 탭의 단계를 완료(또는 미완료)로 표시한다. 단계를 끝낼 때마다 바로 부른다. items 에는 notes_read 결과의 #N 위치 번호(1부터)를 넘긴다. 문구 속 번호가 아니다.',
136 checkItems: '위치 번호 #N (1부터, 문구 속 번호 아님)',
137 checkDone: '기본 true, false 면 완료 취소',
138 read: '노트를 읽는다. tab 을 빼면 탭 목록과 진행률. 사용자가 "아까 거 어디까지 했지" 처럼 이전 작업을 물으면 먼저 부른다.',
139 close: '다 끝났거나 필요 없어진 노트 탭을 지운다. 사용자가 정리를 원할 때만 부른다.',
140 },
141 // 대화창 요약 줄
142 call: {
143 steps: (n: number) => `단계 ${n}개`,
144 addSteps: (n: number) => `단계 ${n}개 추가`,
145 goal: '목표',
146 doneWhen: '종료 조건',
147 results: '결과물',
148 memo: '메모',
149 refresh: '갱신',
150 check: '체크',
151 uncheck: '체크 취소',
152 read: '읽음',
153 readAll: '탭 목록 읽음',
154 close: '탭 닫음',
155 failed: ' · 실패',
156 interrupted: ' · 중단됨',
157 },
158}
159
160export type Strings = typeof ko
161
162const en: Strings = {
163 guide: GUIDE_EN,
164 justNow: 'just now',
165 minAgo: n => `${n} min ago`,
166 hourAgo: n => `${n} h ago`,
167 dayAgo: n => `${n} d ago`,
168 agoSample: '59 min ago',
169 updated: ago => `updated ${ago}`,
170 session: id => `session ${id}`,
171 noSession: 'no session record',
172 blocked: 'blocked',
173 waiting: 'waiting',
174 cleared: 'cleared',
175 resolvedDefault: 'resolved',
176 resolved: '↳ resolved',
177 goal: 'Goal',
178 doneWhen: 'Done when',
179 todo: 'To do',
180 results: 'Results',
181 memo: 'Memo',
182 memoLines: n => `(${n} ${n === 1 ? 'line' : 'lines'})`,
183 empty: 'No notes yet. A tab appears here once the agent makes a plan.',
184 removeTab: 'Delete tab',
185 undo: '↶ Undo',
186 redo: '↷ Redo',
187 scrollUp: n => `▲ Up (${n} ${n === 1 ? 'line' : 'lines'})`,
188 scrollDown: n => `▼ Down (${n} ${n === 1 ? 'line' : 'lines'})`,
189 logNewTab: tab => `agent: new tab ${tab}`,
190 logEdit: tab => `agent: edit ${tab}`,
191 logCheck: tab => `agent: check ${tab}`,
192 logStatus: (n, word) => `agent: #${n} ${word}`,
193 logClose: tab => `agent: delete tab ${tab}`,
194 logToggle: (tab, n) => `check ${tab} #${n}`,
195 logRemove: tab => `delete tab: ${tab}`,
196 failed: 'The notes mod hit an error.',
197 mainOnly: 'Only the main agent writes notes. Return your progress to the main agent as your result.',
198 needTab: 'tab (the tab name) is required.',
199 noTab: tab => `No tab named '${tab}'.`,
200 tabList: names => `Tabs: ${names || '(none)'}`,
201 badItems: (bad, max) => `No such step number: ${bad} (1-${max})`,
202 badStatus: 'status must be one of blocked, waiting, clear.',
203 noTabs: 'There are no note tabs.',
204 closed: tab => `Deleted tab '${tab}'.`,
205 opened: n => (n === 0 ? 'Opened the notes pane. No tabs yet.' : `Opened the notes pane. ${n} ${n === 1 ? 'tab' : 'tabs'}.`),
206 command: 'Open or close the notes side pane (plan, progress, finish line)',
207 paneClosed: 'Closed the notes pane. Run /notes to open it again.',
208 tools: {
209 write:
210 'Create or update a note tab the user sees right away (shown in the side pane, kept per project folder). ' +
211 'If there are 2 or more things to do (including steps the user spelled out) or the work leaves a result such as file edits or posts, create the tab before your first action, list the steps in items, and put the finish line in done_when if there is one. ' +
212 'Name the tab (tab) yourself with a short English name that makes the work easy to recognize (30 characters or fewer). If a tab with that name exists, only the given fields change. ' +
213 'items replaces the whole step list; add_items appends to the end.',
214 writeTab: 'Tab name',
215 writeGoal: 'One-line goal of this work',
216 writeDoneWhen: 'Finish line / acceptance criteria',
217 writeItems: 'Full list of steps (steps with the same text keep their check state)',
218 writeAddItems: 'Steps to append at the end',
219 writeMemo: 'Notes the user must see right away (decisions, caveats, etc.)',
220 writeResults: 'Full list of work products (commits, PRs, file paths, links, etc.)',
221 writeAddResults: 'Items to append to the results section',
222 status:
223 'Mark a step blocked or waiting with a reason, or when it is unblocked leave status: clear with what was done (shown right under the step). ' +
224 'item is the #N position number from notes_read (starting at 1).',
225 statusItem: 'Position number #N (starting at 1)',
226 statusStatus: 'blocked, waiting, or clear (unblocked)',
227 statusNote: 'For blocked/waiting the reason; for clear how it was resolved',
228 check: 'Mark steps of a note tab done (or not done). Call it right after finishing each step. Pass the #N position numbers from notes_read (starting at 1), not numbers inside the step text.',
229 checkItems: 'Position numbers #N (starting at 1, not numbers in the text)',
230 checkDone: 'Default true; false marks the steps not done',
231 read: 'Read the notes. Without tab, returns the tab list with progress. Call it first when the user asks about earlier work ("where were we with that?").',
232 close: 'Delete a note tab that is finished or no longer needed. Call it only when the user wants it cleaned up.',
233 },
234 call: {
235 steps: n => `${n} ${n === 1 ? 'step' : 'steps'}`,
236 addSteps: n => `+${n} ${n === 1 ? 'step' : 'steps'}`,
237 goal: 'goal',
238 doneWhen: 'done when',
239 results: 'results',
240 memo: 'memo',
241 refresh: 'updated',
242 check: 'checked',
243 uncheck: 'unchecked',
244 read: 'read',
245 readAll: 'read tab list',
246 close: 'tab deleted',
247 failed: ' · failed',
248 interrupted: ' · interrupted',
249 },
250}
251
252export const TEXT: Record<Lang, Strings> = { ko, en }
253hooks/theme.ts 72 lines1// 터미널 옆 창의 색 묶음. /config 의 notes.theme 로 고르고, notes.background 로 창 배경색만 따로 덮어쓴다.
2// 기본 = 지금까지 모습(터미널 배경·글자색 그대로). 다른 테마는 배경을 칠하므로 글자색도 함께 정한다.
3export const THEME_NAMES = ['기본', '어둡게', '밝게', '고대비'] as const
4export type ThemeName = (typeof THEME_NAMES)[number]
5
6export type NotesTheme = {
7 // 창 배경, 글자색. 비어 있으면 터미널 것을 그대로 쓴다.
8 bg?: string
9 text?: string
10 // 지금 하는 단계(안 끝난 첫 단계) 줄.
11 current: { bg: string; text: string }
12 tabBorder: string
13 barBorder: string
14 pagerBg: string
15 section: { goal: string; doneWhen: string; todo: string; results: string; memo: string }
16 mark: { blocked: string; ask: string; waiting: string; working: string }
17}
18
19const BASE: NotesTheme = {
20 current: { bg: '#2e6b3f', text: 'white' },
21 tabBorder: 'cyan',
22 barBorder: 'gray',
23 pagerBg: 'gray',
24 section: { goal: 'cyan', doneWhen: 'yellow', todo: 'green', results: 'blue', memo: 'magenta' },
25 mark: { blocked: 'red', ask: 'cyan', waiting: 'yellow', working: 'green' },
26}
27
28const THEMES: Record<ThemeName, NotesTheme> = {
29 기본: BASE,
30 어둡게: {
31 bg: '#1e2230',
32 text: '#e6e9ef',
33 current: { bg: '#2e6b3f', text: '#ffffff' },
34 tabBorder: '#7aa2f7',
35 barBorder: '#565f89',
36 pagerBg: '#3b4261',
37 section: { goal: '#7dcfff', doneWhen: '#e0af68', todo: '#9ece6a', results: '#7aa2f7', memo: '#bb9af7' },
38 mark: { blocked: '#f7768e', ask: '#7dcfff', waiting: '#e0af68', working: '#9ece6a' },
39 },
40 밝게: {
41 bg: '#fafafa',
42 text: '#1f2328',
43 current: { bg: '#d6f0dc', text: '#14532d' },
44 tabBorder: '#0969da',
45 barBorder: '#8c959f',
46 pagerBg: '#d0d7de',
47 section: { goal: '#0969da', doneWhen: '#9a6700', todo: '#1a7f37', results: '#0550ae', memo: '#8250df' },
48 mark: { blocked: '#cf222e', ask: '#0969da', waiting: '#9a6700', working: '#1a7f37' },
49 },
50 고대비: {
51 bg: '#000000',
52 text: '#ffffff',
53 current: { bg: '#ffff00', text: '#000000' },
54 tabBorder: '#00ffff',
55 barBorder: '#ffffff',
56 pagerBg: '#444444',
57 section: { goal: '#00ffff', doneWhen: '#ffff00', todo: '#00ff00', results: '#5c9dff', memo: '#ff66ff' },
58 mark: { blocked: '#ff3333', ask: '#00ffff', waiting: '#ffff00', working: '#00ff00' },
59 },
60}
61
62export const BASE_THEME = BASE
63
64const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
65
66// 설정값 → 색 묶음. 모르는 테마 이름은 기본, 배경색은 #RGB·#RRGGBB 일 때만 덮어쓴다.
67export function toTheme(name: unknown, background: unknown): NotesTheme {
68 const theme = THEMES[THEME_NAMES.includes(name as ThemeName) ? (name as ThemeName) : '기본']
69 const bg = typeof background === 'string' ? background.trim() : ''
70 return HEX.test(bg) ? { ...theme, bg } : theme
71}
72hooks/share-scope.ts 31 lines1import type { NoteTab } from '../types'
2
3// 노트를 어느 세션끼리 함께 보는지. /config 의 notes.shareScope, 기본은 전체.
4// 전체 = 이 PC 의 모든 세션(작업 폴더 상관없이), 같은 폴더 = 같은 작업 폴더의 세션끼리, 세션마다 = 공유 안 함.
5export const SHARE_SCOPES = ['전체', '같은 폴더', '세션마다'] as const
6export type ShareScope = (typeof SHARE_SCOPES)[number]
7export const toShareScope = (value: unknown): ShareScope =>
8 SHARE_SCOPES.includes(value as ShareScope) ? (value as ShareScope) : '전체'
9
10// 전체 공유 파일. 폴더별 파일 이름은 영문·숫자·- 만 쓰므로 _ 로 시작하는 이 이름과 겹치지 않는다.
11export const SHARED_FILE = '_all.json'
12export const SESSION_DIR = 'sessions'
13
14// notes-data 폴더 안에서 노트 파일의 상대 경로.
15export const noteFileName = (scope: ShareScope, root: string, sessionId: string) => {
16 if (scope === '전체') return SHARED_FILE
17 if (scope === '세션마다') return `${SESSION_DIR}/${sessionId.replace(/[^A-Za-z0-9-]/g, '')}.json`
18 return `${root.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '')}.json`
19}
20
21// 처음 전체 공유로 바꿨을 때 폴더별 노트를 한 파일로 모은다. 이름이 같은 탭은 더 최근에 고친 쪽을 남기고,
22// 오래된 것부터 늘어놓아 최근 max 개만 둔다.
23export function mergeTabs(lists: NoteTab[][], max: number): NoteTab[] {
24 const byTitle = new Map<string, NoteTab>()
25 for (const tab of lists.flat()) {
26 const kept = byTitle.get(tab.title)
27 if (kept === undefined || (Date.parse(tab.updatedAt) || 0) > (Date.parse(kept.updatedAt) || 0)) byTitle.set(tab.title, tab)
28 }
29 return [...byTitle.values()].sort((x, y) => (Date.parse(x.updatedAt) || 0) - (Date.parse(y.updatedAt) || 0)).slice(-max)
30}
31types/index.d.ts 38 lines1// status: 막힘(blocked)·대기(waiting) 표시, note 는 그 사유. 풀리면 status 를 비우고 resolved 에 처리 내역을 남긴다.
2export type NoteItemStatus = 'blocked' | 'waiting'
3export type NoteItem = { text: string; isDone: boolean; status?: NoteItemStatus; note?: string; resolved?: string }
4
5// 이 탭을 마지막으로 다룬 세션. title 은 /rename 이름(없으면 자동 제목), 둘 다 없으면 비어 있다.
6export type NoteSession = { id: string; title?: string }
7
8// 세션 상태: working = 일하는 중(턴 진행), ask = 질문·허락 창이 떠서 사람 답을 기다림, idle = 쉼.
9export type LiveState = 'working' | 'ask' | 'idle'
10
11export type NoteTab = {
12 title: string
13 goal?: string
14 doneWhen?: string
15 items: NoteItem[]
16 results?: string[]
17 memo?: string
18 session?: NoteSession
19 updatedAt: string
20}
21
22// 되돌리기 한 건: 바뀐 탭마다 바뀌기 전 모습(before)과 바뀐 뒤 이름(afterTitle).
23// before 만 있으면 지워진 탭, afterTitle 만 있으면 새로 생긴 탭이다.
24export type NoteChangePart = { index: number; before?: NoteTab; afterTitle?: string }
25export type NoteChange = { label: string; at: string; parts: NoteChangePart[] }
26
27// history = 되돌릴 변경, future = 되돌린 뒤 다시 할 변경(앞으로가기). 예전 파일엔 future 가 없을 수 있다.
28export type NotesBook = { root: string; tabs: NoteTab[]; history: NoteChange[]; future?: NoteChange[]; active?: string }
29
30declare module 'claude-code' {
31 interface PluginState {
32 // tick: 'N분 전 갱신' 이 흐르도록 30초마다 오르는 수.
33 // folded: 접어 둔 구역, 키 = "<탭 이름>|<구역>".
34 // live: 세션 id → 상태(탭 줄의 ●·💬 표시용).
35 notes: { book: NotesBook; scroll: number; tick: number; folded: Record<string, boolean>; live: Record<string, LiveState> }
36 }
37}
38