Adds a todo card to the session pane that keeps every todo the model creates on one stack shared by every session, newest on top, so none is lost to…

세션 패널(session-rail)에 할 일 카드를 넣고, 모델이 만드는 할 일을 하나의 스택으로 계속 쌓아 두는 모드입니다. 컨텍스트 압축이나 새 요청 때문에 할 일이 사라지거나 잊히지 않게 하려고 만들었습니다.
모델 목록에서 빠짐으로 표시되어 남습니다. 모델이 만든 할 일은 그 할 일을 만든 세션의 목록만 따라가므로, 다른 세션의 목록이 빼먹었다고 빠짐으로 표시하지 않습니다./todo-stack <할 일>로 직접 할 일을 추가할 수 있습니다. 스택 맨 위에 올라갑니다.plugins/store/)에 저장되어 새 세션을 열어도, 플러그인을 새로고침하거나 업데이트해도 그대로 남습니다. 같은 컴퓨터에서 여러 세션을 함께 열어도 같은 스택을 쓰고, 다른 세션에서 바뀐 내용은 10초 안에 보입니다.할 일 ∨)을 누르면 접히고, 접힌 동안에는 남은 개수만 보입니다./todo-stack: 세션 패널을 엽니다. /todo-stack <할 일>은 할 일을 추가하고 패널을 엽니다.세션 패널에서 사용량 카드 아래에 놓입니다. 패널을 열고 닫는 방법은 session-rail의 README에 있습니다./plugin install todo-stack --marketplace ImTotem/claude-code-mods
session-rail이 함께 설치됩니다. 마켓플레이스는 이 저장소의 main 브랜치에 있습니다.
이 브랜치의 루트가 모드 폴더입니다.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
tsconfig.json은 엔진이 모드를 불러올 때 .claude-plugin/types/에 깔아 주는 타입 설정을 이어받습니다. 그 폴더는 커밋하지 않습니다.
hooks/register.tsx 662 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MutedText, StackedTodo, TodoStack, TodoStatus } from '../types'
5
6// The pane session-rail opens; this mod adds its card to it, and opens it
7// too: a pane id is one pane whichever plugin opens it.
8const RAIL = 'session-rail'
9const RAIL_TITLE = '세션'
10const MAX_TODOS = 300
11const MAX_TEXT = 500
12// One stack for every session, kept in the plugin's store.
13const STORE_KEY = 'stack'
14// Versions before 0.5.0 kept a stack per session under `stack:<id>`, listed
15// oldest first under `sessions`. Claude Code starts a session on the version
16// it has on disk and fetches an update after, so such a version can still run
17// for a while once this one has saved the shared stack.
18const OLD_PREFIX = 'stack:'
19const OLD_INDEX = 'sessions'
20// How often an open session reads the store again for other sessions' changes.
21const WATCH_MS = 10_000
22
23const STATUS_LABEL: Record<TodoStatus, string> = {
24 pending: '대기',
25 in_progress: '진행 중',
26 completed: '완료',
27}
28
29const EMPTY: TodoStack = { todos: [], muted: [] }
30const stackRef = { plugin: 'todo-stack', key: 'stack' } as const
31const stack = atom(stackRef, EMPTY)
32const hideDone = atom({ plugin: 'todo-stack', key: 'hideDone' } as const, false)
33const isConfirmingClear = atom({ plugin: 'todo-stack', key: 'isConfirmingClear' } as const, false)
34const isCollapsed = atom({ plugin: 'todo-stack', key: 'isCollapsed' } as const, false)
35
36type ModelTodo = { content: string; status: TodoStatus }
37type TaskChange = { taskId: string; subject?: string; status?: TodoStatus | 'deleted' }
38
39function newId(): string {
40 return Math.random().toString(36).slice(2, 10)
41}
42
43function oneLine(text: string, max: number): string {
44 const flat = text.replace(/\s+/g, ' ').trim()
45
46 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
47}
48
49const isOpen = (todo: StackedTodo) => todo.status !== 'completed'
50const isDropped = (todo: StackedTodo) => todo.source === 'todo' && todo.isListed === false && isOpen(todo)
51// The model's todos and task ids are its session's own: another session's
52// list never drops them, and its task ids never reach them.
53const isModelTodoOf = (todo: StackedTodo, sessionId: string) => todo.sessionId === sessionId
54// An unfinished todo of another session's, or the person's, that this
55// session's model lists under the same text: the stack is one, so the model
56// takes it over (and can finish it) instead of stacking it again.
57const isAdoptable = (todo: StackedTodo, text: string, sessionId: string) =>
58 isOpen(todo) && todo.text === text && (todo.source === 'manual' || !isModelTodoOf(todo, sessionId))
59
60function mapTodos(value: TodoStack, fn: (todo: StackedTodo) => StackedTodo | null): TodoStack {
61 return {
62 ...value,
63 todos: value.todos.flatMap(todo => {
64 const kept = fn(todo)
65
66 return kept === null ? [] : [kept]
67 }),
68 }
69}
70
71// New todos go on top of the stack; several made at once keep their order.
72function push(value: TodoStack, added: readonly StackedTodo[]): TodoStack {
73 return tidy({ ...value, todos: [...added, ...value.todos] })
74}
75
76// Past MAX_TODOS the lowest finished todo goes first, so an unfinished todo
77// is the last thing the stack forgets.
78function tidy(value: TodoStack): TodoStack {
79 let todos = value.todos
80 while (todos.length > MAX_TODOS) {
81 const finished = todos.map(isOpen).lastIndexOf(false)
82 const drop = finished === -1 ? todos.length - 1 : finished
83 todos = todos.filter((_, index) => index !== drop)
84 }
85
86 return todos === value.todos ? value : { ...value, todos }
87}
88
89// TodoWrite hands over the session's whole list each time: an item it still
90// holds is updated in place, a new one is pushed on top, and an unfinished
91// one it no longer holds stays, marked as dropped.
92function applyTodoWrite(value: TodoStack, list: readonly ModelTodo[], sessionId: string): TodoStack {
93 const items = list.map(item => ({ text: oneLine(item.content, MAX_TEXT), status: item.status }))
94 const texts = items.map(item => item.text)
95 const muted = value.muted.filter(entry => entry.sessionId !== sessionId || texts.includes(entry.text))
96 const isMuted = (text: string) => muted.some(entry => entry.sessionId === sessionId && entry.text === text)
97 let next: TodoStack = { ...value, muted }
98 const matched = new Set<string>()
99 const added: StackedTodo[] = []
100
101 for (const item of items) {
102 if (isMuted(item.text)) continue
103
104 const pool = next.todos.filter(
105 todo =>
106 todo.source === 'todo' && isModelTodoOf(todo, sessionId) && todo.text === item.text && !matched.has(todo.id),
107 )
108 const found = pool.find(todo => todo.isListed === true) ?? pool.find(isOpen)
109
110 if (found !== undefined) {
111 matched.add(found.id)
112 next = mapTodos(next, todo =>
113 todo.id !== found.id
114 ? todo
115 : {
116 ...todo,
117 isListed: true,
118 modelStatus: item.status,
119 status: item.status !== todo.modelStatus ? item.status : todo.status,
120 },
121 )
122 continue
123 }
124
125 const adopted = next.todos.find(todo => !matched.has(todo.id) && isAdoptable(todo, item.text, sessionId))
126 if (adopted !== undefined) {
127 matched.add(adopted.id)
128 next = mapTodos(next, todo => {
129 if (todo.id !== adopted.id) return todo
130 const { taskId: _, ...rest } = todo
131
132 return { ...rest, source: 'todo', sessionId, isListed: true, modelStatus: item.status, status: item.status }
133 })
134 continue
135 }
136
137 const id = newId()
138 matched.add(id)
139 added.push({
140 id,
141 text: item.text,
142 status: item.status,
143 source: 'todo',
144 sessionId,
145 modelStatus: item.status,
146 isListed: true,
147 })
148 }
149
150 const marked = mapTodos(next, todo =>
151 todo.source === 'todo' && isModelTodoOf(todo, sessionId) && !matched.has(todo.id) && todo.isListed !== false
152 ? { ...todo, isListed: false }
153 : todo,
154 )
155
156 return push(marked, added)
157}
158
159// Task ids start over with the task list (a /clear), so an older todo holding
160// the same id lets go of it.
161function applyTaskCreate(value: TodoStack, taskId: string, subject: string, sessionId: string): TodoStack {
162 const released = mapTodos(value, todo => {
163 if (todo.taskId !== taskId || !isModelTodoOf(todo, sessionId)) return todo
164 const { taskId: _, ...rest } = todo
165
166 return rest
167 })
168 const text = oneLine(subject, MAX_TEXT)
169 const adopted = released.todos.find(todo => isAdoptable(todo, text, sessionId))
170 if (adopted !== undefined) {
171 return mapTodos(released, todo => {
172 if (todo.id !== adopted.id) return todo
173 const { isListed: _, ...rest } = todo
174
175 return { ...rest, source: 'task', sessionId, taskId, modelStatus: 'pending' }
176 })
177 }
178
179 return push(released, [
180 {
181 id: newId(),
182 text,
183 status: 'pending',
184 source: 'task',
185 sessionId,
186 modelStatus: 'pending',
187 taskId,
188 },
189 ])
190}
191
192function applyTaskUpdate(value: TodoStack, change: TaskChange, sessionId: string): TodoStack {
193 return mapTodos(value, todo => {
194 if (todo.taskId !== change.taskId || !isModelTodoOf(todo, sessionId)) return todo
195 if (change.status === 'deleted') return null
196
197 let next = todo
198 if (change.subject !== undefined) next = { ...next, text: oneLine(change.subject, MAX_TEXT) }
199 if (change.status !== undefined && change.status !== todo.modelStatus) {
200 next = { ...next, status: change.status, modelStatus: change.status }
201 }
202
203 return next
204 })
205}
206
207// What the person removes while the model's list still holds it is muted, so
208// that session's next TodoWrite does not stack it again.
209function mutedBy(value: TodoStack, removed: readonly StackedTodo[]): MutedText[] {
210 const added = removed.flatMap(todo =>
211 todo.source === 'todo' && todo.isListed === true && todo.sessionId !== undefined
212 ? [{ sessionId: todo.sessionId, text: todo.text }]
213 : [],
214 )
215 const fresh = added.filter(
216 (entry, index) =>
217 !value.muted.some(old => old.sessionId === entry.sessionId && old.text === entry.text) &&
218 added.findIndex(other => other.sessionId === entry.sessionId && other.text === entry.text) === index,
219 )
220
221 return [...value.muted, ...fresh]
222}
223
224function toggle(value: TodoStack, id: string): TodoStack {
225 return mapTodos(value, todo =>
226 todo.id === id ? { ...todo, status: isOpen(todo) ? 'completed' : 'pending' } : todo,
227 )
228}
229
230function remove(value: TodoStack, id: string): TodoStack {
231 const removed = value.todos.filter(todo => todo.id === id)
232
233 return { ...mapTodos(value, todo => (todo.id === id ? null : todo)), muted: mutedBy(value, removed) }
234}
235
236function clearDone(value: TodoStack): TodoStack {
237 const removed = value.todos.filter(todo => !isOpen(todo))
238
239 return { ...mapTodos(value, todo => (isOpen(todo) ? todo : null)), muted: mutedBy(value, removed) }
240}
241
242function clearAll(value: TodoStack): TodoStack {
243 return { todos: [], muted: mutedBy(value, value.todos) }
244}
245
246function addManual(value: TodoStack, text: string, sessionId: string): TodoStack {
247 return push(value, [{ id: newId(), text, status: 'pending', source: 'manual', sessionId }])
248}
249
250// A stack as stored or held: this version's; 0.4's, a flat stack of one
251// session's; or an older one's that kept a group per request (newest group
252// first, a group's todos in their order). Todos and muted texts without a
253// session take `sessionId`, the session an older version kept them for.
254function toStack(saved: unknown, sessionId?: string): TodoStack | undefined {
255 if (typeof saved !== 'object' || saved === null) return undefined
256 const { todos, groups, muted } = saved as Record<string, unknown>
257 if (!Array.isArray(muted)) return undefined
258
259 const listed = Array.isArray(todos)
260 ? (todos as StackedTodo[])
261 : Array.isArray(groups)
262 ? (groups as { todos?: unknown }[]).flatMap(group =>
263 Array.isArray(group.todos) ? (group.todos as StackedTodo[]) : [],
264 )
265 : undefined
266 if (listed === undefined) return undefined
267
268 return {
269 todos: listed.map(todo =>
270 todo.sessionId !== undefined || sessionId === undefined ? todo : { ...todo, sessionId },
271 ),
272 muted: (muted as unknown[]).flatMap((entry): MutedText[] => {
273 if (typeof entry === 'string') return sessionId === undefined ? [] : [{ sessionId, text: entry }]
274 if (typeof entry !== 'object' || entry === null) return []
275 const { sessionId: owner, text } = entry as Record<string, unknown>
276
277 return typeof owner === 'string' && typeof text === 'string' ? [{ sessionId: owner, text }] : []
278 }),
279 }
280}
281
282function taskIdOf(result: unknown): string | undefined {
283 const task = (result as { task?: { id?: unknown } } | null | undefined)?.task
284 const id = task?.id
285
286 return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined
287}
288
289function openList(todos: readonly StackedTodo[], label: (status: TodoStatus) => string): string {
290 return todos
291 .filter(isOpen)
292 .map(todo => `- [${label(todo.status)}] ${todo.text}`)
293 .join('\n')
294}
295
296// The per-session stacks older versions kept, merged onto `base`: the session
297// used last on top, and above what `base` holds, since an older version still
298// running wrote them after it. A todo both hold is kept as the older version
299// left it. The old keys go once the merged stack is saved.
300async function migrate($: EngineInterface, keys: readonly string[], base: TodoStack): Promise<TodoStack> {
301 const ids = keys.map(key => key.slice(OLD_PREFIX.length))
302 const index = await $.store.get(OLD_INDEX)
303 const order = Array.isArray(index) ? index.filter((id): id is string => typeof id === 'string') : []
304 const oldestFirst = [...ids.filter(id => !order.includes(id)), ...order.filter(id => ids.includes(id))]
305
306 let merged = base
307 for (const id of oldestFirst) {
308 const old = toStack(await $.store.get(`${OLD_PREFIX}${id}`), id)
309 if (old === undefined) continue
310 const fresh = new Set(old.todos.map(todo => todo.id))
311 merged = {
312 todos: [...old.todos, ...merged.todos.filter(todo => !fresh.has(todo.id))],
313 muted: [
314 ...merged.muted,
315 ...old.muted.filter(entry => !merged.muted.some(m => m.sessionId === entry.sessionId && m.text === entry.text)),
316 ],
317 }
318 }
319 merged = tidy(merged)
320
321 await $.store.set(STORE_KEY, merged)
322 for (const key of keys) await $.store.delete(key)
323 if (index !== undefined) await $.store.delete(OLD_INDEX)
324
325 return merged
326}
327
328// The store holds the stack; what a session holds in state is a copy of it.
329async function load($: EngineInterface): Promise<TodoStack> {
330 const saved = toStack(await $.store.get(STORE_KEY))
331 const leftovers = (await $.store.keys()).filter(key => key.startsWith(OLD_PREFIX))
332 if (saved !== undefined && leftovers.length === 0) return saved
333
334 return migrate($, leftovers, saved ?? EMPTY)
335}
336
337async function hold($: EngineInterface, value: TodoStack) {
338 const held = await $.state.get(stackRef)
339 if (held.version === 0 || JSON.stringify(held.value) !== JSON.stringify(value)) {
340 await $.state.set(stackRef, value)
341 }
342}
343
344// Changes run one at a time, each on what the store holds then, so a change
345// made in another session meanwhile is kept, not written over.
346let queue: Promise<unknown> = Promise.resolve()
347
348function serially<T>(run: () => Promise<T>): Promise<T> {
349 const done = queue.then(run)
350 queue = done.catch(() => undefined)
351
352 return done
353}
354
355function refresh($: EngineInterface): Promise<void> {
356 return serially(async () => hold($, await load($)))
357}
358
359function change($: EngineInterface, fn: (value: TodoStack, sessionId: string) => TodoStack): Promise<void> {
360 return serially(async () => {
361 const sessionId = await $.session.id()
362 const value = fn(await load($), sessionId)
363 await $.store.set(STORE_KEY, value)
364 await hold($, value)
365 })
366}
367
368// Another session's changes reach the store, not this session's state: an
369// open session reads the store again now and then. Started once per load of
370// this module, so a reload starts it again.
371let isWatching = false
372
373function watch($: EngineInterface) {
374 if (isWatching) return
375 isWatching = true
376 $.clock.every(WATCH_MS, () => {
377 void refresh($).catch(() => undefined)
378 })
379}
380
381async function fillPrompt($: EngineInterface) {
382 const value = await serially(() => load($))
383 const list = openList(value.todos, status => STATUS_LABEL[status])
384 if (list === '') return
385
386 await $.prompt.fill({
387 text: `아직 끝나지 않은 할 일이 있어. 이어서 진행해줘:\n${list}\n`,
388 mode: 'append',
389 })
390}
391
392export const register: Register = on => {
393 on('session.start', async ($, e, next) => {
394 await $.command.register({
395 name: 'todo-stack',
396 description: '세션 패널을 열어 할 일 스택을 보여줍니다. 텍스트를 주면 직접 할 일로 추가합니다',
397 argumentHint: '[추가할 할 일]',
398 })
399
400 await refresh($).catch(() => undefined)
401 watch($)
402
403 return next(e)
404 })
405
406 on('command.run', { command: 'todo-stack' }, async ($, e) => {
407 const text = oneLine(e.args, MAX_TEXT)
408 if (text !== '') await change($, (value, sessionId) => addManual(value, text, sessionId))
409
410 const opened = await $.ui.open({ id: RAIL, title: RAIL_TITLE })
411 const said = text === '' ? '할 일 스택을 열었습니다.' : `할 일을 추가했습니다: ${text}`
412
413 return { text: opened.isPlaced ? said : `${said} (패널을 열지 못했습니다: ${opened.reason})` }
414 })
415
416 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
417 const ran = await next(e)
418 if (e.tool !== 'TodoWrite' || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) {
419 return ran
420 }
421
422 const list = e.todos
423 await change($, (value, sessionId) => applyTodoWrite(value, list, sessionId)).catch(() => undefined)
424
425 return ran
426 }).catch(($, e, next) => next(e))
427
428 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
429 const ran = await next(e)
430 if (e.tool !== 'TaskCreate' || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) {
431 return ran
432 }
433
434 const taskId = taskIdOf(ran.result)
435 const subject = e.subject
436 if (taskId !== undefined) {
437 await change($, (value, sessionId) => applyTaskCreate(value, taskId, subject, sessionId)).catch(
438 () => undefined,
439 )
440 }
441
442 return ran
443 }).catch(($, e, next) => next(e))
444
445 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
446 const ran = await next(e)
447 if (e.tool !== 'TaskUpdate' || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) {
448 return ran
449 }
450
451 const taskChange: TaskChange = { taskId: e.taskId }
452 if (e.subject !== undefined) taskChange.subject = e.subject
453 if (e.status !== undefined) taskChange.status = e.status
454 await change($, (value, sessionId) => applyTaskUpdate(value, taskChange, sessionId)).catch(() => undefined)
455
456 return ran
457 }).catch(($, e, next) => next(e))
458
459 // The pane opening shows the stack as the store holds it now.
460 on('ui.open', { id: RAIL }, async ($, e, next) => {
461 const opened = await next(e)
462 if (opened.value?.isPlaced === true) void refresh($).catch(() => undefined)
463
464 return opened
465 }).catch(($, e, next) => next(e))
466
467 // Compaction is where the model loses track: the summarizer is told to carry
468 // this session's unfinished todos over.
469 on('session.compact', async ($, e, next) => {
470 if (e.agentId !== undefined) return next(e)
471
472 const sessionId = await $.session.id()
473 const value = await serially(() => load($)).catch(() => EMPTY)
474 const list = openList(
475 value.todos.filter(todo => todo.sessionId === sessionId),
476 status => status,
477 )
478 if (list === '') return next(e)
479
480 const note =
481 'The user keeps a stack of todo items for this session (todo-stack plugin). ' +
482 'Carry every open item below into the summary, verbatim with its status, ' +
483 'under a heading "Open todos", so none is forgotten after compaction:\n' +
484 list
485 const instructions = e.instructions === undefined ? note : `${e.instructions}\n\n${note}`
486
487 return next({ ...e, instructions })
488 }).catch(($, e, next) => next(e))
489
490 // The card goes below whatever the other mods add, so the usage card leads.
491 on('ui.render', { component: 'Pane', requestId: RAIL }, async ($, e, next) => {
492 const above = await next(e)
493 const table = $.ui.resolve(e)
494 const { Box, Text, Button } = table
495 const Input = 'Input' in table ? table.Input : undefined
496
497 // A reload of the plugin, or an update, starts with nothing held: this
498 // draw reads the stack from the store, and the state takes it up after
499 // the draw (a draw sees the state as it was when it began).
500 const held = await $.state.get(stackRef)
501 let value = toStack(held.value) ?? EMPTY
502 if (held.version === 0) {
503 value = await serially(() => load($)).catch(() => EMPTY)
504 $.clock.after(1, () => {
505 void refresh($).catch(() => undefined)
506 })
507 }
508 watch($)
509 const isHiding = await read($, hideDone)
510 const isConfirming = await read($, isConfirmingClear)
511 const isFolded = await read($, isCollapsed)
512
513 const todos = value.todos
514 const open = todos.filter(isOpen).length
515 const working = todos.filter(todo => todo.status === 'in_progress').length
516 const dropped = todos.filter(isDropped).length
517 const done = todos.length - open
518 const shown = isHiding ? todos.filter(isOpen) : todos
519
520 // Where a pointer can hover, a ✕ shows only while it is over its row (the
521 // keyed Box around it).
522 const canHover = e.surface === 'desktop' || e.surface === 'vscode'
523 const onHover = (control: ReturnType<typeof Button>) =>
524 canHover ? (
525 <Box display="none" hover={{ display: 'flex' }}>
526 {control}
527 </Box>
528 ) : (
529 control
530 )
531
532 // The mark is the checkbox; the text is plain text beside it, so it wraps
533 // instead of being cut to one line inside a button.
534 const row = (todo: StackedTodo) => {
535 const isDone = !isOpen(todo)
536 const mark = isDone ? '☑' : todo.status === 'in_progress' ? '◐' : '☐'
537
538 return (
539 <Box key={`r-${todo.id}`} flexDirection="row" gap={1} alignItems="flex-start">
540 <Button key={`t-${todo.id}`} plain onPress={() => change($, v => toggle(v, todo.id))}>
541 <Text color={isDone ? 'success' : todo.status === 'in_progress' ? 'warning' : undefined}>{mark}</Text>
542 </Button>
543 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
544 <Text
545 wrap="wrap"
546 strikethrough={isDone}
547 dimColor={isDone}
548 weight={todo.status === 'in_progress' ? 'semibold' : undefined}
549 >
550 {todo.text}
551 </Text>
552 {isDropped(todo) && (
553 <Text color="warning" size="footnote">
554 모델 목록에서 빠짐
555 </Text>
556 )}
557 </Box>
558 {onHover(
559 <Button key={`x-${todo.id}`} plain dimColor label="✕" onPress={() => change($, v => remove(v, todo.id))} />,
560 )}
561 </Box>
562 )
563 }
564
565 const summary = `남은 ${open} · 진행 중 ${working} · 완료 ${done}` + (dropped > 0 ? ` · 빠짐 ${dropped}` : '')
566
567 const header = (
568 <Box flexDirection="row" justifyContent="space-between" gap={1}>
569 <Button key="todo-fold" plain onPress={() => update($, isCollapsed, isOn => !isOn)}>
570 <Text weight="semibold">할 일</Text>
571 <Text dimColor>{isFolded ? ' ›' : ' ∨'}</Text>
572 </Button>
573 {isFolded ? (
574 <Text dimColor tabular>{`남은 ${open}`}</Text>
575 ) : (
576 <Button
577 key="hide"
578 plain
579 dimColor
580 label={isHiding ? '완료 보이기' : '완료 숨기기'}
581 onPress={() => update($, hideDone, isOn => !isOn)}
582 />
583 )}
584 </Box>
585 )
586
587 const card = isFolded ? (
588 <Box key="todo-card" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
589 {header}
590 </Box>
591 ) : (
592 <Box key="todo-card" flexDirection="column" gap="md" borderStyle="round" borderDimColor paddingX={1}>
593 {header}
594 <Text dimColor tabular>
595 {summary}
596 </Text>
597 {Input !== undefined && (
598 <Input
599 key="add"
600 placeholder="할 일 직접 추가…"
601 value=""
602 submitLabel="추가"
603 onSubmit={text => {
604 const added = oneLine(text, MAX_TEXT)
605 if (added !== '') void change($, (v, sessionId) => addManual(v, added, sessionId))
606 }}
607 />
608 )}
609 {shown.length === 0 ? (
610 <Text dimColor wrap="wrap">
611 {todos.length === 0
612 ? '아직 쌓인 할 일이 없습니다. 작업을 요청하면 모델이 만든 할 일이 여기에 위로 쌓입니다.'
613 : '남은 할 일이 없습니다.'}
614 </Text>
615 ) : (
616 <Box flexDirection="column" gap="xs">
617 {shown.map(row)}
618 </Box>
619 )}
620 {isHiding && done > 0 && <Text dimColor>{`완료된 할 일 ${done}개 숨김`}</Text>}
621 <Box flexDirection="row" flexWrap="wrap" gap={1}>
622 {open > 0 && (
623 <Button key="fill" variant="primary" label="남은 할 일 프롬프트에 넣기" onPress={() => fillPrompt($)} />
624 )}
625 {done > 0 && (
626 <Button key="clear-done" dimColor label="완료 정리" onPress={() => change($, clearDone)} />
627 )}
628 {todos.length > 0 && !isConfirming && (
629 <Button
630 key="clear"
631 dimColor
632 label="모두 지우기"
633 onPress={() => update($, isConfirmingClear, () => true)}
634 />
635 )}
636 {todos.length > 0 && isConfirming && <Text color="warning">모두 지울까요?</Text>}
637 {todos.length > 0 && isConfirming && (
638 <Button
639 key="clear-yes"
640 label="지우기"
641 onPress={async () => {
642 await change($, clearAll)
643 await update($, isConfirmingClear, () => false)
644 }}
645 />
646 )}
647 {todos.length > 0 && isConfirming && (
648 <Button key="clear-no" label="취소" onPress={() => update($, isConfirmingClear, () => false)} />
649 )}
650 </Box>
651 </Box>
652 )
653
654 return (
655 <Box flexDirection="column" gap={1}>
656 {above}
657 {card}
658 </Box>
659 )
660 })
661}
662types/index.d.ts 41 lines1export type TodoStatus = 'pending' | 'in_progress' | 'completed'
2
3export type StackedTodo = {
4 id: string
5 text: string
6 status: TodoStatus
7 // Where it came from: the model's TodoWrite list, its Task tools, or the person.
8 source: 'todo' | 'task' | 'manual'
9 // The session it was made in. The stack is shared by every session, but a
10 // model's list and task ids are its own session's.
11 sessionId?: string
12 // The status the model last reported, so a press of the person's is kept
13 // until the model itself reports a different one.
14 modelStatus?: TodoStatus
15 taskId?: string
16 // TodoWrite items only: whether the model's latest list still holds it.
17 isListed?: boolean
18}
19
20// A TodoWrite text the person removed, ignored until that session's list drops it.
21export type MutedText = { sessionId: string; text: string }
22
23// One stack for every session, kept in the plugin's store.
24export type TodoStack = {
25 // The top of the stack first: newer todos above older ones.
26 todos: StackedTodo[]
27 muted: MutedText[]
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'todo-stack': {
33 stack: TodoStack
34 hideDone: boolean
35 isConfirmingClear: boolean
36 // The person folded the card to its header.
37 isCollapsed: boolean
38 }
39 }
40}
41