A mod plugin for Claude Code — gives every session a note where you and the agent jot down what to do or check, and tick it off together.

A mod plugin for Claude Code — gives every session a note where you and the agent jot down what to do or check, and tick it off together. For multi-step work, the session lays out a process and shows which step it is on.

session-note is a mod plugin for Claude Code. Mods let a plugin draw its own parts right inside the Claude Code screen, so the note is not a separate window: it sits just above the prompt and in a pane beside the terminal.
Work with an agent for a while and you will hear yourself say things like:
In the moment you are sure you will remember. But as the conversation grows, these lines scroll away and are gone, and the agent will not bring them back up unless you do. That is how the things you put off quietly get forgotten.
session-note gives every session a small note. Put the things you postponed and the things to check in its to-do list, and tick them off when they are done. The note always sits right above the prompt, and the agent reads the same note and keeps track with you.
When you hand the session multi-step work, it lays out the order as a process in the note. A process is a roadmap the session writes itself and keeps current as the work moves along. A glance above the prompt tells you which step it is on, and when you come back from something else you can see right away how far it got.
Type these two lines inside Claude Code:
/plugin marketplace add angar2/session-note
/plugin install session-note@angar2
The line just above the prompt (the band) has two areas, left and right.
☑ 2 ≡ │ ◼ 2.1 Read the logs Open
☑ 2 on the left is the number of open to-dos. When the agent makes a proposal, it shows up next to it after a │.≡ │ ◼ 2.1 Read the logs on the right is the process step the session is working on. It is empty when no step is in progress.Open at the right end opens the pane. It stays in place even when a proposal comes in, and reads Close while the pane is open.… first. Symbols, numbers and buttons always stay.Say "let's check token expiry handling later" during the conversation, and the agent puts a proposal on the left of the band.
☑ 2 │ [+] Check token expiry [Add] [Dismiss] ≡ │ ◼ 2.1 Read the logs Open
[+] means "add this as a new item?"[Add] to put it in the note, or [Dismiss] if you don't need it.When that work is finished, the agent checks it against the note and proposes completing it.
☑ 2 │ [✓] Check token expiry [Complete] [Not yet] ≡ │ ◼ 2.1 Read the logs Open
[✓] means "this item looks done."[Complete] if it is, or [Not yet] if something is left.[+] 2 new · [✓] 1 done. Press Open at the right end to answer them one by one in the To do tab of the pane.Hand the session multi-step work, like "Fix the login bug" or "Ship the 1.1.0 release", and it lays out a process and tells you in one line. It does not make one for a quick question.
☑ 1 ≡ │ ◼ 1 Reproduce Open
☑ 1 ≡ │ ◼ 2.1 Read the logs Open
+1 more.Press Open at the right end of the band, or type /note, and the pane opens on the right side of the terminal. In a narrow window it opens above the prompt.
The top of the pane has two tabs, like ☑ To do 2 ≡ Process 1. The numbers are the open to-dos and the unfinished processes. Press a tab to switch. The pane reopens on the tab you saw last.
The To do tab works like this:
+ New item, and the done items.+ New item, write a title and details, and press [Add]. Items you wrote are marked with ☺.☐. Press ☑ on a done item to open it again.[Edit] [Delete].Everything is done with the mouse. The keyboard is only for typing titles and details.
Press the ≡ Process tab to see the whole process.
◼ Fix the login bug
◼ 1 Reproduce
◼ 2 Find the cause ▾
◼ 2.1 Read the logs
◻ 2.2 Form a hypothesis
◻ 3 Fix it
◻ 4 Add a test
◻ Ship 1.1.0 ▸ paused at 3
──────────────────────
Finished 1 ▸
◻ waiting, blue ◼ in progress, green ◼ done, red ◼ stopped.▸. Press its title and it turns into ▾ and shows the sub-steps. At first only the steps in progress are expanded.paused at 3. Press it to look inside.Finished N ▸ at the bottom.Ask the agent, and it does it right away, without a proposal.
To-dos
| Say | The agent |
|---|---|
| "Put this in the note" | adds an item |
| "That one is done" | completes the item |
| "Rename that item" | edits the item |
| "Delete that one" | deletes the item |
| "Check the note" | goes over the open items and proposes completing the finished ones |
The agent never edits or deletes to-dos unless you tell it to.
Processes
| Say | The agent |
|---|---|
| "Remove step 3" | removes step 3 from the current process |
| "Put this before step 2" | inserts a step before step 2 |
| "Move step 4 after step 2" | reorders the steps |
| "Hold the release, let's look at this bug first" | lays out a new process and starts it; the one on hold remembers where it stopped |
| "Delete that process" | deletes the process |
The session updates the process itself as it works. It moves through steps and fills in sub-steps without a word, and gives a one-line reason only when it changes the main steps.
Change these any time with /plugin configure session-note@angar2. Your notes keep their items.
| Setting | Values | Default | Description |
|---|---|---|---|
| Language | English · 한국어 | English | The language of the band and the pane |
| Band background | None · Gray · Olive · Wine | None | The background color of the band. Pick one if you want the band to stand out |
The colors follow your terminal theme.
/clear starts a new session, so you start with an empty note. The old note stays with the old session./resume, its note comes back with it./resume, the session gets the note's to-dos and process again and picks up from the step it was on.Each session's note is one JSON file:
~/.claude/plugins/data/session-note-angar2/sessions/<session id>.json
Uninstalling the plugin deletes the notes too. To keep them, add --keep-data when you uninstall:
claude plugin uninstall session-note@angar2 --keep-data
/clear or /resume./rewind rewinds the conversation but not the note. The agent can read the note again to get the latest state.hooks/register.tsx 59 lines1// session-note 모드의 훅 모듈 진입점 — 세션의 노트를 열고 띠·패널·/note·할 일 도구·과정 도구를 등록한다
2import type { EngineInterface, Register } from 'claude-code'
3
4import { drawBand } from './band'
5import { drawPane } from './pane'
6import { arrival, wholeNote } from './tell'
7import { backgroundOf, langOf, textOf, type Text } from './text'
8import { registerProcessTool, serveProcessTool } from './process-tool'
9import { registerTool, serveTool } from './todo-tool'
10import { PANE, openPane, openSession, paneClosed, readNote } from './view'
11
12export const register: Register = (on, options) => {
13 const text = textOf(langOf(options.language))
14
15 // 세션 시작과 모드가 다시 켜질 때마다: /note·할 일 도구·과정 도구 등록, 이 세션의 노트 읽기, 패널이 열려 있는지 맞추기
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'note', description: text.command })
18 await registerTool($)
19 await registerProcessTool($)
20 await openSession($, text, await $.session.id())
21 return next(e)
22 })
23
24 // /note — 패널을 열고 대화창에 줄을 남기지 않는다
25 on('command.run', { command: 'note' }, async $ => {
26 await openPane($, text.notes)
27 return {}
28 })
29
30 // 패널이 닫히면(띠의 닫기·엔진의 닫기 표시) 초안을 버린다
31 on('ui.close', { id: PANE }, async ($, e, next) => {
32 const closed = await next(e)
33 await paneClosed($)
34 return closed
35 })
36
37 // 세션이 바뀌면(/clear·/resume·--fork-session) 그 세션의 노트로 갈아끼우고, 불러온 세션과 압축 직후에는 할 일과 과정을 전한다(spec AGENT.md 4·5절·6-5).
38 // 훅의 additionalContext로 넘겨야 압축·불러온 대화 뒤에 놓인다($.session.append는 압축 경계보다 앞에 놓인다 — 실제 세션에서 확인)
39 on('classic.SessionStart', async ($, e, next) => {
40 const answered = await next(e)
41 const told = await switchTo($, text, e.source, e.session_id)
42 if (told === null) return answered
43 return { ...answered, additionalContext: [...(answered.additionalContext ?? []), told] }
44 })
45
46 serveTool(on)
47 serveProcessTool(on)
48 drawBand(on, text, backgroundOf(options.bandBackground))
49 drawPane(on, text)
50}
51
52// 세션 전환·압축 때 세션에 전할 글, 없으면 null
53async function switchTo($: EngineInterface, text: Text, source: string, id: string): Promise<string | null> {
54 if (source === 'compact') return wholeNote(await readNote($))
55 if (source !== 'clear' && source !== 'resume' && source !== 'fork') return null
56 const shown = await openSession($, text, id)
57 return source === 'clear' ? null : arrival(shown)
58}
59hooks/band.tsx 163 lines1// 입력창 위 띠 — 왼쪽은 할 일(열린 항목 수, 세션의 제안), 오른쪽은 과정(지금 단계), 맨 오른쪽은 늘 있는 열기·닫기 (spec UI.md 1절)
2import type { On } from 'claude-code'
3
4import { activeProcess, currentOf } from './process'
5import { DIVIDER, LABEL, MARK, SQUARE, buttonLook, cellsOf, cut } from './text'
6import type { Text } from './text'
7import {
8 acceptProposal,
9 dismissProposal,
10 focusAfter,
11 isPaneOpen,
12 liveProposals,
13 openItems,
14 readNote,
15 togglePane,
16} from './view'
17
18// 과정 단계 제목에 남길 최소 칸(앞 띄어쓰기 포함) — 이보다 좁으면 제목을 빼고 번호만 남긴다
19const MIN_STEP_TITLE = 6
20
21// background: 설정에서 고른 띠 배경 테마 키(없으면 배경 없음). 터미널에서만 칠한다
22export function drawBand(on: On, text: Text, background?: string) {
23 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
24 if (e.props.hasSurvey) return next(e)
25 const { Box, Button, Text: T } = $.ui.resolve(e)
26 const current = await readNote($)
27 const count = openItems(current).length
28 const proposals = liveProposals(current)
29 const isEmpty = count === 0 && proposals.length === 0
30 const isOpen = await isPaneOpen($)
31
32 // 제안이 하나면 그 제목과 받기·무시 버튼, 여럿이면 개수 요약(열기가 할 일 탭으로 연다)
33 const only = proposals.length === 1 ? proposals[0] : undefined
34 const many = proposals.length > 1
35 const toggleLabel = isOpen ? text.close : text.open
36 const titleOf = (itemId: string) => current.items.find(item => item.id === itemId)?.title ?? ''
37 const fresh = proposals.filter(p => p.kind === 'new').length
38 const finished = proposals.length - fresh
39 const summary = [
40 fresh > 0 ? `${MARK.new.label} ${text.pendingNew(fresh)}` : '',
41 finished > 0 ? `${MARK.done.label} ${text.pendingDone(finished)}` : '',
42 ]
43 .filter(part => part !== '')
44 .join(' · ')
45 const proposalTitle = only === undefined ? '' : only.kind === 'new' ? only.title : titleOf(only.itemId)
46 const accept = only === undefined ? '' : buttonLook(e.surface, only.kind === 'new' ? text.accept : text.complete).label
47 const dismiss = only === undefined ? '' : buttonLook(e.surface, only.kind === 'new' ? text.dismiss : text.notYet).label
48
49 // 진행 중 과정의 지금 단계 — 없으면 과정 구역을 비운다
50 const proc = activeProcess(current)
51 const now = proc === undefined ? null : currentOf(proc)
52 const stepHead = now === null ? '' : `${LABEL.process}${DIVIDER}${SQUARE.active.label} ${now.at.number}`
53 const others = now === null || now.others === 0 ? '' : text.others(now.others)
54
55 // 좁은 띠: 글자 폭을 세어 과정 단계 제목부터 줄이고, 다음으로 제안 제목을 줄인다. 기호·개수·번호·버튼은 자르지 않는다
56 const fixed =
57 cellsOf(`${LABEL.todo} ${count}`) +
58 (only !== undefined ? cellsOf(DIVIDER) + cellsOf(MARK[only.kind].label) + 2 + 1 + cellsOf(accept) + 1 + cellsOf(dismiss) : 0) +
59 (many ? cellsOf(DIVIDER) + cellsOf(summary) : 0) +
60 (now === null ? 0 : 2 + cellsOf(stepHead) + (others === '' ? 0 : 1 + cellsOf(others))) +
61 2 +
62 cellsOf(toggleLabel)
63 const room = e.props.bodyColumns - fixed
64 const wantProposal = cellsOf(proposalTitle)
65 const wantStep = now === null ? 0 : 1 + cellsOf(now.at.step.title)
66 let proposalCells = wantProposal
67 let stepCells = wantStep
68 if (wantProposal + wantStep > room) {
69 stepCells = room - wantProposal
70 if (stepCells < MIN_STEP_TITLE) {
71 stepCells = 0
72 proposalCells = Math.max(1, room)
73 }
74 }
75 const stepTitle = now === null || stepCells === 0 ? '' : ` ${cut(now.at.step.title, stepCells - 1)}`
76
77 const mark = (kind: 'new' | 'done') => <T color={MARK[kind].color}>{MARK[kind].label}</T>
78 const pending =
79 only !== undefined ? (
80 <Box key="pending" flexDirection="row" flexShrink={1} minWidth={0}>
81 <T dimColor>{DIVIDER}</T>
82 <T wrap="truncate-end">
83 {mark(only.kind)}
84 {' '}
85 {cut(proposalTitle, proposalCells)}
86 </T>
87 <Box flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
88 <Button
89 key={`accept-${only.id}`}
90 {...buttonLook(e.surface, only.kind === 'new' ? text.accept : text.complete)}
91 onPress={() => void focusAfter($, e.requestId, 'toggle', acceptProposal($, text, only.id))}
92 />
93 <Button
94 key={`dismiss-${only.id}`}
95 dimColor
96 {...buttonLook(e.surface, only.kind === 'new' ? text.dismiss : text.notYet)}
97 onPress={() => void focusAfter($, e.requestId, 'toggle', dismissProposal($, text, only.id))}
98 />
99 </Box>
100 </Box>
101 ) : many ? (
102 <Box key="pending" flexDirection="row" flexShrink={1} minWidth={0}>
103 <T dimColor>{DIVIDER}</T>
104 <T wrap="truncate-end">
105 {fresh > 0 ? mark('new') : ''}
106 {fresh > 0 ? <T dimColor> {text.pendingNew(fresh)}</T> : ''}
107 {fresh > 0 && finished > 0 ? <T dimColor>{' · '}</T> : ''}
108 {finished > 0 ? mark('done') : ''}
109 {finished > 0 ? <T dimColor> {text.pendingDone(finished)}</T> : ''}
110 </T>
111 </Box>
112 ) : null
113
114 // 과정 구역 — 보기만 하는 표시(버튼 없음)
115 const step =
116 now === null ? null : (
117 <Box key="process" flexShrink={0}>
118 <T wrap="truncate-end">
119 <T dimColor>
120 {LABEL.process}
121 {DIVIDER}
122 </T>
123 <T color={SQUARE.active.color}>{SQUARE.active.label}</T> {now.at.number}
124 {stepTitle}
125 {others === '' ? '' : <T dimColor> {others}</T>}
126 </T>
127 </Box>
128 )
129
130 return (
131 <Box
132 flexDirection="row"
133 justifyContent="space-between"
134 width={e.props.bodyColumns}
135 backgroundColor={e.surface === 'terminal' ? background : undefined}
136 >
137 <Box flexDirection="row" flexShrink={1} minWidth={0}>
138 <Box key="count" flexShrink={0}>
139 {isEmpty ? (
140 <T dimColor>{LABEL.todo} 0</T>
141 ) : (
142 <T>
143 <T dimColor>{LABEL.todo}</T>{' '}
144 <T bold>{String(count)}</T>
145 </T>
146 )}
147 </Box>
148 {pending}
149 </Box>
150 <Box flexDirection="row" gap={2} flexShrink={0} marginLeft={2}>
151 {step}
152 <Button
153 key="toggle"
154 plain
155 label={toggleLabel}
156 onPress={() => void togglePane($, text.notes, isOpen, many ? 'todo' : undefined)}
157 />
158 </Box>
159 </Box>
160 )
161 })
162}
163hooks/pane.tsx 383 lines1// 오른쪽 노트 패널 — 할 일·과정 탭. 할 일 탭은 세션의 제안·열린 항목·새 항목·완료 묶음과 펼침·수정·삭제 (spec UI.md 2·3절)
2import type { EngineInterface, On, RenderChildren } from 'claude-code'
3
4import type { Item } from './note'
5import { isFinished } from './process'
6import { drawProcessTab } from './process-pane'
7import { LABEL, MARK, buttonLook } from './text'
8import type { Text } from './text'
9import {
10 PANE,
11 acceptProposal,
12 addItem,
13 dismissProposal,
14 doneItems,
15 editFieldKey,
16 focusAfter,
17 liveProposals,
18 readNote,
19 openItems,
20 readView,
21 removeItem,
22 saveEdit,
23 setView,
24 startEdit,
25 toggleDone,
26 type Draft,
27 type Tab,
28} from './view'
29
30const SHOWN_DONE = 5
31
32// 2026-10-16T14:05:00+09:00 → 2026-10-16 14:05 (쓴 곳의 현지 시각 그대로)
33const shortTime = (iso: string) => iso.slice(0, 16).replace('T', ' ')
34
35export function drawPane(on: On, text: Text) {
36 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
37 const elements = $.ui.resolve(e)
38 const { Box, Button, Text: T } = elements
39 // 입력칸이 없는 화면(모바일)에서는 보기와 체크만 한다
40 const Input = 'Input' in elements ? elements.Input : null
41 const current = await readNote($)
42 const view = await readView($)
43 const open = openItems(current)
44 const done = doneItems(current)
45 // 구분선 — 넉넉히 그리고 한 줄로 잘라 데스크톱에서도 패널 끝까지 닿게 한다
46 const rule = (
47 <Box height={1} overflow="hidden">
48 <T dimColor>{'─'.repeat(Math.max(1, e.props.bodyColumns) * 4)}</T>
49 </Box>
50 )
51 // 누른 버튼이 사라지는 동작은 끝난 뒤 커서를 남는 버튼(key)으로 옮긴다(view.ts focusAfter)
52 const stay = (key: string, action: Promise<unknown>) => void focusAfter($, PANE, key, action)
53 const addKey = view.adding === null ? 'add' : 'add-cancel'
54 // 같은 목록에서 바로 아래(없으면 위)의 제목 버튼, 그것도 없으면 fallback
55 const nearTitle = (list: { id: string }[], id: string, prefix: string, fallback: string) => {
56 const at = list.findIndex(one => one.id === id)
57 const other = list[at + 1] ?? list[at - 1]
58 return other === undefined ? fallback : `${prefix}${other.id}`
59 }
60 const itemNear = (item: Item) =>
61 item.status === 'done'
62 ? nearTitle(shownDone, item.id, 'title-', 'done-group')
63 : nearTitle(open, item.id, 'title-', addKey)
64
65 // 새 항목·수정의 제목·내용 칸. 친 글은 그 칸 값만 상태에 쓰고, Enter는 상태의 초안 전체로 저장한다
66 const fields = (kind: 'add' | 'edit', draft: Draft) =>
67 Input === null
68 ? []
69 : (['title', 'body'] as const).map(field => (
70 <Input
71 key={kind === 'add' ? `add-${field}` : editFieldKey(field, view.editKey)}
72 placeholder={field === 'title' ? text.title : text.details}
73 value={draft[field]}
74 submitLabel={kind === 'add' ? text.submitAdd : text.submitSave}
75 onInput={(value: string) => void setDraft($, kind, field, value)}
76 onSubmit={(value: string) =>
77 stay(
78 kind === 'add' ? 'add' : `title-${view.editing?.id}`,
79 setDraft($, kind, field, value).then(() =>
80 kind === 'add' ? addFromView($, text) : saveFromView($, text),
81 ),
82 )
83 }
84 />
85 ))
86
87 const row = (item: Item) => {
88 const isDone = item.status === 'done'
89 const isOpen = view.openId === item.id
90 const editing = view.editing?.id === item.id ? view.editing : null
91 const check = (
92 <Button
93 key={`check-${item.id}`}
94 plain
95 dimColor
96 label={isDone ? '☑' : '☐'}
97 onPress={() => stay(itemNear(item), toggleDone($, text, item.id))}
98 />
99 )
100 // 체크 칸 옆 세로 묶음 — 제목·내용·입력 칸·버튼을 제목 시작 위치에 맞춘다
101 const beside = (...children: RenderChildren[]) => (
102 <Box key={`row-${item.id}`} flexDirection="row" gap={1} alignItems="flex-start">
103 {check}
104 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
105 {children}
106 </Box>
107 </Box>
108 )
109 if (editing !== null) {
110 const [title, body] = fields('edit', editing)
111 return [
112 beside(
113 title,
114 body,
115 <Box flexDirection="row" gap={1} justifyContent="flex-end">
116 <Button
117 key="edit-ok"
118 {...buttonLook(e.surface, text.saveOk)}
119 onPress={() => stay(`title-${item.id}`, saveFromView($, text))}
120 />
121 <Button
122 key="edit-cancel"
123 dimColor
124 {...buttonLook(e.surface, text.cancel)}
125 onPress={() => stay(`title-${item.id}`, setView($, v => ({ ...v, editing: null })))}
126 />
127 </Box>,
128 ),
129 ]
130 }
131 // 제목만 누르면 펼치고 접는다
132 const head = (
133 <Box flexDirection="row">
134 <Button key={`title-${item.id}`} plain onPress={() => void toggleOpen($, item.id)}>
135 <T wrap="wrap">{item.title}</T>
136 {item.source === 'user' ? <T dimColor> ☺</T> : ''}
137 </Button>
138 </Box>
139 )
140 if (!isOpen) return [beside(head)]
141 const actions =
142 view.armedId === item.id ? (
143 <Box flexDirection="row" gap={1}>
144 <Button
145 key={`delete-ok-${item.id}`}
146 {...buttonLook(e.surface, text.delOk)}
147 onPress={() => stay(itemNear(item), removeItem($, text, item.id))}
148 />
149 <Button
150 key="delete-cancel"
151 dimColor
152 {...buttonLook(e.surface, text.cancel)}
153 onPress={() => stay(`delete-${item.id}`, setView($, v => ({ ...v, armedId: null })))}
154 />
155 </Box>
156 ) : (
157 <Box flexDirection="row" gap={1}>
158 {Input !== null && (
159 <Button
160 key={`edit-${item.id}`}
161 dimColor
162 {...buttonLook(e.surface, text.edit)}
163 onPress={() => void startEdit($, text.notes, item)}
164 />
165 )}
166 <Button
167 key={`delete-${item.id}`}
168 dimColor
169 {...buttonLook(e.surface, text.del)}
170 onPress={() => stay('delete-cancel', setView($, v => ({ ...v, armedId: item.id })))}
171 />
172 </Box>
173 )
174 return [
175 beside(
176 head,
177 <T dimColor wrap="wrap">
178 {item.body === '' ? text.noDetails : item.body}
179 </T>,
180 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start">
181 <T dimColor>{shortTime(item.createdAt)}</T>
182 {actions}
183 </Box>,
184 ),
185 ]
186 }
187
188 const adding = view.adding
189 const addRows =
190 Input === null
191 ? []
192 : adding === null
193 ? [
194 <Button
195 key="add"
196 plain
197 dimColor
198 label={text.newItem}
199 onPress={() =>
200 stay(
201 'add-cancel',
202 setView($, v => ({ ...v, editing: null, armedId: null, adding: { title: '', body: '' } })),
203 )
204 }
205 />,
206 ]
207 : [
208 ...fields('add', adding),
209 <Box flexDirection="row" gap={1} justifyContent="flex-end">
210 <Button
211 key="add-ok"
212 {...buttonLook(e.surface, text.addOk)}
213 onPress={() => stay('add', addFromView($, text))}
214 />
215 <Button
216 key="add-cancel"
217 dimColor
218 {...buttonLook(e.surface, text.cancel)}
219 onPress={() => stay('add', setView($, v => ({ ...v, adding: null })))}
220 />
221 </Box>,
222 ]
223
224 const shownDone = view.showAll ? done : done.slice(0, SHOWN_DONE)
225 const doneRows =
226 done.length === 0
227 ? []
228 : [
229 <Button
230 key="done-group"
231 plain
232 dimColor
233 label={`${text.doneGroup(done.length)} ${view.showDone ? '▾' : '▸'}`}
234 onPress={() =>
235 void setView($, v => ({ ...v, showDone: !v.showDone, showAll: v.showDone ? false : v.showAll }))
236 }
237 />,
238 ...(view.showDone ? shownDone.flatMap(row) : []),
239 ...(view.showDone && !view.showAll && done.length > SHOWN_DONE
240 ? [
241 <Box paddingLeft={2}>
242 <Button
243 key="more"
244 dimColor
245 {...buttonLook(e.surface, text.more(done.length - SHOWN_DONE))}
246 onPress={() => stay(`title-${done[SHOWN_DONE]?.id}`, setView($, v => ({ ...v, showAll: true })))}
247 />
248 </Box>,
249 ]
250 : []),
251 ]
252
253 // 맨 위의 제안 — 띠와 같은 표시와 버튼으로 하나씩. 제목을 누르면 내용(완료 제안은 그 항목의 내용)을 펼친다
254 const proposals = liveProposals(current)
255 const proposalNear = (id: string) =>
256 nearTitle(proposals, id, 'ptitle-', open[0] === undefined ? addKey : `title-${open[0].id}`)
257 const proposalRows = proposals.flatMap(p => {
258 const target = p.kind === 'done' ? current.items.find(item => item.id === p.itemId) : undefined
259 const body = p.kind === 'new' ? p.body : (target?.body ?? '')
260 const detail = view.openId === p.id ? (
261 <T dimColor wrap="wrap">
262 {body === '' ? text.noDetails : body}
263 </T>
264 ) : null
265 return [
266 <Box
267 key={`proposal-${p.id}`}
268 flexDirection="row"
269 justifyContent="space-between"
270 alignItems="flex-start"
271 gap={2}
272 >
273 <Box flexDirection="row" gap={2} flexShrink={1} minWidth={0} alignItems="flex-start">
274 <Box flexShrink={0} width={3}>
275 <T color={MARK[p.kind].color}>{MARK[p.kind].label}</T>
276 </Box>
277 <Box flexDirection="column" flexShrink={1} minWidth={0}>
278 <Box flexDirection="row">
279 <Button key={`ptitle-${p.id}`} plain onPress={() => void toggleOpen($, p.id)}>
280 <T wrap="wrap">{p.kind === 'new' ? p.title : (target?.title ?? '')}</T>
281 </Button>
282 </Box>
283 {detail}
284 </Box>
285 </Box>
286 <Box flexDirection="row" gap={1} flexShrink={0}>
287 <Button
288 key={`accept-${p.id}`}
289 {...buttonLook(e.surface, p.kind === 'new' ? text.accept : text.complete)}
290 onPress={() => stay(proposalNear(p.id), acceptProposal($, text, p.id))}
291 />
292 <Button
293 key={`dismiss-${p.id}`}
294 dimColor
295 {...buttonLook(e.surface, p.kind === 'new' ? text.dismiss : text.notYet)}
296 onPress={() => stay(proposalNear(p.id), dismissProposal($, text, p.id))}
297 />
298 </Box>
299 </Box>,
300 ]
301 })
302
303 // 맨 위 탭 줄 — 고른 탭은 밝고 굵게, 다른 탭은 회색. N은 남은 할 일 수·안 끝난 과정 수
304 const tabButton = (tab: Tab, label: string) => {
305 const chosen = view.tab === tab
306 return (
307 <Button key={`tab-${tab}`} plain onPress={() => void setView($, v => ({ ...v, tab }))}>
308 <T bold={chosen ? true : undefined} dimColor={chosen ? undefined : true}>
309 {label}
310 </T>
311 </Button>
312 )
313 }
314 // 제목은 터미널에서만 본문에 쓴다 — 데스크톱은 머리말에 제목이 있다
315 const head = (
316 <Box flexDirection="column">
317 {e.surface === 'terminal' ? <T bold>{text.notes}</T> : null}
318 <Box flexDirection="row" gap={4}>
319 {tabButton('todo', `${LABEL.todo} ${text.todo} ${open.length}`)}
320 {tabButton(
321 'process',
322 `${LABEL.process} ${text.process} ${current.processes.filter(proc => !isFinished(proc)).length}`,
323 )}
324 </Box>
325 {rule}
326 </Box>
327 )
328
329 if (view.tab === 'process')
330 return (
331 <Box flexDirection="column" width={e.props.bodyColumns} gap={1}>
332 {head}
333 {drawProcessTab($, { Box, Button, Text: T }, text, current, view, rule)}
334 </Box>
335 )
336 return (
337 <Box flexDirection="column" width={e.props.bodyColumns} gap={1}>
338 {head}
339 {proposalRows.length === 0 ? null : (
340 <Box flexDirection="column">
341 {proposalRows}
342 {rule}
343 </Box>
344 )}
345 <Box flexDirection="column">
346 {open.length === 0 ? <T dimColor>{text.none}</T> : open.flatMap(row)}
347 {rule}
348 {addRows}
349 </Box>
350 {doneRows.length === 0 ? null : <Box flexDirection="column">{doneRows}</Box>}
351 </Box>
352 )
353 })
354}
355
356// 항목·제안의 제목을 누르면 펼치고, 다시 누르거나 다른 것을 펼치면 접는다. 접을 때 그 항목의 수정 초안과 삭제 확인은 버린다
357async function toggleOpen($: EngineInterface, id: string) {
358 await setView($, v =>
359 v.openId === id
360 ? { ...v, openId: null, editing: null, armedId: null }
361 : { ...v, openId: id, editing: null, armedId: null },
362 )
363}
364
365// 초안의 한 칸만 바꾼다(그사이 다른 칸에 친 글을 덮지 않는다)
366async function setDraft($: EngineInterface, kind: 'add' | 'edit', field: keyof Draft, value: string) {
367 await setView($, v => {
368 if (kind === 'add') return v.adding === null ? v : { ...v, adding: { ...v.adding, [field]: value } }
369 return v.editing === null ? v : { ...v, editing: { ...v.editing, [field]: value } }
370 })
371}
372
373// [추가]·[저장]·칸의 Enter는 지금까지 친 초안을 쓴다
374async function addFromView($: EngineInterface, text: Text) {
375 const view = await readView($)
376 if (view.adding !== null) await addItem($, text, view.adding)
377}
378
379async function saveFromView($: EngineInterface, text: Text) {
380 const view = await readView($)
381 if (view.editing !== null) await saveEdit($, text, view.editing.id, view.editing)
382}
383hooks/tell.ts 129 lines1// 세션(모델)에게 노트를 전하는 글 — 사용자가 바꾼 것 한 줄, 노트 전체, 과정 보기 (spec AGENT.md 4절·6-5). 세션이 읽는 글이라 영어 하나로 쓴다
2import type { EngineInterface } from 'claude-code'
3
4import { doneItems, liveProposals, openItems } from './note'
5import type { Item, Note, Process, Proposal, Step } from './note'
6import { activeProcess, isFinished, numbered, processesInOrder, statusOf, stoppedAt } from './process'
7
8// 노트 전체를 한 번에 전하는 글의 상한 — 넘으면 내용을 빼고 제목만 보낸다
9const LIMIT = 4000
10const HEAD = '[session-note]'
11
12// 세션에게 보이는 번호는 id의 앞 8글자다
13export const refOf = (id: string) => id.slice(0, 8)
14
15const named = (item: Item) => `[${refOf(item.id)}] "${item.title}"`
16
17export const told = {
18 added: (item: Item) => `${HEAD} The user added ${named(item)} to the note.`,
19 edited: (item: Item) =>
20 `${HEAD} The user edited ${named(item)}${item.body === '' ? '' : ` — details: ${item.body}`}.`,
21 removed: (item: Item) => `${HEAD} The user deleted ${named(item)} from the note.`,
22 completed: (item: Item) => `${HEAD} The user completed ${named(item)}.`,
23 reopened: (item: Item) => `${HEAD} The user reopened ${named(item)}.`,
24 accepted: (item: Item) => `${HEAD} The user accepted your proposal; it is now item ${named(item)}.`,
25 dismissed: (title: string) => `${HEAD} The user dismissed your proposal "${title}".`,
26 confirmed: (item: Item) => `${HEAD} The user confirmed ${named(item)} is done.`,
27 notYet: (item: Item) => `${HEAD} The user said ${named(item)} is not done yet; it stays open.`,
28}
29
30const proposalLine = (note: Note, p: Proposal) => {
31 if (p.kind === 'new') return `- [${refOf(p.id)}] new item: "${p.title}"`
32 const item = note.items.find(one => one.id === p.itemId)
33 return `- [${refOf(p.id)}] done: ${item === undefined ? `[${refOf(p.itemId)}]` : named(item)}`
34}
35
36// 노트를 글로 — 열린 항목은 제목과 내용, 완료 항목은 제목, 사용자가 아직 답하지 않은 제안
37export function describe(note: Note, withBody: boolean): string {
38 const open = openItems(note)
39 const done = doneItems(note)
40 const proposals = liveProposals(note)
41 const lines = [`${HEAD} The session note now:`, 'Open items:']
42 if (open.length === 0) lines.push('- (none)')
43 for (const item of open) {
44 lines.push(`- ${named(item)}`)
45 if (withBody && item.body !== '') lines.push(` ${item.body}`)
46 }
47 if (done.length > 0) lines.push('Done items:', ...done.map(item => `- ${named(item)}`))
48 if (proposals.length > 0)
49 lines.push('Your proposals still waiting for the user:', ...proposals.map(p => proposalLine(note, p)))
50 return lines.join('\n')
51}
52
53// 단계마다 한 줄: 깊이만큼 들여 쓰고 화면 순번·숨은 번호·제목·상태. 메모는 다음 줄
54const stepLines = (steps: Step[], withMemo: boolean) =>
55 numbered(steps).flatMap(({ step, number, depth }) => {
56 const pad = ' '.repeat(depth)
57 const line = `${pad}${number} [${refOf(step.id)}] "${step.title}" (${statusOf(step)})`
58 return withMemo && step.memo !== '' ? [line, `${pad} memo: ${step.memo}`] : [line]
59 })
60
61// 과정 하나 — 머리줄과 단계 전체
62export const processLines = (proc: Process, withMemo: boolean) => [
63 `[${refOf(proc.id)}] "${proc.title}" (${isFinished(proc) ? 'finished' : proc.status})`,
64 ...(proc.steps.length === 0 ? [' (no steps)'] : stepLines(proc.steps, withMemo)),
65]
66
67// 과정 도구의 보기 — 끝나지 않은 과정은 단계 전체(진행 중 과정이 맨 위), 끝난 과정은 제목만
68export function describeProcesses(note: Note): string {
69 const shown = processesInOrder(note)
70 if (shown.length === 0) return 'No processes in this session yet.'
71 const finished = shown.filter(isFinished)
72 const lines = shown.filter(proc => !isFinished(proc)).flatMap(proc => processLines(proc, true))
73 if (finished.length > 0)
74 lines.push('Finished processes:', ...finished.map(proc => `- [${refOf(proc.id)}] "${proc.title}"`))
75 return lines.join('\n')
76}
77
78const hasOpenProcess = (note: Note) => note.processes.some(proc => !isFinished(proc))
79
80// 압축·불러온 세션에 전하는 과정 — 진행 중 과정은 단계 전체, 대기 과정은 제목과 멈춘 자리
81function handedProcesses(note: Note, withMemo: boolean): string[] {
82 const active = activeProcess(note)
83 const waiting = processesInOrder(note).filter(proc => proc !== active && !isFinished(proc))
84 if (active === undefined && waiting.length === 0) return []
85 const lines = ['Your processes:']
86 if (active !== undefined) lines.push('Active process:', ...processLines(active, withMemo))
87 if (waiting.length > 0)
88 lines.push(
89 'Waiting processes:',
90 ...waiting.map(proc => {
91 const at = stoppedAt(proc)
92 const where = at === undefined ? '' : ` — stopped at ${at.number} "${at.step.title}"`
93 return `- [${refOf(proc.id)}] "${proc.title}"${where}`
94 }),
95 )
96 return lines
97}
98
99// 압축 직후에 전하는 노트 전체(할 일과 과정). 비었으면 한 줄, 상한을 넘으면 내용·메모를 빼고 제목만
100export function wholeNote(note: Note): string {
101 if (note.items.length === 0 && liveProposals(note).length === 0 && !hasOpenProcess(note))
102 return `${HEAD} This session's note is empty.`
103 const full = [describe(note, true), ...handedProcesses(note, true)].join('\n')
104 if (full.length <= LIMIT) return full
105 return [
106 describe(note, false),
107 ...handedProcesses(note, false),
108 'Details were left out; use the list action of the todo and process tools to read them.',
109 ].join('\n')
110}
111
112// /resume·--fork-session으로 불러온 세션에 전하는 글 — 열린 항목·답하지 않은 제안·끝나지 않은 과정이 있으면 전체, 완료만 있으면 한 줄, 비었으면 한 줄
113export function arrival(note: Note): string {
114 if (openItems(note).length > 0 || liveProposals(note).length > 0 || hasOpenProcess(note)) return wholeNote(note)
115 const done = doneItems(note).length
116 if (done > 0) return `${HEAD} No open items in this session's note (${done} done).`
117 return `${HEAD} This session's note is empty.`
118}
119
120// 세션에게 한 줄을 전한다. 사용자 화면에는 보이지 않고 모델만 읽는다. 거부되면 디버그 로그에만 남긴다(6-4 ⑦)
121export async function tell($: EngineInterface, text: string) {
122 try {
123 const stored = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
124 if ('deny' in stored) $.ui.log(`not told: ${String(stored.deny)}`, { to: 'debug' })
125 } catch (error) {
126 $.ui.log(`not told: ${String(error)}`, { to: 'debug' })
127 }
128}
129hooks/text.ts 142 lines1// 띠·패널의 화면 문구 표 — 설치 때 고른 화면 언어(English·한국어)로 고른다 (spec UI.md 5절)
2
3export type Lang = 'en' | 'ko'
4
5const TEXT = {
6 en: {
7 notes: 'Notes',
8 todo: 'To do',
9 process: 'Process',
10 others: (n: number) => `+${n} more`,
11 pausedAt: (number: string) => `paused at ${number}`,
12 finished: (n: number) => `Finished ${n}`,
13 noProcesses: 'No open processes',
14 open: 'Open',
15 close: 'Close',
16 newItem: '+ New item',
17 title: 'Title',
18 details: 'Details',
19 addOk: 'Add',
20 saveOk: 'Save',
21 cancel: 'Cancel',
22 edit: 'Edit',
23 del: 'Delete',
24 delOk: 'Confirm delete',
25 none: 'No open items',
26 noDetails: '(No details)',
27 doneGroup: (n: number) => `Done ${n}`,
28 more: (n: number) => `Show more (${n})`,
29 submitAdd: 'Add',
30 submitSave: 'Save',
31 accept: 'Add',
32 dismiss: 'Dismiss',
33 complete: 'Complete',
34 notYet: 'Not yet',
35 pendingNew: (n: number) => `${n} new`,
36 pendingDone: (n: number) => `${n} done`,
37 command: 'Open the notes pane',
38 saveFailed: "Couldn't save the note",
39 unreadable: "Couldn't read the saved note; kept a copy",
40 },
41 ko: {
42 notes: '노트',
43 todo: '할 일',
44 process: '과정',
45 others: (n: number) => `외 ${n}`,
46 pausedAt: (number: string) => `${number}에서 멈춤`,
47 finished: (n: number) => `끝난 과정 ${n}`,
48 noProcesses: '남은 과정이 없어요',
49 open: '열기',
50 close: '닫기',
51 newItem: '+ 새 항목',
52 title: '제목',
53 details: '내용',
54 addOk: '추가',
55 saveOk: '저장',
56 cancel: '취소',
57 edit: '수정',
58 del: '삭제',
59 delOk: '삭제 확인',
60 none: '남은 항목이 없어요',
61 noDetails: '(내용 없음)',
62 doneGroup: (n: number) => `완료 ${n}`,
63 more: (n: number) => `더 보기 (${n})`,
64 submitAdd: '추가',
65 submitSave: '저장',
66 accept: '추가',
67 dismiss: '무시',
68 complete: '완료',
69 notYet: '아직',
70 pendingNew: (n: number) => `새 항목 ${n}`,
71 pendingDone: (n: number) => `끝난 항목 ${n}`,
72 command: '노트 패널을 열어요',
73 saveFailed: '노트를 저장하지 못했어요',
74 unreadable: '저장된 노트를 읽지 못해 사본을 남겼어요',
75 },
76}
77
78export type Text = (typeof TEXT)['en']
79
80// 제안 종류 표시 — 언어와 상관없이 새 항목 제안은 파란 [+], 완료 제안은 초록 [✓] (테마 키)
81export const MARK = {
82 new: { label: '[+]', color: 'suggestion' },
83 done: { label: '[✓]', color: 'success' },
84} as const
85
86// 띠·탭의 이름표 기호 — 할 일 ☑, 과정 ≡ (이름표와 같은 회색)
87export const LABEL = { todo: '☑', process: '≡' } as const
88
89// 띠의 흐린 구분선 — 할 일 개수와 제안 사이, 과정 기호와 지금 단계 사이
90export const DIVIDER = ' │ '
91
92// 단계·과정의 상태 네모 — 대기 ◻, 진행 중·완료·중단 ◼에 파랑·초록·빨강 (테마 키)
93export const SQUARE = {
94 waiting: { label: '◻', color: undefined },
95 active: { label: '◼', color: 'suggestion' },
96 done: { label: '◼', color: 'success' },
97 stopped: { label: '◼', color: 'error' },
98} as const
99
100// 글자의 터미널 칸 수 — 한글·한자·전각·그림 글자는 두 칸, 그 밖은 한 칸
101const isWide = (code: number) =>
102 (code >= 0x1100 && code <= 0x115f) ||
103 (code >= 0x2e80 && code <= 0xa4cf) ||
104 (code >= 0xac00 && code <= 0xd7a3) ||
105 (code >= 0xf900 && code <= 0xfaff) ||
106 (code >= 0xfe30 && code <= 0xfe4f) ||
107 (code >= 0xff00 && code <= 0xff60) ||
108 (code >= 0xffe0 && code <= 0xffe6) ||
109 (code >= 0x1f300 && code <= 0x1faff) ||
110 (code >= 0x20000 && code <= 0x3fffd)
111
112export const cellsOf = (line: string) => [...line].reduce((n, ch) => n + (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1), 0)
113
114// cells 칸 안에 들어가게 끝을 …로 줄인다
115export function cut(line: string, cells: number): string {
116 if (cellsOf(line) <= cells) return line
117 let kept = ''
118 for (const ch of line) {
119 if (cellsOf(kept + ch) > cells - 1) break
120 kept += ch
121 }
122 return `${kept.trimEnd()}…`
123}
124
125// 동작 버튼 — 두 화면 모두 글자 버튼이고, 터미널만 [이름]으로 감싼다
126export function buttonLook(surface: string, label: string) {
127 return { plain: true as const, label: surface === 'terminal' ? `[${label}]` : label }
128}
129
130// userConfig language 값 → 문구. 모르는 값은 기본 English
131export const langOf = (value: unknown): Lang => (value === '한국어' ? 'ko' : 'en')
132
133export const textOf = (lang: Lang): Text => TEXT[lang]
134
135// userConfig bandBackground 값 → 띠 배경 테마 키. None·모르는 값은 배경 없음 (spec UI.md 1절)
136const BACKGROUND = { Gray: 'subtle', Olive: 'diffAddedDimmed', Wine: 'diffRemovedDimmed' } as const
137
138export const backgroundOf = (value: unknown) =>
139 typeof value === 'string' && Object.hasOwn(BACKGROUND, value)
140 ? BACKGROUND[value as keyof typeof BACKGROUND]
141 : undefined
142hooks/process-tool.ts 340 lines1// 세션(모델)이 직접 짜고 고치는 과정 도구(process) — 보기·짜기·단계 넣기·고치기·빼기·옮기기·상태 바꾸기·진행 중 과정 바꾸기·제목 고치기·지우기 (spec AGENT.md 6절)
2// 도구는 세션의 호출을 행동 이유로 거절하지 않는다. 거절은 데이터 모양(깊이·상태 값·없는 번호)에만 한다
3import type { EngineInterface, On } from 'claude-code'
4
5import { isoAt, newId } from './note'
6import type { Note, Process, Step, StepStatus } from './note'
7import {
8 MAX_DEPTH,
9 STEP_STATUSES,
10 activate,
11 activeProcess,
12 editStep,
13 findProcess,
14 findStep,
15 heightOf,
16 insertSteps,
17 leavesOf,
18 moveStep,
19 removeSteps,
20 setStatus,
21 settle,
22 statusOf,
23 withProcess,
24} from './process'
25import { describeProcesses, processLines, refOf } from './tell'
26import { readNote, saveChange } from './view'
27
28const NAME = 'process'
29const CALLED = 'mcp__session-note__process'
30
31// 행동은 권고로만 준다(내장 작업 목록처럼) — 할 일 도구 설명보다 확실히 짧게(spec AGENT.md 6-4)
32const DESCRIPTION = `Your process: a roadmap you write and keep current for multi-step work with the user. The user sees the current step above the prompt and the whole process in the side pane, and changes it only by asking you. Steps and processes have an 8-character ref like [a1b2c3d4]; a step can also be named by its on-screen number ("3", "2.1"), in the active process unless you give one.
33
34When: use it proactively, without being asked, for work that will span several turns or steps the user may later ask about ("where are we?"); skip one-reply tasks. Make one if the user asks; not if they say no. Plan the top level at the start; fill sub-steps as you enter a step. Two levels by default, a third only when needed.
35
36Status: set a step active before you start it and done right after. stopped = not finished or dropped. Postponed for another process is not stopped. If the user says remove a step, remove it.
37
38Say one line when you create a process, or change its top level on your own (with why). Don't mention starting, finishing or filling steps, or changes the user asked for.
39
40Interruptions: a quick side task stays out (todo); a prerequisite goes in as a sub-step where you are; a big task with another goal is a new process (the old one waits); a changed goal: show the new plan, get an OK, create it, stop the old unfinished steps. Say any change in a very short line.
41
42Titles: short noun phrases (~15 chars), not repeating the parent, not one-word tags. If the built-in task list is on, plan here only.`
43
44const STEP_INPUT = {
45 type: 'object',
46 properties: {
47 title: { type: 'string' },
48 memo: { type: 'string', description: 'Notes for you; not shown to the user.' },
49 status: { type: 'string', enum: STEP_STATUSES },
50 steps: { type: 'array', items: { type: 'object' }, description: 'Sub-steps, same shape.' },
51 },
52 required: ['title'],
53} as const
54
55const SCHEMA = {
56 type: 'object',
57 properties: {
58 action: {
59 type: 'string',
60 enum: ['list', 'create', 'add', 'edit', 'remove', 'move', 'set', 'switch', 'rename', 'delete'],
61 description:
62 'list; create (title, steps, active=false to keep it waiting); add (steps at parent/before/after); edit (id, title, memo); remove (ids); move (id to parent/before/after); set (changes); switch (make process active); rename (process, title); delete (process).',
63 },
64 process: { type: 'string', description: 'Process ref; default the active process.' },
65 title: { type: 'string' },
66 memo: { type: 'string' },
67 steps: { type: 'array', items: STEP_INPUT },
68 id: { type: 'string', description: 'Step ref or number.' },
69 ids: { type: 'array', items: { type: 'string' } },
70 parent: { type: 'string', description: 'Put under this step; omit for the top level.' },
71 before: { type: 'string' },
72 after: { type: 'string' },
73 changes: {
74 type: 'array',
75 items: {
76 type: 'object',
77 properties: { id: { type: 'string' }, status: { type: 'string', enum: STEP_STATUSES } },
78 required: ['id', 'status'],
79 },
80 },
81 active: { type: 'boolean' },
82 },
83 required: ['action'],
84} as const
85
86type Answer = { result: string } | { deny: string }
87
88const textOf = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
89
90// [a1b2c3d4]·a1b2c3d4·id 전체·순번 모두 받는다
91const refIn = (value: unknown) => textOf(value).replace(/^\[|\]$/g, '')
92
93const unsaved: Answer = { deny: "Couldn't save the note (or it is not open yet). Try again later." }
94
95const named = (step: Step) => `[${refOf(step.id)}] "${step.title}"`
96
97export function registerProcessTool($: EngineInterface) {
98 return $.tool.register({ name: NAME, description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
99}
100
101export function serveProcessTool(on: On) {
102 on('tool.call', { tool: CALLED }, async ($, e) => answer($, e as unknown as Record<string, unknown>)).catch(() => ({
103 deny: 'The process tool failed. Try again later.',
104 }))
105}
106
107async function answer($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
108 const action = textOf(args['action'])
109 const note = await readNote($)
110 if (action === 'list') return { result: describeProcesses(note) }
111 if (action === 'create') return create($, args)
112 const procRef = refIn(args['process'])
113 const proc = procRef === '' ? activeProcess(note) : findProcess(note, procRef)
114 if (proc === undefined)
115 return {
116 deny:
117 procRef === ''
118 ? 'There is no active process. Give a process ref, or create one.'
119 : `No process [${procRef}]. Use list to see the refs.`,
120 }
121 if (action === 'switch') return save($, activate, proc, `"${proc.title}" is now the active process.`)
122 if (action === 'rename') {
123 const title = textOf(args['title'])
124 if (title === '') return { deny: 'Give the new process title.' }
125 return change($, proc, one => ({ ...one, title }), `Renamed the process to "${title}".`)
126 }
127 if (action === 'delete') {
128 const drop = (n: Note, id: string) => ({ ...n, processes: n.processes.filter(one => one.id !== id) })
129 return save($, drop, proc, `Deleted "${proc.title}".`)
130 }
131 if (action === 'add') return add($, proc, args)
132 if (action === 'edit') return edit($, proc, args)
133 if (action === 'remove') return remove($, proc, args)
134 if (action === 'move') return move($, proc, args)
135 if (action === 'set') return set($, proc, args)
136 return {
137 deny: `Unknown action "${action}". Use list, create, add, edit, remove, move, set, switch, rename or delete.`,
138 }
139}
140
141// 노트를 바꿔 저장하고 한 줄로 답한다
142async function save($: EngineInterface, edit: (n: Note, id: string) => Note, proc: Process, line: string) {
143 const saved = await saveChange($, n => edit(n, proc.id))
144 return saved === null ? unsaved : { result: line }
145}
146
147// 과정 하나를 바꿔 저장한다. 답은 한 줄, full이면 바뀐 과정 전체를 붙인다
148async function change(
149 $: EngineInterface,
150 proc: Process,
151 edit: (proc: Process) => Process,
152 line: string,
153 full = false,
154): Promise<Answer> {
155 const saved = await saveChange($, n => withProcess(n, proc.id, edit))
156 if (saved === null) return unsaved
157 const now = saved.processes.find(one => one.id === proc.id)
158 if (!full || now === undefined) return { result: line }
159 return { result: [line, ...processLines(now, false)].join('\n') }
160}
161
162// 입력 단계 → 새 단계(새 숨은 번호). 제목이 없거나 상태 값이 틀리면 이유를 돌려준다
163function stepsIn(value: unknown): Step[] | string {
164 if (value === undefined) return []
165 if (!Array.isArray(value)) return 'steps must be a list.'
166 const steps: Step[] = []
167 for (const raw of value) {
168 const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
169 const title = textOf(input['title'])
170 if (title === '') return 'Every step needs a title.'
171 const status = input['status'] === undefined ? 'waiting' : textOf(input['status'])
172 if (!STEP_STATUSES.includes(status as StepStatus))
173 return `Unknown status "${status}". Use waiting, active, done or stopped.`
174 const below = stepsIn(input['steps'])
175 if (typeof below === 'string') return below
176 steps.push({ id: newId(), title, memo: textOf(input['memo']), status: status as StepStatus, steps: below })
177 }
178 return steps
179}
180
181const tooDeep = `Steps go at most ${MAX_DEPTH} levels deep.`
182
183async function create($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
184 const title = textOf(args['title'])
185 if (title === '') return { deny: 'A process needs a title.' }
186 const steps = stepsIn(args['steps'])
187 if (typeof steps === 'string') return { deny: steps }
188 if (steps.some(step => heightOf(step) > MAX_DEPTH)) return { deny: tooDeep }
189 const isActive = args['active'] !== false
190 const proc: Process = {
191 id: newId(),
192 title,
193 status: 'waiting',
194 createdAt: isoAt(await $.clock.now()),
195 steps: settle(steps),
196 }
197 const saved = await saveChange($, n => {
198 const added = { ...n, processes: [...n.processes, proc] }
199 return isActive ? activate(added, proc.id) : added
200 })
201 if (saved === null) return unsaved
202 const made = saved.processes.find(one => one.id === proc.id) ?? proc
203 return { result: ['Created:', ...processLines(made, false)].join('\n') }
204}
205
206// 넣을 자리 — before·after면 그 단계의 형제 자리, parent면 그 아래 끝, 아무것도 없으면 1단 끝
207type Place = { parentId: string | null; index: number; depth: number }
208
209function placeIn(proc: Process, args: Record<string, unknown>): Place | string {
210 for (const side of ['before', 'after'] as const) {
211 const ref = refIn(args[side])
212 if (ref === '') continue
213 const near = findStep(proc, ref)
214 if (near === undefined) return `No step ${ref}. Use list to see the steps.`
215 const siblings = near.parentId === null ? proc.steps : (findStep(proc, near.parentId)?.step.steps ?? [])
216 const at = siblings.findIndex(one => one.id === near.step.id)
217 return { parentId: near.parentId, index: side === 'before' ? at : at + 1, depth: near.depth }
218 }
219 const ref = refIn(args['parent'])
220 if (ref === '') return { parentId: null, index: proc.steps.length, depth: 1 }
221 const parent = findStep(proc, ref)
222 if (parent === undefined) return `No step ${ref}. Use list to see the steps.`
223 return { parentId: parent.step.id, index: parent.step.steps.length, depth: parent.depth + 1 }
224}
225
226async function add($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
227 const steps = stepsIn(args['steps'])
228 if (typeof steps === 'string') return { deny: steps }
229 if (steps.length === 0) return { deny: 'Give the steps to add.' }
230 const place = placeIn(proc, args)
231 if (typeof place === 'string') return { deny: place }
232 if (steps.some(step => place.depth + heightOf(step) - 1 > MAX_DEPTH)) return { deny: tooDeep }
233 return change(
234 $,
235 proc,
236 one => insertSteps(one, place.parentId, place.index, steps),
237 `Added ${steps.map(named).join(', ')}. Now:`,
238 true,
239 )
240}
241
242async function edit($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
243 const ref = refIn(args['id'])
244 const found = findStep(proc, ref)
245 if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
246 const title = textOf(args['title'])
247 const memo = typeof args['memo'] === 'string' ? args['memo'].trim() : undefined
248 if (title === '' && memo === undefined) return { deny: 'Give a new title or memo to edit.' }
249 const line = `Edited ${found.number} ${named({ ...found.step, title: title || found.step.title })}.`
250 return change($, proc, one => editStep(one, found.step.id, title, memo), line)
251}
252
253async function remove($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
254 const refs = [...(Array.isArray(args['ids']) ? args['ids'] : []), args['id']].map(refIn).filter(ref => ref !== '')
255 if (refs.length === 0) return { deny: 'Give the ids of the steps to remove.' }
256 const found = refs.map(ref => findStep(proc, ref))
257 const missing = refs.filter((_, n) => found[n] === undefined)
258 if (missing.length > 0) return { deny: `No step ${missing.join(', ')}. Use list to see the steps.` }
259 const steps = found.flatMap(one => (one === undefined ? [] : [one.step]))
260 return change(
261 $,
262 proc,
263 one => removeSteps(one, new Set(steps.map(step => step.id))),
264 `Removed ${steps.map(named).join(', ')}. Now:`,
265 true,
266 )
267}
268
269async function move($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
270 const ref = refIn(args['id'])
271 const found = findStep(proc, ref)
272 if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
273 // 자리 순번은 옮기기 전 기준으로 숨은 번호로 바꾼 뒤, 옮길 단계를 뺀 과정에서 자리를 찾는다
274 const near: Record<string, string> = {}
275 for (const side of ['parent', 'before', 'after'] as const) {
276 const sideRef = refIn(args[side])
277 if (sideRef === '') continue
278 const target = findStep(proc, sideRef)
279 if (target === undefined) return { deny: `No step ${sideRef}. Use list to see the steps.` }
280 if (target.path.slice(0, found.depth).join('.') === found.number)
281 return { deny: 'A step cannot move next to or under itself.' }
282 near[side] = target.step.id
283 }
284 const place = placeIn(removeSteps(proc, new Set([found.step.id])), near)
285 if (typeof place === 'string') return { deny: place }
286 if (place.depth + heightOf(found.step) - 1 > MAX_DEPTH) return { deny: tooDeep }
287 return change(
288 $,
289 proc,
290 one => moveStep(one, found.step.id, place.parentId, place.index),
291 `Moved ${named(found.step)}. Now:`,
292 true,
293 )
294}
295
296// 답은 저장된 과정에서 만든다 — 세부 단계가 있는 단계는 세부 단계를 따르므로 요청한 상태가 되지 않을 수 있다
297async function set($: EngineInterface, proc: Process, args: Record<string, unknown>): Promise<Answer> {
298 const changes = Array.isArray(args['changes']) ? args['changes'] : []
299 if (changes.length === 0) return { deny: 'Give changes: a list of { id, status }.' }
300 const resolved: { id: string; status: StepStatus; before: Step }[] = []
301 // 한 호출 안의 순번은 모두 호출 전 기준으로 찾는다
302 for (const raw of changes) {
303 const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
304 const ref = refIn(input['id'])
305 const status = textOf(input['status'])
306 const found = findStep(proc, ref)
307 if (found === undefined) return { deny: `No step ${ref}. Use list to see the steps.` }
308 if (!STEP_STATUSES.includes(status as StepStatus))
309 return { deny: `Unknown status "${status}". Use waiting, active, done or stopped.` }
310 resolved.push({ id: found.step.id, status: status as StepStatus, before: found.step })
311 }
312 const saved = await saveChange($, n =>
313 withProcess(n, proc.id, one => resolved.reduce((acc, next) => setStatus(acc, next.id, next.status), one)),
314 )
315 if (saved === null) return unsaved
316 const now = saved.processes.find(one => one.id === proc.id) ?? proc
317 const leafStatuses = (step: Step) => leavesOf(step).map(leaf => leaf.status).join()
318 const done: string[] = []
319 const missed: string[] = []
320 for (const { id, status, before } of resolved) {
321 const after = findStep(now, id)
322 if (after === undefined) continue
323 const actual = statusOf(after.step)
324 if (actual === status) {
325 done.push(`${after.number} ${actual}`)
326 continue
327 }
328 const below = after.step.steps.map((child, n) => `${after.number}.${n + 1} ${statusOf(child)}`).join(', ')
329 const why = below === '' ? '' : ` (a step with sub-steps follows them: ${below})`
330 missed.push(
331 leafStatuses(before) === leafStatuses(after.step)
332 ? `${after.number} unchanged: still ${actual}, not ${status}${why}.`
333 : `${after.number} is ${actual}, not ${status}${why}.`,
334 )
335 }
336 const waiting = proc.status === 'waiting' ? `"${proc.title}" is waiting; use switch to make it active.` : ''
337 const head = done.length === 0 ? [] : [`Set ${done.join(', ')}.`]
338 return { result: [...head, ...missed, waiting].filter(line => line !== '').join(' ') }
339}
340hooks/todo-tool.ts 200 lines1// 세션(모델)이 쓰는 할 일 도구(todo) — 목록·제안·완료 제안, 사용자가 시킨 추가·완료·수정·삭제·되돌리기, 대화로 받은 제안 (spec AGENT.md 1~3절)
2import type { EngineInterface, On } from 'claude-code'
3
4import { isoAt, newId, openItems } from './note'
5import type { Item, Note, Proposal } from './note'
6import { describe, refOf } from './tell'
7import {
8 acceptIn,
9 completeIn,
10 editIn,
11 forgetItem,
12 liveProposals,
13 readNote,
14 removeIn,
15 reopenIn,
16 saveChange,
17} from './view'
18
19const NAME = 'todo'
20const CALLED = 'mcp__session-note__todo'
21
22// 제안 기준과 완료 대조 규칙은 이 설명문에 고정한다 — 매 답변 노트를 넣지 않는다(성공 기준 ③)
23const DESCRIPTION = `The session note: a short list of things to do or check in this session, shared with the user. The user sees it above the prompt and in a side pane, and can add, edit, complete or delete items there; those changes reach you as messages starting with [session-note]. Items and proposals are named by an 8-character ref such as [a1b2c3d4].
24
25Actions:
26- list: the whole note (open items with details, done items, your proposals still waiting).
27- propose: suggest a new item (title, optional body). It waits on the user's band until they add or dismiss it.
28- propose_done: suggest that an open item is done (id). The user completes it or says not yet.
29- add: add an item (title, optional body) — only when the user tells you to note something.
30- complete: complete an item (id) — only when the user tells you it is done.
31- edit: change an item's title and/or body (id, title, body; an empty body clears it) — only when the user tells you to.
32- delete: delete an item (id) — only when the user tells you to.
33- reopen: reopen a done item (id) — only when the user tells you it is not done.
34- accept: accept a waiting proposal (id) — when the user answers it in the conversation ("keep it").
35
36Never edit, delete or reopen items on your own. To change an item, edit it; do not add a new item in its place.
37
38When to propose: only what the conversation clearly put off for later ("later", "let's skip that for now") and questions left without a conclusion ("is this right?", "we should check that"). Do not propose work you are about to do now, details you may decide yourself, or steps of your own plan. Never ask in your reply whether to add something to the note, and do not point the user to your proposals; they see them on the band. Propose and move on.
39
40Completion check: when you finish a piece of work, and when the user asks you to check the note, compare the open items with what was done. For an item that looks done, use propose_done; never complete it yourself unless the user tells you to. After finishing a piece of work, if open items remain, mention them briefly at the end of your reply. Do not check the note on every turn.`
41
42const SCHEMA = {
43 type: 'object',
44 properties: {
45 action: {
46 type: 'string',
47 enum: ['list', 'propose', 'propose_done', 'add', 'complete', 'edit', 'delete', 'reopen', 'accept'],
48 },
49 title: { type: 'string', description: 'The item title, one short line (propose, add, edit).' },
50 body: { type: 'string', description: 'Optional details (propose, add, edit).' },
51 id: {
52 type: 'string',
53 description: 'The 8-character ref of an item or proposal (propose_done, complete, edit, delete, reopen, accept).',
54 },
55 },
56 required: ['action'],
57} as const
58
59type Answer = { result: string } | { deny: string }
60
61const textOf = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
62
63// [a1b2c3d4]·a1b2c3d4·id 전체 모두 받는다
64const refIn = (value: unknown) => textOf(value).replace(/^\[|\]$/g, '')
65
66const sameRef = (id: string, ref: string) => ref !== '' && (id === ref || refOf(id) === ref)
67
68const named = (item: Item) => `[${refOf(item.id)}] "${item.title}"`
69
70const unsaved: Answer = { deny: "Couldn't save the note (or it is not open yet). Try again later." }
71
72export function registerTool($: EngineInterface) {
73 return $.tool.register({ name: NAME, description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
74}
75
76export function serveTool(on: On) {
77 on('tool.call', { tool: CALLED }, async ($, e) => answer($, e as unknown as Record<string, unknown>)).catch(() => ({
78 deny: 'The todo tool failed. Try again later.',
79 }))
80}
81
82async function answer($: EngineInterface, args: Record<string, unknown>): Promise<Answer> {
83 const action = textOf(args['action'])
84 if (action === 'list') return { result: describe(await readNote($), true) }
85 if (action === 'propose') return propose($, textOf(args['title']), textOf(args['body']))
86 if (action === 'add') return add($, textOf(args['title']), textOf(args['body']))
87 const ref = refIn(args['id'])
88 if (action === 'propose_done') return proposeDone($, ref)
89 if (action === 'complete') return complete($, ref)
90 if (action === 'edit') return edit($, ref, textOf(args['title']), bodyIn(args['body']))
91 if (action === 'delete') return remove($, ref)
92 if (action === 'reopen') return reopen($, ref)
93 if (action === 'accept') return accept($, ref)
94 return {
95 deny: `Unknown action "${action}". Use list, propose, propose_done, add, complete, edit, delete, reopen or accept.`,
96 }
97}
98
99const openItem = (n: Note, ref: string) => openItems(n).find(item => sameRef(item.id, ref))
100
101const anyItem = (n: Note, ref: string) => n.items.find(item => sameRef(item.id, ref))
102
103// edit의 내용 — 주지 않았으면 그대로 두고, 빈 글이면 비운다
104const bodyIn = (value: unknown) => (typeof value === 'string' ? value.trim() : undefined)
105
106async function propose($: EngineInterface, title: string, body: string): Promise<Answer> {
107 if (title === '') return { deny: 'A proposal needs a title.' }
108 const current = await readNote($)
109 const same = (other: string) => other.toLowerCase() === title.toLowerCase()
110 if (openItems(current).some(item => same(item.title))) return { result: `"${title}" is already an open item.` }
111 if (current.proposals.some(p => p.kind === 'new' && same(p.title)))
112 return { result: `"${title}" is already proposed and waiting for the user.` }
113 const proposal: Proposal = { id: newId(), kind: 'new', title, body, proposedAt: isoAt(await $.clock.now()) }
114 const saved = await saveChange($, n => ({ ...n, proposals: [...n.proposals, proposal] }))
115 if (saved === null) return unsaved
116 return {
117 result: `Proposed "${title}" [${refOf(proposal.id)}]. It waits on the user's band; do not mention it in your reply.`,
118 }
119}
120
121async function proposeDone($: EngineInterface, ref: string): Promise<Answer> {
122 const item = openItem(await readNote($), ref)
123 if (item === undefined) return { deny: `No open item [${ref}]. Use list to see the refs.` }
124 if ((await readNote($)).proposals.some(p => p.kind === 'done' && p.itemId === item.id))
125 return { result: `${named(item)} is already proposed as done and waiting for the user.` }
126 const proposal: Proposal = { id: newId(), kind: 'done', itemId: item.id, proposedAt: isoAt(await $.clock.now()) }
127 const saved = await saveChange($, n => ({ ...n, proposals: [...n.proposals, proposal] }))
128 if (saved === null) return unsaved
129 return { result: `Proposed ${named(item)} as done. It waits on the user's band; do not mention it in your reply.` }
130}
131
132async function add($: EngineInterface, title: string, body: string): Promise<Answer> {
133 if (title === '') return { deny: 'An item needs a title.' }
134 const item: Item = {
135 id: newId(),
136 title,
137 body,
138 source: 'instruction',
139 status: 'open',
140 createdAt: isoAt(await $.clock.now()),
141 doneAt: null,
142 }
143 const saved = await saveChange($, n => ({ ...n, items: [...n.items, item] }))
144 if (saved === null) return unsaved
145 return { result: `Added ${named(item)}.` }
146}
147
148async function complete($: EngineInterface, ref: string): Promise<Answer> {
149 const item = openItem(await readNote($), ref)
150 if (item === undefined) return { deny: `No open item [${ref}]. Use list to see the refs.` }
151 const at = isoAt(await $.clock.now())
152 const saved = await saveChange($, n => completeIn(n, item.id, at))
153 if (saved === null) return unsaved
154 return { result: `Completed ${named(item)}.` }
155}
156
157async function edit($: EngineInterface, ref: string, title: string, body: string | undefined): Promise<Answer> {
158 const item = anyItem(await readNote($), ref)
159 if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
160 if (title === '' && body === undefined) return { deny: 'Give a new title or body to edit.' }
161 const saved = await saveChange($, n => editIn(n, item.id, title, body))
162 if (saved === null) return unsaved
163 const edited = saved.items.find(one => one.id === item.id)
164 if (edited === undefined) return { deny: `The item [${ref}] is gone.` }
165 return { result: `Edited ${named(edited)}.` }
166}
167
168async function remove($: EngineInterface, ref: string): Promise<Answer> {
169 const item = anyItem(await readNote($), ref)
170 if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
171 const saved = await saveChange($, n => removeIn(n, item.id))
172 if (saved === null) return unsaved
173 await forgetItem($, item.id)
174 return { result: `Deleted ${named(item)}.` }
175}
176
177async function reopen($: EngineInterface, ref: string): Promise<Answer> {
178 const item = anyItem(await readNote($), ref)
179 if (item === undefined) return { deny: `No item [${ref}]. Use list to see the refs.` }
180 if (item.status === 'open') return { result: `${named(item)} is already open.` }
181 const saved = await saveChange($, n => reopenIn(n, item.id))
182 if (saved === null) return unsaved
183 return { result: `Reopened ${named(item)}.` }
184}
185
186async function accept($: EngineInterface, ref: string): Promise<Answer> {
187 const proposal = liveProposals(await readNote($)).find(p => sameRef(p.id, ref))
188 if (proposal === undefined) return { deny: `No waiting proposal [${ref}]. Use list to see the refs.` }
189 const at = isoAt(await $.clock.now())
190 const itemId = newId()
191 const saved = await saveChange($, n => {
192 const latest = n.proposals.find(p => p.id === proposal.id)
193 return latest === undefined ? n : acceptIn(n, latest, at, itemId)
194 })
195 if (saved === null) return unsaved
196 const item = saved.items.find(one => one.id === (proposal.kind === 'new' ? itemId : proposal.itemId))
197 if (item === undefined) return { deny: `The proposal [${ref}] is gone.` }
198 return { result: proposal.kind === 'new' ? `Added ${named(item)}.` : `Completed ${named(item)}.` }
199}
200hooks/view.ts 287 lines1// 띠·패널이 그리는 값($.state)과 노트를 바꾸는 동작 — 바꿀 때마다 파일에 저장하고, 실패하면 화면을 그대로 둔다
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4
5import type { Draft, Item, Note, Proposal, Tab, View } from '../types'
6import { emptyNote, isoAt, newId } from './note'
7
8export { doneItems, liveProposals, openItems } from './note'
9import { keepCopy, loadNote, notePath, saveNote } from './store'
10import { tell, told } from './tell'
11import type { Text } from './text'
12
13export type { Draft, Tab, View } from '../types'
14
15export const PANE = 'session-note'
16
17// 모듈을 불러올 때마다 새로 정해진다 — 다시 켜지기 전의 초안을 알아보는 표시
18export const LOAD = newId()
19
20const blankView = (): View => ({
21 load: LOAD,
22 openId: null,
23 armedId: null,
24 showDone: false,
25 showAll: false,
26 adding: null,
27 editing: null,
28 editKey: LOAD,
29 tab: 'todo',
30 folds: {},
31 showFinished: false,
32})
33
34const session = atom({ plugin: 'session-note', key: 'session' } as const, null)
35const note = atom({ plugin: 'session-note', key: 'note' } as const, emptyNote())
36const paneOpen = atom({ plugin: 'session-note', key: 'paneOpen' } as const, false)
37const viewAtom = atom({ plugin: 'session-note', key: 'view' } as const, blankView())
38
39// 엔진은 다른 파일로 넘긴 $를 function 선언 안에서만 따라가므로, $를 받는 공용 함수는 모두 function으로 쓴다
40export async function readView($: EngineInterface): Promise<View> {
41 const view = await read($, viewAtom)
42 return view.load === LOAD ? view : blankView()
43}
44
45export async function setView($: EngineInterface, change: (view: View) => View) {
46 await update($, viewAtom, view => change(view.load === LOAD ? view : blankView()))
47}
48
49// 엔진은 상태 값을 그 값을 정의한 파일 안에서만 읽고 쓰게 하므로, 다른 파일은 아래 함수로 다룬다
50export async function readNote($: EngineInterface): Promise<Note> {
51 return read($, note)
52}
53
54export async function isPaneOpen($: EngineInterface): Promise<boolean> {
55 return read($, paneOpen)
56}
57
58// 세션의 노트를 화면 값으로 올리고, 패널이 지금 열려 있는지 맞춘다. 앞 세션의 초안·펼침은 버린다
59async function showSession($: EngineInterface, opened: { id: string; path: string; overwrite: boolean }, shown: Note) {
60 await update($, session, () => opened)
61 await update($, note, () => shown)
62 await update($, viewAtom, () => blankView())
63 const panes = await $.ui.panes()
64 await update($, paneOpen, () => panes.some(pane => pane.id === PANE))
65}
66
67// 세션 시작·전환 때 그 세션의 노트 파일을 연다. 읽을 수 없으면 사본을 남기고 빈 노트로 시작한다(spec DATA.md 3절)
68export async function openSession($: EngineInterface, text: Text, id: string): Promise<Note> {
69 const path = await notePath($, id)
70 const loaded = await loadNote($, path)
71 if (loaded.ok) {
72 await showSession($, { id, path, overwrite: false }, loaded.note)
73 return loaded.note
74 }
75 const copy = loaded.text === undefined ? null : await keepCopy($, path, loaded.text, isoAt(await $.clock.now()))
76 if (copy === null) $.ui.log(`could not keep a copy of ${path}: ${loaded.reason}`, { to: 'debug' })
77 else $.ui.toast(text.unreadable)
78 // 사본을 못 남겼으면 원본을 지킨다 — 저장은 실패로 둔다
79 await showSession($, { id, path, overwrite: copy !== null }, emptyNote())
80 return emptyNote()
81}
82
83// 패널이 닫혔다 — 초안을 버린다
84export async function paneClosed($: EngineInterface) {
85 await update($, paneOpen, () => false)
86 await dropDrafts($)
87}
88
89// 초안을 모두 버린다(패널 닫기 등)
90export async function dropDrafts($: EngineInterface) {
91 await setView($, view => ({ ...view, adding: null, editing: null, armedId: null }))
92}
93
94export async function openPane($: EngineInterface, title: string) {
95 await $.ui.open({ id: PANE, title })
96 await update($, paneOpen, () => true)
97}
98
99// 수정 칸의 key — 끝의 editKey는 수정을 저장할 때마다 바뀐다(types View.editKey)
100export const editFieldKey = (field: keyof Draft, editKey: string) => `edit-${field}-${editKey}`
101
102// [수정]을 누르면 그 항목을 수정 칸으로 바꾸고, 패널이 키보드를 받아 제목 칸 끝에 커서를 둔다.
103// 패널은 누른 즉시 다시 연다 — 기다린 뒤 열면 좁은 터미널에서 엔진이 누름에 대한 열기로 보지 않는다(TASK-010).
104// 채팅 입력창에 치다 만 글이 있으면 엔진이 키보드를 넘기지 않는다 — 커서 옮기기는 덤이라, 거절되거나 실패해도 수정 칸은 그대로 둔다
105export async function startEdit($: EngineInterface, title: string, item: Item) {
106 const opened = $.ui.open({ id: PANE, title, focus: true })
107 await setView($, view => ({
108 ...view,
109 adding: null,
110 armedId: null,
111 editing: { id: item.id, title: item.title, body: item.body },
112 }))
113 await opened
114 const { editKey } = await readView($)
115 await $.ui.focus({ requestId: PANE, key: editFieldKey('title', editKey) }).catch(() => undefined)
116}
117
118// 데스크톱 앱은 누른 버튼이 누른 결과로 사라지면 다음 첫 클릭에 반응하지 않는다(TASK-014). 동작이 끝나면 커서를
119// 그 화면(site: 패널 id 또는 띠의 requestId)에 남는 버튼(key)으로 옮긴다 — 덤이라, 거절되거나 실패해도 그대로 둔다
120export async function focusAfter($: EngineInterface, site: string, key: string, action: Promise<unknown>) {
121 await action
122 await $.ui.focus({ requestId: site, key }).catch(() => undefined)
123}
124
125// isOpen: 띠가 그릴 때 읽은 열림 상태. 열 때는 기다림 없이 $.ui.open부터 불러야 엔진이 누름에 대한 열기로
126// 보고 폭과 상관없이 배치한다 — 기다린 뒤 열면 좁은 터미널(110칸 미만)에서 그리지 않는다 (spec UI.md 2절).
127// tab: 그 탭으로 연다(띠의 보기 → 할 일). 없으면 마지막으로 고른 탭 그대로
128export async function togglePane($: EngineInterface, title: string, isOpen: boolean, tab?: Tab) {
129 if (!isOpen) {
130 const opened = openPane($, title)
131 if (tab !== undefined) await setView($, view => ({ ...view, tab }))
132 return opened
133 }
134 // 자기 $.ui.close에는 자기 ui.close 훅이 돌지 않을 수 있어 여기서도 닫힘을 기록한다
135 await $.ui.close({ id: PANE })
136 await paneClosed($)
137}
138
139// 노트를 바꿔 저장한다. 저장에 성공하면 화면 값을 바꾸고 저장된 노트를, 노트가 열리지 않았거나 실패하면 null
140export async function saveChange($: EngineInterface, edit: (n: Note) => Note): Promise<Note | null> {
141 const opened = await read($, session)
142 if (opened === null) return null
143 try {
144 const saved = await saveNote($, opened.path, edit, opened.overwrite)
145 await update($, note, () => saved)
146 if (opened.overwrite) await update($, session, () => ({ ...opened, overwrite: false }))
147 return saved
148 } catch {
149 return null
150 }
151}
152
153// 사용자가 화면에서 바꾼 것 — 실패하면 토스트를 띄운다
154async function change($: EngineInterface, text: Text, edit: (n: Note) => Note): Promise<Note | null> {
155 const saved = await saveChange($, edit)
156 if (saved === null) $.ui.toast(text.saveFailed)
157 return saved
158}
159
160const withItem = (n: Note, id: string, edit: (item: Item) => Item): Note => ({
161 ...n,
162 items: n.items.map(item => (item.id === id ? edit(item) : item)),
163})
164
165const itemIn = (n: Note, id: string) => n.items.find(item => item.id === id)
166
167// 항목을 완료한다. 그 항목의 완료 제안은 없앤다(spec DATA.md 2절)
168export const completeIn = (n: Note, id: string, at: string): Note => ({
169 ...withItem(n, id, item => (item.status === 'open' ? { ...item, status: 'done', doneAt: at } : item)),
170 proposals: n.proposals.filter(p => !(p.kind === 'done' && p.itemId === id)),
171})
172
173// 완료한 항목을 다시 연다
174export const reopenIn = (n: Note, id: string): Note =>
175 withItem(n, id, item => (item.status === 'done' ? { ...item, status: 'open', doneAt: null } : item))
176
177// 항목과 그 항목을 가리키는 완료 제안을 지운다
178export const removeIn = (n: Note, id: string): Note => ({
179 ...n,
180 items: n.items.filter(item => item.id !== id),
181 proposals: n.proposals.filter(p => !(p.kind === 'done' && p.itemId === id)),
182})
183
184// 제목·내용을 고친다. 빈 제목과 주지 않은 내용은 그대로 둔다
185export const editIn = (n: Note, id: string, title: string, body?: string): Note =>
186 withItem(n, id, item => ({ ...item, title: title === '' ? item.title : title, body: body ?? item.body }))
187
188// 제안을 받는다: 새 항목 제안은 받은 시각의 항목(올린 경로 proposal)이 되고, 완료 제안은 그 항목을 완료한다
189export function acceptIn(n: Note, proposal: Proposal, at: string, itemId: string): Note {
190 const rest = { ...n, proposals: n.proposals.filter(p => p.id !== proposal.id) }
191 if (proposal.kind === 'done') return completeIn(rest, proposal.itemId, at)
192 const item: Item = {
193 id: itemId,
194 title: proposal.title,
195 body: proposal.body,
196 source: 'proposal',
197 status: 'open',
198 createdAt: at,
199 doneAt: null,
200 }
201 return { ...rest, items: [...rest.items, item] }
202}
203
204export async function toggleDone($: EngineInterface, text: Text, id: string) {
205 const at = isoAt(await $.clock.now())
206 const saved = await change($, text, n => (itemIn(n, id)?.status === 'open' ? completeIn(n, id, at) : reopenIn(n, id)))
207 if (saved === null) return
208 await setView($, view => (view.openId === id ? { ...view, openId: null, editing: null, armedId: null } : view))
209 const item = itemIn(saved, id)
210 if (item) await tell($, item.status === 'done' ? told.completed(item) : told.reopened(item))
211}
212
213// 빈 제목은 추가하지 않고 초안을 그대로 둔다
214export async function addItem($: EngineInterface, text: Text, draft: Draft) {
215 const title = draft.title.trim()
216 if (title === '') return
217 const at = isoAt(await $.clock.now())
218 const item: Item = {
219 id: newId(),
220 title,
221 body: draft.body.trim(),
222 source: 'user',
223 status: 'open',
224 createdAt: at,
225 doneAt: null,
226 }
227 const saved = await change($, text, n => ({ ...n, items: [...n.items, item] }))
228 if (saved === null) return
229 await setView($, view => ({ ...view, adding: null }))
230 await tell($, told.added(item))
231}
232
233// 제목을 비우고 저장하면 제목은 그대로 두고 내용만 저장한다
234export async function saveEdit($: EngineInterface, text: Text, id: string, draft: Draft) {
235 const title = draft.title.trim()
236 const saved = await change($, text, n => editIn(n, id, title, draft.body.trim()))
237 if (saved === null) return
238 await setView($, view => ({ ...view, editing: null, editKey: newId() }))
239 const item = itemIn(saved, id)
240 if (item) await tell($, told.edited(item))
241}
242
243// 항목과 그 항목을 가리키는 제안을 완전히 지운다
244export async function removeItem($: EngineInterface, text: Text, id: string) {
245 const before = itemIn(await read($, note), id)
246 const saved = await change($, text, n => removeIn(n, id))
247 if (saved === null) return
248 await setView($, view => ({ ...view, openId: null, armedId: null, editing: null }))
249 if (before) await tell($, told.removed(before))
250}
251
252// 세션이 지운 항목의 펼침·초안을 닫는다
253export async function forgetItem($: EngineInterface, id: string) {
254 await setView($, view => ({
255 ...view,
256 openId: view.openId === id ? null : view.openId,
257 armedId: view.armedId === id ? null : view.armedId,
258 editing: view.editing?.id === id ? null : view.editing,
259 }))
260}
261
262// 띠·패널의 추가·완료 — 제안을 받는다
263export async function acceptProposal($: EngineInterface, text: Text, id: string) {
264 const proposal = (await read($, note)).proposals.find(p => p.id === id)
265 if (proposal === undefined) return
266 const at = isoAt(await $.clock.now())
267 const itemId = newId()
268 const saved = await change($, text, n => {
269 const latest = n.proposals.find(p => p.id === id)
270 return latest === undefined ? n : acceptIn(n, latest, at, itemId)
271 })
272 if (saved === null) return
273 const item = itemIn(saved, proposal.kind === 'new' ? itemId : proposal.itemId)
274 if (item) await tell($, proposal.kind === 'new' ? told.accepted(item) : told.confirmed(item))
275}
276
277// 띠·패널의 무시·아직 — 제안만 없앤다
278export async function dismissProposal($: EngineInterface, text: Text, id: string) {
279 const proposal = (await read($, note)).proposals.find(p => p.id === id)
280 if (proposal === undefined) return
281 const saved = await change($, text, n => ({ ...n, proposals: n.proposals.filter(p => p.id !== id) }))
282 if (saved === null) return
283 if (proposal.kind === 'new') return tell($, told.dismissed(proposal.title))
284 const item = itemIn(saved, proposal.itemId)
285 if (item) await tell($, told.notYet(item))
286}
287hooks/process.ts 184 lines1// 과정의 데이터 규칙 — 깊이 3단, 위 단계 상태 계산, 진행 중 과정 하나, 아래 단계의 진행 중 이어받기, 화면 순번과 지금 단계 (spec AGENT.md 6-3, DATA.md 4절)
2// 코드는 데이터 모양만 지킨다. 진행 중 단계의 개수·위치는 막지 않는다.
3// 이름에 process를 쓰지 않는다(Node 전역 process를 가린다) — 과정 하나는 proc으로 부른다
4
5import type { Note, Process, Step, StepStatus } from '../types'
6
7// 과정 단계의 깊이는 최대 3단이다
8export const MAX_DEPTH = 3
9
10export const STEP_STATUSES: readonly StepStatus[] = ['waiting', 'active', 'done', 'stopped']
11
12const isOver = (status: StepStatus) => status === 'done' || status === 'stopped'
13
14// 아래 단계가 있는 단계의 상태는 아래 단계로 정해진다: 모두 완료 → 완료, 모두 완료·중단이고 중단이 있으면 → 중단,
15// 하나라도 시작했으면 → 진행 중, 아니면 → 대기. 파일에 적힌 위 단계 값에 기대지 않는다
16export function statusOf(step: Step): StepStatus {
17 if (step.steps.length === 0) return step.status
18 const below = step.steps.map(statusOf)
19 if (below.every(status => status === 'done')) return 'done'
20 if (below.every(isOver)) return 'stopped'
21 if (below.some(status => status !== 'waiting')) return 'active'
22 return 'waiting'
23}
24
25// 위 단계에 계산한 상태를 적어 둔다 — 파일을 읽는 사람이 헷갈리지 않게
26export const settle = (steps: Step[]): Step[] =>
27 steps.map(step => {
28 if (step.steps.length === 0) return step
29 const below = settle(step.steps)
30 return { ...step, steps: below, status: statusOf({ ...step, steps: below }) }
31 })
32
33// 단계가 있고 모두 완료·중단인 과정은 끝난 과정이다(지우지 않고 화면에서 숨긴다)
34export const isFinished = (proc: Process) => proc.steps.length > 0 && proc.steps.map(statusOf).every(isOver)
35
36// 끝나지 않은 진행 중 과정 — 많아야 하나
37export const activeProcess = (note: Note) => note.processes.find(proc => proc.status === 'active' && !isFinished(proc))
38
39// 진행 중 과정, 대기 과정(만든 순), 끝난 과정(만든 순) 순서
40export function processesInOrder(note: Note): Process[] {
41 const active = activeProcess(note)
42 const byAge = [...note.processes].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
43 const waiting = byAge.filter(proc => proc !== active && !isFinished(proc))
44 const finished = byAge.filter(isFinished)
45 return [...(active === undefined ? [] : [active]), ...waiting, ...finished]
46}
47
48// 다른 과정을 진행 중으로 바꾸면 하던 과정은 대기가 된다. 단계 상태는 그대로 둔다(돌아올 자리)
49export const activate = (note: Note, id: string): Note => ({
50 ...note,
51 processes: note.processes.map(proc => ({ ...proc, status: proc.id === id ? 'active' : 'waiting' })),
52})
53
54export const withProcess = (note: Note, id: string, change: (proc: Process) => Process): Note => ({
55 ...note,
56 processes: note.processes.map(proc => (proc.id === id ? change(proc) : proc)),
57})
58
59// 화면과 같은 순번(1, 2, 2.1, 2.1.1)을 단 단계 목록 — 자리 순서대로 매기고 저장하지 않는다
60export type Numbered = { step: Step; number: string; depth: number; parentId: string | null; path: number[] }
61
62export function numbered(steps: Step[], parentId: string | null = null, path: number[] = []): Numbered[] {
63 return steps.flatMap((step, index) => {
64 const here = [...path, index + 1]
65 return [
66 { step, number: here.join('.'), depth: here.length, parentId, path: here },
67 ...numbered(step.steps, step.id, here),
68 ]
69 })
70}
71
72const sameRef = (id: string, ref: string) => ref !== '' && (id === ref || id.slice(0, 8) === ref)
73
74// 숨은 번호(8글자·전체 id) 또는 화면 순번("3", "2.1")으로 단계를 찾는다
75export function findStep(proc: Process, ref: string): Numbered | undefined {
76 const all = numbered(proc.steps)
77 return all.find(one => sameRef(one.step.id, ref)) ?? all.find(one => one.number === ref)
78}
79
80export const findProcess = (note: Note, ref: string) => note.processes.find(proc => sameRef(proc.id, ref))
81
82// 단계 아래로 몇 단이 있는지(아래 단계가 없으면 1)
83export const heightOf = (step: Step): number => 1 + Math.max(0, ...step.steps.map(heightOf))
84
85export const leavesOf = (step: Step): Step[] => (step.steps.length === 0 ? [step] : step.steps.flatMap(leavesOf))
86
87// 지금 단계 — 진행 중인 끝 단계가 하나면 그 단계, 여럿이고 같은 1단 안이면 그들을 묶는 가장 가까운 위 단계,
88// 서로 다른 1단에 흩어져 있으면 앞쪽 하나와 나머지 개수(띠의 "외 N"). 진행 중 단계가 없으면 null (spec UI.md 6절)
89export function currentOf(proc: Process): { at: Numbered; others: number } | null {
90 const all = numbered(proc.steps)
91 const active = all.filter(one => one.step.steps.length === 0 && one.step.status === 'active')
92 if (active.length === 0) return null
93 const groups = new Map<number, Numbered[]>()
94 for (const one of active) groups.set(one.path[0] ?? 0, [...(groups.get(one.path[0] ?? 0) ?? []), one])
95 const [first = []] = groups.values()
96 const at = all.find(one => one.number === commonPath(first.map(one => one.path)).join('.'))
97 return at === undefined ? null : { at, others: groups.size - 1 }
98}
99
100// 여러 순번이 함께 시작하는 가장 긴 앞부분 — 하나면 그 순번 그대로
101function commonPath(paths: number[][]): number[] {
102 const [head = [], ...rest] = paths
103 let n = 0
104 while (n < head.length && rest.every(path => path[n] === head[n])) n++
105 return head.slice(0, n)
106}
107
108// 대기 과정이 멈춘 자리 — 지금 단계, 없으면 끝나지 않은 첫 끝 단계
109export function stoppedAt(proc: Process): Numbered | undefined {
110 const current = currentOf(proc)
111 if (current !== null) return current.at
112 return numbered(proc.steps).find(one => one.step.steps.length === 0 && !isOver(one.step.status))
113}
114
115// 단계 목록 안에서 id인 단계를 고친다(어느 깊이든)
116const mapStep = (steps: Step[], id: string, change: (step: Step) => Step): Step[] =>
117 steps.map(step => (step.id === id ? change(step) : { ...step, steps: mapStep(step.steps, id, change) }))
118
119const dropSteps = (steps: Step[], ids: ReadonlySet<string>): Step[] =>
120 steps.filter(step => !ids.has(step.id)).map(step => ({ ...step, steps: dropSteps(step.steps, ids) }))
121
122// 첫 끝 단계를 진행 중으로 — 이어받기에 쓴다
123const startFirst = (step: Step): Step =>
124 step.steps.length === 0
125 ? { ...step, status: 'active' }
126 : { ...step, steps: step.steps.map((one, index) => (index === 0 ? startFirst(one) : one)) }
127
128// parentId(없으면 1단) 아래 index 자리에 단계를 넣는다. 진행 중인 끝 단계에 처음 아래 단계를 넣고 넣는 단계가 모두
129// 대기면 첫 단계가 진행 중을 이어받는다 — 위 단계가 대기로 뒤집혀 지금 단계가 사라지지 않게
130export function insertSteps(proc: Process, parentId: string | null, index: number, added: Step[]): Process {
131 const place = (siblings: Step[]) => [...siblings.slice(0, index), ...added, ...siblings.slice(index)]
132 if (parentId === null) return { ...proc, steps: settle(place(proc.steps)) }
133 const steps = mapStep(proc.steps, parentId, parent => {
134 const inherits =
135 parent.steps.length === 0 && parent.status === 'active' && added.every(step => statusOf(step) === 'waiting')
136 const below = inherits ? added.map((step, n) => (n === 0 ? startFirst(step) : step)) : added
137 return { ...parent, steps: [...parent.steps.slice(0, index), ...below, ...parent.steps.slice(index)] }
138 })
139 return { ...proc, steps: settle(steps) }
140}
141
142// 단계를 빼면 그 아래 단계도 함께 빠진다. 아래 단계가 모두 빠진 위 단계는 마지막 계산 상태를 제 상태로 남긴다
143export const removeSteps = (proc: Process, ids: ReadonlySet<string>): Process => ({
144 ...proc,
145 steps: settle(dropSteps(proc.steps, ids)),
146})
147
148export function editStep(proc: Process, id: string, title: string, memo?: string): Process {
149 return {
150 ...proc,
151 steps: mapStep(proc.steps, id, step => ({ ...step, title: title === '' ? step.title : title, memo: memo ?? step.memo })),
152 }
153}
154
155// 단계 하나를 다른 자리로 옮긴다(아래 단계와 함께). index는 단계를 뺀 뒤의 자리다
156export function moveStep(proc: Process, id: string, parentId: string | null, index: number): Process {
157 const moving = findStep(proc, id)?.step
158 if (moving === undefined) return proc
159 return insertSteps(removeSteps(proc, new Set([id])), parentId, index, [moving])
160}
161
162// 끝 단계는 그 상태로. 아래 단계가 있는 단계는 거절하지 않고 그 아래 끝 단계들에 적용한다 — 완료·중단 = 끝나지 않은
163// 끝 단계 모두, 진행 중 = 끝나지 않은 첫 끝 단계(이미 진행 중인 것이 있으면 그대로), 대기 = 진행 중인 끝 단계들
164export function setStatus(proc: Process, id: string, status: StepStatus): Process {
165 const steps = mapStep(proc.steps, id, step => {
166 if (step.steps.length === 0) return { ...step, status }
167 const leaves = leavesOf(step)
168 const open = leaves.filter(leaf => !isOver(leaf.status))
169 const targets =
170 status === 'active'
171 ? open.some(leaf => leaf.status === 'active')
172 ? []
173 : open.slice(0, 1)
174 : status === 'waiting'
175 ? leaves.filter(leaf => leaf.status === 'active')
176 : open
177 const ids = new Set(targets.map(leaf => leaf.id))
178 const apply = (one: Step): Step =>
179 ids.has(one.id) ? { ...one, status } : { ...one, steps: one.steps.map(apply) }
180 return apply(step)
181 })
182 return { ...proc, steps: settle(steps) }
183}
184hooks/note.ts 63 lines1// 노트·항목·제안·과정의 형식과 파일 글자 ↔ 노트 변환 (spec DATA.md 2·4절)
2
3import type { Item, Note, Process, Proposal } from '../types'
4
5export type { Item, ItemSource, Note, Process, Proposal, Step, StepStatus } from '../types'
6
7export const emptyNote = (): Note => ({ version: 1, items: [], proposals: [], processes: [] })
8
9export const isEmpty = (note: Note) =>
10 note.items.length === 0 && note.proposals.length === 0 && note.processes.length === 0
11
12export const newId = () => crypto.randomUUID()
13
14// 열린 항목은 먼저 올라온 것이 위, 완료 항목은 최근에 끝난 것이 위
15export const openItems = (n: Note) =>
16 n.items.filter(item => item.status === 'open').sort((a, b) => a.createdAt.localeCompare(b.createdAt))
17
18export const doneItems = (n: Note) =>
19 n.items.filter(item => item.status === 'done').sort((a, b) => (b.doneAt ?? '').localeCompare(a.doneAt ?? ''))
20
21// 완료 제안의 대상이 지워졌거나 이미 완료면 그 제안은 없는 것으로 본다(spec DATA.md 2절)
22export const liveProposals = (n: Note) =>
23 n.proposals.filter(p => p.kind === 'new' || n.items.some(item => item.id === p.itemId && item.status === 'open'))
24
25// 시간대를 붙인 ISO 형식: 2026-10-15T14:32:00+09:00
26export function isoAt(ms: number): string {
27 const d = new Date(ms)
28 const offset = -d.getTimezoneOffset()
29 const local = new Date(ms + offset * 60_000).toISOString().slice(0, 19)
30 const sign = offset >= 0 ? '+' : '-'
31 const hh = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0')
32 const mm = String(Math.abs(offset) % 60).padStart(2, '0')
33 return `${local}${sign}${hh}:${mm}`
34}
35
36export type Parsed = { ok: true; note: Note } | { ok: false; reason: string }
37
38export function parseNote(text: string): Parsed {
39 let raw: unknown
40 try {
41 raw = JSON.parse(text)
42 } catch {
43 return { ok: false, reason: 'not JSON' }
44 }
45 if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }
46 const { version, items, proposals, processes } = raw as Record<string, unknown>
47 if (version !== 1) return { ok: false, reason: `unknown version ${String(version)}` }
48 if (!Array.isArray(items) || !Array.isArray(proposals)) return { ok: false, reason: 'missing lists' }
49 // processes가 없는 1.0.x 파일은 과정이 빈 노트다
50 if (processes !== undefined && !Array.isArray(processes)) return { ok: false, reason: 'processes is not a list' }
51 return {
52 ok: true,
53 note: {
54 version: 1,
55 items: items as Item[],
56 proposals: proposals as Proposal[],
57 processes: (processes ?? []) as Process[],
58 },
59 }
60}
61
62export const formatNote = (note: Note) => `${JSON.stringify(note, null, 2)}\n`
63hooks/process-pane.tsx 139 lines1// 패널의 과정 탭 — 진행 중 과정(펼침), 대기 과정(한 줄 + 멈춘 자리), 끝난 과정 묶음, 단계 접기. 보기·펼치기만 한다 (spec UI.md 2절)
2import type { ClientElements, EngineInterface, RenderChildren } from 'claude-code'
3
4import type { Note, Process, Step } from './note'
5import { activeProcess, currentOf, isFinished, numbered, processesInOrder, statusOf, stoppedAt } from './process'
6import { SQUARE } from './text'
7import type { Text } from './text'
8import { setView, type View } from './view'
9
10type Parts = Pick<ClientElements, 'Box' | 'Button' | 'Text'>
11
12// 과정 탭 본문의 구획들 — 패널이 구획 사이에 한 줄 여백을 둔다. rule: 패널 끝까지 닿는 구분선
13export function drawProcessTab(
14 $: EngineInterface,
15 parts: Parts,
16 text: Text,
17 note: Note,
18 view: View,
19 rule: RenderChildren,
20): RenderChildren[] {
21 const { Box, Button, Text: T } = parts
22 const active = activeProcess(note)
23 const others = processesInOrder(note).filter(proc => proc !== active)
24 const waiting = others.filter(proc => !isFinished(proc))
25 const finished = others.filter(isFinished)
26 // 지금 단계(굵은 제목)는 진행 중 과정에만 있다
27 const currentId = active === undefined ? null : (currentOf(active)?.at.step.id ?? null)
28 // 펼침 — 사용자가 누른 값, 없으면 기본(진행 중인 단계만 펼침, 과정은 접음)
29 const isUnfolded = (id: string, byDefault: boolean) => view.folds[id] ?? byDefault
30 const flip = (id: string, shown: boolean) =>
31 void setView($, v => ({ ...v, folds: { ...v.folds, [id]: !shown } }))
32 const arrow = (shown: boolean) => (shown ? '▾' : '▸')
33 const square = (status: Step['status']) => <T color={SQUARE[status].color}>{SQUARE[status].label}</T>
34
35 // 단계 줄 `<네모> <순번> <제목>` — 깊이만큼 들여 쓰고, 아래 단계가 있으면 제목을 눌러 접고 편다
36 const stepRows = (proc: Process): RenderChildren[] => {
37 const rows: RenderChildren[] = []
38 const numberOf = new Map(numbered(proc.steps).map(one => [one.step.id, one.number]))
39 const walk = (steps: Step[], depth: number) => {
40 for (const step of steps) {
41 const number = numberOf.get(step.id) ?? ''
42 const status = statusOf(step)
43 const hasBelow = step.steps.length > 0
44 const shown = hasBelow && isUnfolded(step.id, status === 'active')
45 const title = (
46 <T bold={step.id === currentId ? true : undefined} wrap="wrap">
47 {step.title}
48 {hasBelow ? <T dimColor> {arrow(shown)}</T> : ''}
49 </T>
50 )
51 rows.push(
52 <Box key={`step-${step.id}`} flexDirection="row" gap={1} paddingLeft={depth * 2} alignItems="flex-start">
53 <Box flexShrink={0}>
54 <T>
55 {square(status)} <T dimColor>{number}</T>
56 </T>
57 </Box>
58 <Box flexDirection="row" flexShrink={1} minWidth={0}>
59 {hasBelow ? (
60 <Button key={`fold-${step.id}`} plain onPress={() => flip(step.id, shown)}>
61 {title}
62 </Button>
63 ) : (
64 title
65 )}
66 </Box>
67 </Box>,
68 )
69 if (shown) walk(step.steps, depth + 1)
70 }
71 }
72 walk(proc.steps, 1)
73 return rows
74 }
75
76 // 대기·끝난 과정의 머리줄 — 누르면 단계를 펼쳐 보기만 하고 진행 중 과정은 바뀌지 않는다
77 const folded = (proc: Process, status: Step['status'], aside: string) => {
78 const shown = isUnfolded(proc.id, false)
79 return [
80 <Box key={`proc-${proc.id}`} flexDirection="row" gap={1} alignItems="flex-start">
81 <Box flexShrink={0}>{square(status)}</Box>
82 <Box flexDirection="row" flexShrink={1} minWidth={0}>
83 <Button key={`fold-${proc.id}`} plain onPress={() => flip(proc.id, shown)}>
84 <T wrap="wrap">
85 <T bold>{proc.title}</T>
86 <T dimColor> {arrow(shown)}</T>
87 {aside === '' ? '' : <T dimColor>{` ${aside}`}</T>}
88 </T>
89 </Button>
90 </Box>
91 </Box>,
92 ...(shown ? stepRows(proc) : []),
93 ]
94 }
95
96 const sections: RenderChildren[] = []
97 if (active !== undefined)
98 sections.push(
99 <Box key="active" flexDirection="column">
100 <Box key={`proc-${active.id}`} flexDirection="row" gap={1} alignItems="flex-start">
101 <Box flexShrink={0}>{square('active')}</Box>
102 <T bold wrap="wrap">
103 {active.title}
104 </T>
105 </Box>
106 {stepRows(active)}
107 </Box>,
108 )
109 if (waiting.length > 0)
110 sections.push(
111 <Box key="waiting" flexDirection="column">
112 {waiting.flatMap(proc => {
113 const at = stoppedAt(proc)
114 return folded(proc, 'waiting', at === undefined ? '' : text.pausedAt(at.number))
115 })}
116 </Box>,
117 )
118 if (sections.length === 0) sections.push(<T dimColor>{text.noProcesses}</T>)
119 if (finished.length > 0)
120 sections.push(
121 <Box key="finished" flexDirection="column">
122 {rule}
123 <Button
124 key="finished-group"
125 plain
126 dimColor
127 label={`${text.finished(finished.length)} ${arrow(view.showFinished)}`}
128 onPress={() => void setView($, v => ({ ...v, showFinished: !v.showFinished }))}
129 />
130 {view.showFinished
131 ? finished.flatMap(proc =>
132 folded(proc, proc.steps.map(statusOf).every(status => status === 'done') ? 'done' : 'stopped', ''),
133 )
134 : []}
135 </Box>,
136 )
137 return sections
138}
139hooks/store.ts 53 lines1// 세션마다 노트 파일 하나를 읽고 쓴다 — 쓸 때마다 다시 읽고 변경만 얹는다 (spec DATA.md 3절)
2import type { EngineInterface } from 'claude-code'
3
4import { dataDir } from './data-dir'
5import { emptyNote, formatNote, isEmpty, parseNote, type Note } from './note'
6
7// 읽을 수 없으면 읽은 글(있으면)을 함께 돌려준다 — 사본을 남기는 데 쓴다
8export type Loaded = { ok: true; note: Note } | { ok: false; path: string; reason: string; text?: string }
9
10export async function notePath($: EngineInterface, sessionId: string): Promise<string> {
11 return `${await dataDir($)}/sessions/${sessionId}.json`
12}
13
14// 파일이 없으면 빈 노트다. 읽을 수 없으면 실패를 알린다
15export async function loadNote($: EngineInterface, path: string): Promise<Loaded> {
16 if (!(await $.fs.exists(path))) return { ok: true, note: emptyNote() }
17 let text: string
18 try {
19 text = await $.fs.read(path)
20 } catch (error) {
21 return { ok: false, path, reason: String(error) }
22 }
23 const parsed = parseNote(text)
24 return parsed.ok ? parsed : { ok: false, path, reason: parsed.reason, text }
25}
26
27// 읽을 수 없는 파일의 사본을 같은 폴더에 <세션ID>.unreadable-<일시>.json으로 남긴다. 남긴 경로, 실패하면 null
28export async function keepCopy($: EngineInterface, path: string, text: string, at: string): Promise<string | null> {
29 const copy = `${path.replace(/\.json$/, '')}.unreadable-${at.slice(0, 19).replace(/[-:]/g, '')}.json`
30 try {
31 await $.fs.write(copy, text)
32 return copy
33 } catch {
34 return null
35 }
36}
37
38// 최신 파일 위에 change를 얹어 저장하고 결과 노트를 돌려준다. overwrite면 읽지 않고 빈 노트 위에 얹는다(사본을 남긴 원본 자리).
39// 파일이 없고 결과도 비어 있으면 파일을 만들지 않는다. 읽기·쓰기 실패는 호출한 쪽으로 던진다
40export async function saveNote(
41 $: EngineInterface,
42 path: string,
43 change: (note: Note) => Note,
44 overwrite = false,
45): Promise<Note> {
46 const loaded = overwrite ? { ok: true as const, note: emptyNote() } : await loadNote($, path)
47 if (!loaded.ok) throw new Error(`cannot read ${path}: ${loaded.reason}`)
48 const next = change(loaded.note)
49 if (isEmpty(next) && !(await $.fs.exists(path))) return next
50 await $.fs.write(path, formatNote(next))
51 return next
52}
53