SLOPSHOPPER

todo-stack

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…

paneguardcommandtimer
v0.5.2no licenseupdated 2026-10-26ImTotem/claude-code-mods/plugins/todo-stack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-stack
│ ┃ 세션 ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ todo-fold 완료 숨기기 │ ⏺ Update(src/auth.ts) │ ┃ │ 남은 0 · 진행 중 0 · 완료 0 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ 할 일 직접 추가… ⏎ 추가 │ ⏺ Bash(bun test) │ ┃ │ 아직 쌓인 할 일이 없습니다. 작업을 │ ⎿ 3 pass, 1 fail │ ┃ │ 요청하면 모델이 만든 할 일이 여기에 위로 │ │ ┃ │ 쌓입니다. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo-stack │ ⎿ todo-stack: 할 일 스택을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 세션
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────╮ │ todo-fold 완료 숨기기 │ │ 남은 0 · 진행 중 0 · 완료 0 │ │ 할 일 직접 추가… ⏎ 추가 │ │ 아직 쌓인 할 일이 없습니다. 작업을 요청하면 모델이 만든 │ │ 할 일이 여기에 위로 쌓입니다. │ ╰──────────────────────────────────────────────────────────╯
README

todo-stack

세션 패널(session-rail)에 할 일 카드를 넣고, 모델이 만드는 할 일을 하나의 스택으로 계속 쌓아 두는 모드입니다. 컨텍스트 압축이나 새 요청 때문에 할 일이 사라지거나 잊히지 않게 하려고 만들었습니다.

  • 요청별로 묶지 않고 할 일만 쌓습니다. 모델이 만든 할 일(TodoWrite, TaskCreate)과 직접 추가한 할 일 모두 새로 생긴 것이 맨 위에 올라갑니다. 모델이 한 번에 만든 목록은 그 순서 그대로 한 덩어리로 위에 올라갑니다.
  • 이미 쌓인 할 일은 상태가 바뀌어도 자리를 옮기지 않습니다.
  • 할 일 글은 잘리지 않고 줄바꿈됩니다.
  • ☐ 대기 · ◐ 진행 중 · ☑ 완료. 표시를 누르면 완료/미완료가 바뀌고, ✕는 그 할 일을 지웁니다. 데스크톱·VS Code에서는 ✕가 마우스를 올린 줄에만 보입니다.
  • 모델의 목록에서 빠졌는데 끝나지 않은 항목은 모델 목록에서 빠짐으로 표시되어 남습니다. 모델이 만든 할 일은 그 할 일을 만든 세션의 목록만 따라가므로, 다른 세션의 목록이 빼먹었다고 빠짐으로 표시하지 않습니다.
  • 이전 세션에서 쌓인 할 일이나 직접 추가한 할 일 중 끝나지 않은 것을 모델이 같은 글로 목록(TodoWrite)이나 TaskCreate에 쓰면, 새로 쌓지 않고 그 할 일을 이어받습니다. 그래서 새 세션의 모델도 이전 할 일의 상태를 바꾸고 완료할 수 있습니다. 남은 할 일 프롬프트에 넣기로 넣은 글이 그대로 쓰이면 이어받아집니다.
  • 직접 누른 완료 표시와 직접 지운 항목은 모델이 다음에 목록을 다시 써도 되돌아오지 않습니다.
  • 맨 위 입력칸이나 /todo-stack <할 일>로 직접 할 일을 추가할 수 있습니다. 스택 맨 위에 올라갑니다.
  • 남은 할 일 프롬프트에 넣기: 끝나지 않은 할 일 목록을 프롬프트 상자에 넣어 줍니다. 모델에게 다시 알려줄 때 씁니다.
  • 완료 숨기기 / 완료 정리 / 모두 지우기(한 번 더 확인)로 스택을 정리할 수 있습니다.
  • 컨텍스트 압축 때 그 세션에서 생긴 끝나지 않은 할 일을 요약에 그대로 남기라는 지시가 압축 지시에 덧붙습니다. 조직이 Claude Code를 관리하는 환경에서는 사용자가 설치한 플러그인의 압축 훅이 실행되지 않습니다.
  • 스택은 세션과 상관없이 하나입니다. 플러그인 저장소(Claude Code 설정 폴더의 plugins/store/)에 저장되어 새 세션을 열어도, 플러그인을 새로고침하거나 업데이트해도 그대로 남습니다. 같은 컴퓨터에서 여러 세션을 함께 열어도 같은 스택을 쓰고, 다른 세션에서 바뀐 내용은 10초 안에 보입니다.
  • 클라우드 세션(claude.ai/code, 앱의 클라우드 세션)은 세션마다 컨테이너가 따로라서 저장소도 세션마다 따로입니다. 한 클라우드 세션 안에서는 새로고침해도 남지만, 다른 클라우드 세션과는 공유되지 않습니다.
  • 세션별로 저장하던 이전 버전의 스택은 처음 실행할 때 하나로 합칩니다(최근에 쓴 세션의 할 일이 위). 요청별로 묶여 있던 것도 묶음을 풀어 합칩니다. Claude Code는 디스크에 있던 버전으로 먼저 시작하고 업데이트를 그 뒤에 받기 때문에, 이전 버전이 잠깐 실행되며 따로 저장한 할 일도 다음에 읽을 때 위에 합칩니다.
  • 카드 제목(할 일 ∨)을 누르면 접히고, 접힌 동안에는 남은 개수만 보입니다.
  • /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/에 깔아 주는 타입 설정을 이어받습니다. 그 폴더는 커밋하지 않습니다.

Source 2 files
hooks/register.tsx 662 lines
1import { 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}
662
types/index.d.ts 41 lines
1export 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