A side pane listing the session's tasks: what is in progress, what is pending and what is done.

Sanmu 自製的 Claude Code mod 集合。這個 repo 本身就是一個 plugin marketplace,裡面每個資料夾是一個 mod。(English below. 中文說明在英文段落之後。)
A collection of Claude Code mods by Sanmu. The repo is itself a plugin marketplace; each folder is one mod.
Opens a "To-do" pane at the side of the window that shows the session's task list as it changes:
The labels are in Chinese by default; set the language option to en for English (see Settings below). Tasks come from the task tools Claude already uses (TaskCreate, TaskUpdate, TaskList, TodoWrite), so there is nothing to set up. When a session starts, the list is rebuilt from the conversation, so a resumed session shows its earlier tasks; /clear empties it. If you close the pane, type /to-do to open it again.
In a terminal, the pane docks at the side in fullscreen mode at 110 columns or wider; otherwise it shows above the prompt.
Your own to-dos and pins. Besides Claude's tasks, the pane keeps two lists of yours. Each session has its own pane: a resumed session shows its list again, a new session (or /clear) starts empty. They are saved in ~/.claude/to-do/sessions/<session id>.json.
/to-do add book flights. Click a to-do to tick it (it moves into the completed list; click it there to untick); × removes it./to-do pin [title].Finished work logs itself. When Claude finishes something you asked for, it adds one line to the completed list on its own (or ticks the matching to-do of yours). Turn this off with the autoLog option. Clear finished to-dos at the bottom of the open list empties it.
Claude can also mark, list and remove these by id (n1, p1 …) when you ask. The tools it uses for this only change the mod's own list, so they run without a permission prompt.
In a Claude Code terminal session, type:
/plugin install to-do --marketplace linsanmufilm-svg/claude-mods
Press y to add the marketplace, then Enter to pick the user scope (loads in every session).
Or from the command line:
claude plugin marketplace add linsanmufilm-svg/claude-mods
claude plugin install to-do@claude-mods
Start a new session and the pane appears.
| Option | Values | Default | What it does |
|---|---|---|---|
language | zh, en | zh | Language of the pane's labels |
autoLog | true, false | true | Claude adds each request it finishes to the completed list on its own |
Set it on the screen shown during install, or later from /config in a Claude Code terminal session (the plugin's rows are listed there). Changing it reloads the mod.
claude plugin marketplace update claude-mods
claude plugin update to-do@claude-mods
claude plugin uninstall to-do@claude-mods
These mods use Claude Code's function hooks API, which is still early access and may change between Claude Code releases.
在視窗側邊開一個「To-do」窗格,即時顯示這個 session 的任務清單:
任務來自 Claude 本來就在用的任務工具(TaskCreate、TaskUpdate、TaskList、TodoWrite),不需要額外設定。session 開始時會從對話紀錄重建清單,所以恢復舊 session 也看得到之前的任務;/clear 會清空清單。窗格關掉後輸入 /to-do 可以再打開。
標籤預設是中文;把 language 選項設成 en 就會改成英文(見下方「設定」)。
在終端機的全螢幕模式且寬度至少 110 欄時,窗格會停靠在側邊;其他情況顯示在輸入框上方。
自己的待辦與釘選。 除了 Claude 的任務,窗格還有兩份你自己的清單。每個 session 有自己的窗格:恢復舊 session 會看到它原本的清單,新 session(或 /clear 之後)從空的開始。資料存在 ~/.claude/to-do/sessions/<session id>.json。
/to-do add 訂機票。點一下打勾(會移到「已完成」清單,在那裡再點一下可取消);× 移除。/to-do pin [標題]。做完的事自動記錄。 Claude 做完你交代的事,會自己在「已完成」加一行(如果剛好是你的某個待辦,就直接把它打勾)。不想要可以把 autoLog 選項關掉。展開清單最下方的「清除已完成的待辦」可以清空。
你也可以請 Claude 依編號(n1、p1 …)標記完成、列出或移除這些項目。Claude 用來改這兩份清單的工具只會動這個 mod 自己的資料,所以執行時不會跳權限確認。
在 Claude Code 的終端機 session 裡輸入:
/plugin install to-do --marketplace linsanmufilm-svg/claude-mods
接著依序:按 y 加入 marketplace,再按 Enter 選 user scope(所有 session 都會載入)。
也可以用命令列安裝:
claude plugin marketplace add linsanmufilm-svg/claude-mods
claude plugin install to-do@claude-mods
裝好後開一個新的 session 就會生效。
| 選項 | 可選值 | 預設 | 作用 |
|---|---|---|---|
language | zh、en | zh | 窗格標籤的語言 |
autoLog | true、false | true | Claude 做完你交代的事會自動記進「已完成」 |
安裝時的設定畫面可以直接選;之後也能在 Claude Code 終端機 session 裡輸入 /config 修改(plugin 的選項會列在裡面),改完 mod 會自動重新載入。
claude plugin marketplace update claude-mods
claude plugin update to-do@claude-mods
claude plugin uninstall to-do@claude-mods
每個 mod 是一個 function-hooks plugin:
to-do/
├── .claude-plugin/plugin.json # manifest
├── hooks/hooks.json # 指向 hooks 模組
├── hooks/register.tsx # hooks 與窗格畫面
├── hooks/tasks.ts # 工具呼叫 → 任務清單
├── hooks/labels.ts # 中英文標籤
├── hooks/saved.ts # 我的待辦與釘選(存在 $.store)
├── types/index.d.ts # $.state 的型別契約
├── tests/to-do.test.ts
└── tests/saved.test.ts
檢查與測試:
claude plugin validate to-do
claude plugin test to-do
新增 mod 時,在 .claude-plugin/marketplace.json 的 plugins 加一筆 { "name": "<mod>", "source": "./<mod>" }。
這些 mod 使用 Claude Code 的 function hooks API,目前仍是 early access,Claude Code 版本更新時 API 可能變動。
hooks/register.tsx 494 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, ToolCallResult } from 'claude-code'
3
4import type { Saved, TodoItem, TodoStatus } from '../types'
5import { labelsFor } from './labels'
6import type { Labels } from './labels'
7import { EMPTY, addNote, addPin, clearDone, describe, has, parseSaved, remove, setDone } from './saved'
8import { TASK_TOOLS, applyCall } from './tasks'
9
10const PANE = 'to-do'
11const TITLE = 'To-do'
12const items = atom({ plugin: 'to-do', key: 'items' } as const, [])
13const saved = atom({ plugin: 'to-do', key: 'saved' } as const, EMPTY)
14const expanded = atom({ plugin: 'to-do', key: 'expanded' } as const, [])
15const showDone = atom({ plugin: 'to-do', key: 'showDone' } as const, false)
16
17const SECTIONS: { status: TodoStatus; mark: string; color?: string }[] = [
18 { status: 'in_progress', mark: '▸', color: 'warning' },
19 { status: 'pending', mark: '○' },
20 { status: 'completed', mark: '✓', color: 'success' },
21]
22
23// The tools Claude calls to change the person's own list, served below as
24// mcp__to-do__<name>. Each only touches that list, so they run without asking.
25const TOOL_SPECS = [
26 {
27 name: 'add_todo',
28 description:
29 "Add a to-do to the person's own list in the To-do side pane. Use it when the person asks you to add, " +
30 'note or remember something they need to do. The list belongs to this session.',
31 inputSchema: {
32 type: 'object',
33 properties: {
34 text: { type: 'string', description: "The to-do, short, in the person's own words and language." },
35 },
36 required: ['text'],
37 },
38 isDeferred: false,
39 },
40 {
41 name: 'pin',
42 description:
43 'Pin an important part of this conversation to the To-do side pane, so the person can find it again ' +
44 'without scrolling back; kept with this session. Use it when the person asks to pin, save or bookmark ' +
45 'something said in the conversation (a recommendation, a decision, a command, a list).',
46 inputSchema: {
47 type: 'object',
48 properties: {
49 title: { type: 'string', description: "A short name for what is pinned, in the person's language." },
50 body: {
51 type: 'string',
52 description:
53 'The pinned content itself in Markdown, faithful to what was said: keep names, commands, links ' +
54 'and numbers. The person reads this instead of the conversation, so give the substance, not a ' +
55 'pointer to it.',
56 },
57 },
58 required: ['title', 'body'],
59 },
60 isDeferred: false,
61 },
62 {
63 name: 'update_item',
64 description:
65 'Change an item in the To-do side pane by its id (n1, n2 … for to-dos, p1, p2 … for pins, as add_todo, ' +
66 'pin and list_items report them): mark a to-do done or not done, or remove a to-do or a pin.',
67 inputSchema: {
68 type: 'object',
69 properties: {
70 id: { type: 'string' },
71 action: { type: 'string', enum: ['done', 'undone', 'remove'] },
72 },
73 required: ['id', 'action'],
74 },
75 isDeferred: false,
76 },
77 {
78 name: 'log_done',
79 description:
80 "Record a piece of work you finished for the person in the To-do side pane's completed list " +
81 "(this session's). Call it once per finished request, as the system prompt describes.",
82 inputSchema: {
83 type: 'object',
84 properties: {
85 text: {
86 type: 'string',
87 description: "What was done, one short line in the person's language (e.g. 「把 to-do mod 推上 GitHub」).",
88 },
89 },
90 required: ['text'],
91 },
92 isDeferred: false,
93 },
94 {
95 name: 'list_items',
96 description: "List the person's saved to-dos and pins in the To-do side pane, with their ids.",
97 inputSchema: { type: 'object', properties: {} },
98 isDeferred: false,
99 },
100]
101
102// What the model reads so the completed list fills itself: added to the
103// system prompt while the autoLog option is on and log_done is offered.
104const AUTO_LOG = {
105 id: 'to-do:auto-log',
106 scope: 'session',
107 text: [
108 '# To-do pane',
109 "The person keeps a To-do side pane. Its completed list should show the work you finished for them, without their asking:",
110 '- When you finish something the person asked for (a change made, a thing installed, set up, written, published, or researched and reported back), call mcp__to-do__log_done once with one short line in the person\'s language saying what was done, before your final reply. One call per finished request, not one per step.',
111 "- If what you finished is one of the person's own open to-dos (mcp__to-do__list_items lists them), mark that to-do done with mcp__to-do__update_item instead of logging a new line.",
112 '- Do not log answers to simple questions, plans not yet carried out, or work that failed or was left unfinished.',
113 ].join('\n'),
114} as const
115
116// Opens the pane; where no surface can show one, there is nothing to do.
117function openPane($: EngineInterface): void {
118 $.ui.open({ id: PANE, title: TITLE }).catch(() => {})
119}
120
121// Each session has its own to-dos and pins: one file per session, named by
122// the session's id (its transcript's name), which a resumed session keeps.
123async function savedPath($: EngineInterface): Promise<string> {
124 const home = await $.env.get('HOME')
125 if (!home) throw new Error('HOME is not set: nowhere to keep the to-do list')
126 return `${home}/.claude/to-do/sessions/${await $.session.id()}.json`
127}
128
129async function writeSaved($: EngineInterface, value: Saved): Promise<void> {
130 await $.fs.write(await savedPath($), `${JSON.stringify(value, null, 2)}\n`)
131}
132
133/** Reads this session's list. A file that does not parse rejects rather than reads as empty, so it is never overwritten. */
134async function loadSaved($: EngineInterface): Promise<Saved> {
135 const path = await savedPath($)
136 return (await $.fs.exists(path)) ? parseSaved(JSON.parse(await $.fs.read(path))) : EMPTY
137}
138
139/**
140 * Applies a change to this session's to-dos and pins, read fresh from its
141 * file so the state never drifts from what is saved.
142 */
143async function changeSaved($: EngineInterface, change: (s: Saved) => Saved): Promise<Saved> {
144 const after = change(await loadSaved($))
145 await writeSaved($, after)
146 await update($, saved, () => after)
147 return after
148}
149
150async function record(
151 $: EngineInterface,
152 call: { readonly tool: string },
153 ran: ToolCallResult,
154): Promise<void> {
155 if ('deny' in ran && ran.deny !== undefined) return
156 if (ran.isError) return
157 try {
158 let wasEmpty = false
159 await update($, items, list => {
160 wasEmpty = list.length === 0
161 return applyCall(list, call.tool, call as Record<string, unknown>, ran.result)
162 })
163 if (wasEmpty && (await read($, items)).length > 0) openPane($)
164 } catch {
165 // Keep the tool's result whatever became of the list.
166 }
167}
168
169const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
170
171async function addTodo($: EngineInterface, labels: Labels, text: string, isDone = false): Promise<string> {
172 let id = ''
173 await changeSaved($, s => {
174 const added = addNote(s, text, isDone)
175 id = added.note.id
176 return added.saved
177 })
178 openPane($)
179 return isDone ? labels.logged(id, text) : labels.added(id, text)
180}
181
182async function pinText($: EngineInterface, labels: Labels, title: string, body: string): Promise<string> {
183 let id = ''
184 await changeSaved($, s => {
185 const added = addPin(s, title, body)
186 id = added.pin.id
187 return added.saved
188 })
189 openPane($)
190 return labels.pinned(id, title)
191}
192
193export const register: Register = (on, options) => {
194 const labels = labelsFor(options.language)
195
196 on('session.start', async ($, e, next) => {
197 await $.command.register({
198 name: 'to-do',
199 description: 'Show the To-do pane; add a to-do or pin the selected text',
200 argumentHint: 'add <to-do> | pin [title]',
201 })
202 try {
203 for (const spec of TOOL_SPECS) await $.tool.register(spec)
204 } catch {
205 // Without the tools the pane still shows Claude's tasks and the saved list.
206 }
207
208 try {
209 const loaded = await loadSaved($)
210 await update($, saved, () => loaded)
211 } catch {
212 // A file that does not parse: the pane starts empty and the file is
213 // left alone until it is fixed.
214 }
215
216 // Rebuild the list from the transcript, so a resumed session or a reload
217 // of this mod shows the tasks made before it loaded.
218 try {
219 let list: TodoItem[] = []
220 let found = false
221 for (const message of await $.session.messages()) {
222 for (const use of message.toolUses) {
223 if (!(TASK_TOOLS as readonly string[]).includes(use.tool) || use.isError) continue
224 found = true
225 list = applyCall(list, use.tool, use.input, use.result)
226 }
227 }
228 if (found) await update($, items, () => list)
229 } catch {
230 // No transcript to read: start from what the state holds.
231 }
232
233 openPane($)
234
235 return next(e)
236 })
237
238 on('session.end', async ($, e, next) => {
239 // A /clear starts a new session, with a list of its own.
240 if (e.reason === 'clear') {
241 await update($, items, () => [])
242 await update($, saved, () => EMPTY)
243 await update($, expanded, () => [])
244 await update($, showDone, () => false)
245 }
246
247 return next(e)
248 })
249
250 on('command.run', { command: 'to-do' }, async ($, e) => {
251 const args = e.args.trim()
252 const [verb = '', ...rest] = args.split(/\s+/)
253 const tail = args.slice(verb.length).trim()
254
255 if (args === '') {
256 await $.ui.open({ id: PANE, title: TITLE })
257 return { text: 'To-do pane opened.' }
258 }
259 if (verb === 'add' && tail !== '') return { text: await addTodo($, labels, tail) }
260 if (verb === 'pin') {
261 const selected = await $.ui.selection().catch(() => undefined)
262 const body = selected?.text.trim() ?? ''
263 if (body === '') return { text: labels.nothingSelected }
264 const firstLine = body.split('\n')[0] ?? body
265 const title = rest.length > 0 ? tail : firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine
266 return { text: await pinText($, labels, title, body) }
267 }
268
269 return { text: labels.usage }
270 })
271
272 // Claude's own task tools. Each call runs first, then what it answered is
273 // folded into the list. The hooks never refuse a call: the list is a view,
274 // so a failure passes it on.
275 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
276 const ran = await next(e)
277 await record($, e, ran)
278 return ran
279 }).catch(($, e, next) => next(e))
280 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
281 const ran = await next(e)
282 await record($, e, ran)
283 return ran
284 }).catch(($, e, next) => next(e))
285 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
286 const ran = await next(e)
287 await record($, e, ran)
288 return ran
289 }).catch(($, e, next) => next(e))
290 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
291 const ran = await next(e)
292 // A subagent's TodoWrite is its own list, not the session's.
293 if (!e.agentId) await record($, e, ran)
294 return ran
295 }).catch(($, e, next) => next(e))
296
297 // The mod's own tools.
298 on('tool.call', { tool: 'mcp__to-do__add_todo' }, async ($, e) => {
299 const text = str(e.text)
300 if (text === '') return { result: 'Nothing added: text is empty.' }
301 return { result: await addTodo($, labels, text) }
302 }).catch(() => ({ result: 'The To-do pane could not save this; nothing changed.' }))
303 on('tool.call', { tool: 'mcp__to-do__log_done' }, async ($, e) => {
304 const text = str(e.text)
305 if (text === '') return { result: 'Nothing logged: text is empty.' }
306 return { result: await addTodo($, labels, text, true) }
307 }).catch(() => ({ result: 'The To-do pane could not save this; nothing changed.' }))
308 on('tool.call', { tool: 'mcp__to-do__pin' }, async ($, e) => {
309 const title = str(e.title)
310 const body = str(e.body)
311 if (title === '' || body === '') return { result: 'Nothing pinned: title and body are both needed.' }
312 return { result: await pinText($, labels, title, body) }
313 }).catch(() => ({ result: 'The To-do pane could not save this; nothing changed.' }))
314 on('tool.call', { tool: 'mcp__to-do__update_item' }, async ($, e) => {
315 const id = str(e.id)
316 const action = str(e.action)
317 const before = await loadSaved($)
318 if (!has(before, id)) return { result: `No item ${id}.\n${describe(before)}` }
319 if (action === 'remove') await changeSaved($, s => remove(s, id))
320 else if (action === 'done' || action === 'undone') await changeSaved($, s => setDone(s, id, action === 'done'))
321 else return { result: `Unknown action ${action}: use done, undone or remove.` }
322 return { result: `${id}: ${action}.` }
323 }).catch(() => ({ result: 'The To-do pane could not save this; nothing changed.' }))
324 on('tool.call', { tool: 'mcp__to-do__list_items' }, async $ => ({ result: describe(await loadSaved($)) })).catch(
325 () => ({ result: 'The To-do list could not be read.' }),
326 )
327
328 // These tools change only the mod's own list: let them run without a prompt.
329 on('tool.check', { tool: 'mcp__to-do__add_todo' }, () => ({ decision: 'allow' }))
330 on('tool.check', { tool: 'mcp__to-do__log_done' }, () => ({ decision: 'allow' }))
331 on('tool.check', { tool: 'mcp__to-do__pin' }, () => ({ decision: 'allow' }))
332
333 // The completed list fills itself: the model is told to log what it finishes.
334 on('prompt.compose', async ($, e, next) => {
335 const composed = await next(e)
336 if (options.autoLog === false || !e.tools.includes('mcp__to-do__log_done')) return composed
337 return { sections: [...composed.sections, AUTO_LOG] }
338 })
339 on('tool.check', { tool: 'mcp__to-do__update_item' }, () => ({ decision: 'allow' }))
340 on('tool.check', { tool: 'mcp__to-do__list_items' }, () => ({ decision: 'allow' }))
341
342 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
343 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
344 const list = await read($, items)
345 const { notes, pins } = await read($, saved)
346 const open = await read($, expanded)
347 const isDoneShown = await read($, showDone)
348
349 if (list.length === 0 && notes.length === 0 && pins.length === 0) {
350 return (
351 <Box flexDirection="column">
352 <Text dimColor>{labels.empty[0]}</Text>
353 <Text dimColor>{labels.empty[1]}</Text>
354 </Box>
355 )
356 }
357
358 const done = list.filter(item => item.status === 'completed').length
359 const sections: RenderChildren[] = []
360
361 if (pins.length > 0) {
362 sections.push(
363 <Box key="pins" flexDirection="column">
364 <Text bold color="claude">
365 📌 {labels.pins} ({pins.length})
366 </Text>
367 {pins.map(p => {
368 const isOpen = open.includes(p.id)
369 return (
370 <Box key={`pin-${p.id}`} flexDirection="column">
371 <Box flexDirection="row" justifyContent="space-between">
372 <Button
373 key={`open-${p.id}`}
374 plain
375 onPress={() =>
376 update($, expanded, ids => (ids.includes(p.id) ? ids.filter(id => id !== p.id) : [...ids, p.id]))
377 }
378 >
379 {isOpen ? '▾' : '▸'} {p.title}
380 </Button>
381 <Button key={`unpin-${p.id}`} plain dimColor label="×" onPress={() => changeSaved($, s => remove(s, p.id))} />
382 </Box>
383 {isOpen && (
384 <Box marginLeft={2} marginBottom={1}>
385 <Markdown text={p.body} />
386 </Box>
387 )}
388 </Box>
389 )
390 })}
391 </Box>,
392 )
393 }
394
395 const openNotes = notes.filter(n => !n.isDone)
396 const doneNotes = notes.filter(n => n.isDone)
397 const doneTasks = list.filter(item => item.status === 'completed')
398
399 if (openNotes.length > 0) {
400 sections.push(
401 <Box key="notes" flexDirection="column">
402 <Text bold>
403 {labels.notes} ({openNotes.length})
404 </Text>
405 {openNotes.map(n => (
406 <Box key={`note-${n.id}`} flexDirection="row" justifyContent="space-between">
407 <Button key={`toggle-${n.id}`} plain onPress={() => changeSaved($, s => setDone(s, n.id, true))}>
408 ☐ {n.text}
409 </Button>
410 <Button key={`remove-${n.id}`} plain dimColor label="×" onPress={() => changeSaved($, s => remove(s, n.id))} />
411 </Box>
412 ))}
413 </Box>,
414 )
415 }
416
417 if (list.length > 0) {
418 sections.push(
419 <Box key="tasks" flexDirection="column">
420 <Text bold>
421 {labels.tasks} · {labels.progress(done, list.length)}
422 </Text>
423 {SECTIONS.filter(section => section.status !== 'completed').map(section => {
424 const rows = list.filter(item => item.status === section.status)
425 if (rows.length === 0 && section.status === 'in_progress') return null
426
427 return (
428 <Box key={section.status} flexDirection="column" marginTop={1}>
429 <Text bold color={section.color}>
430 {labels.sections[section.status]} ({rows.length})
431 </Text>
432 {rows.length === 0 && <Text dimColor>{labels.none}</Text>}
433 {rows.map(item => (
434 <Text key={item.id} color={section.color}>
435 {section.mark} {item.subject}
436 </Text>
437 ))}
438 </Box>
439 )
440 })}
441 </Box>,
442 )
443 }
444
445 // Everything finished, the person's to-dos and Claude's tasks alike, in one
446 // list that stays folded until it is opened.
447 const doneCount = doneNotes.length + doneTasks.length
448 if (doneCount > 0) {
449 sections.push(
450 <Box key="done" flexDirection="column">
451 <Button key="done-toggle" plain onPress={() => update($, showDone, isShown => !isShown)}>
452 {isDoneShown ? '▾' : '▸'} <Text color="success">✓ {labels.sections.completed}</Text> ({doneCount})
453 </Button>
454 {isDoneShown && (
455 <Box flexDirection="column" marginLeft={2}>
456 {doneNotes.map(n => (
457 <Box key={`note-${n.id}`} flexDirection="row" justifyContent="space-between">
458 <Button
459 key={`toggle-${n.id}`}
460 plain
461 dimColor
462 onPress={() => changeSaved($, s => setDone(s, n.id, false))}
463 >
464 ☑ {n.text}
465 </Button>
466 <Button key={`remove-${n.id}`} plain dimColor label="×" onPress={() => changeSaved($, s => remove(s, n.id))} />
467 </Box>
468 ))}
469 {doneTasks.map(item => (
470 <Text key={item.id} dimColor>
471 ✓ {item.subject}
472 </Text>
473 ))}
474 {doneNotes.length > 0 && (
475 <Box marginTop={1}>
476 <Button key="clear-done" plain dimColor onPress={() => changeSaved($, clearDone)}>
477 {labels.clearDone}
478 </Button>
479 </Box>
480 )}
481 </Box>
482 )}
483 </Box>,
484 )
485 }
486
487 return (
488 <Box flexDirection="column" gap={1}>
489 {sections}
490 </Box>
491 )
492 })
493}
494hooks/labels.ts 54 lines1import type { TodoStatus } from '../types'
2
3export type Labels = {
4 sections: Record<TodoStatus, string>
5 progress: (done: number, total: number) => string
6 empty: [string, string]
7 none: string
8 notes: string
9 pins: string
10 tasks: string
11 added: (id: string, text: string) => string
12 pinned: (id: string, title: string) => string
13 logged: (id: string, text: string) => string
14 clearDone: string
15 nothingSelected: string
16 usage: string
17}
18
19const LABELS: Record<'zh' | 'en', Labels> = {
20 zh: {
21 sections: { in_progress: '進行中', pending: '待辦', completed: '已完成' },
22 progress: (done, total) => `完成 ${done}/${total}`,
23 empty: ['還沒有任務。', '跟 Claude 說「加一個待辦:…」或「把…釘選起來」,或等 Claude 排出任務。'],
24 none: '(無)',
25 notes: '我的待辦',
26 pins: '釘選',
27 tasks: 'Claude 的任務',
28 added: (id, text) => `已加入待辦 ${id}:${text}`,
29 pinned: (id, title) => `已釘選 ${id}:${title}`,
30 logged: (id, text) => `已記進已完成 ${id}:${text}`,
31 clearDone: '清除已完成的待辦',
32 nothingSelected: '沒有選取的文字。先在對話裡選取要釘選的段落,或直接跟 Claude 說「把…釘選起來」。',
33 usage: '用法:/to-do(打開窗格)、/to-do add <待辦>、/to-do pin [標題](釘選目前選取的文字)',
34 },
35 en: {
36 sections: { in_progress: 'In progress', pending: 'Pending', completed: 'Completed' },
37 progress: (done, total) => `${done}/${total} done`,
38 empty: ['No tasks yet.', 'Ask Claude to "add a to-do: …" or "pin …", or wait for Claude to plan tasks.'],
39 none: '(none)',
40 notes: 'My to-dos',
41 pins: 'Pinned',
42 tasks: "Claude's tasks",
43 added: (id, text) => `Added to-do ${id}: ${text}`,
44 pinned: (id, title) => `Pinned ${id}: ${title}`,
45 logged: (id, text) => `Logged as done ${id}: ${text}`,
46 clearDone: 'Clear finished to-dos',
47 nothingSelected: 'Nothing is selected. Select a part of the conversation first, or ask Claude to "pin …".',
48 usage: 'Usage: /to-do (open the pane), /to-do add <to-do>, /to-do pin [title] (pin the selected text)',
49 },
50}
51
52/** The labels for the `language` option; anything but `en` reads as Chinese. */
53export const labelsFor = (language: unknown): Labels => (language === 'en' ? LABELS.en : LABELS.zh)
54hooks/saved.ts 74 lines1import type { NoteItem, PinItem, Saved } from '../types'
2
3export const EMPTY: Saved = { notes: [], pins: [] }
4
5const isRec = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
6
7/** Reads what `$.store` held, dropping anything that is not a note or a pin. */
8export function parseSaved(raw: unknown): Saved {
9 if (!isRec(raw)) return EMPTY
10 const notes = Array.isArray(raw.notes)
11 ? raw.notes.filter(
12 (n): n is NoteItem =>
13 isRec(n) && typeof n.id === 'string' && typeof n.text === 'string' && typeof n.isDone === 'boolean',
14 )
15 : []
16 const pins = Array.isArray(raw.pins)
17 ? raw.pins.filter(
18 (p): p is PinItem =>
19 isRec(p) && typeof p.id === 'string' && typeof p.title === 'string' && typeof p.body === 'string',
20 )
21 : []
22 return { notes, pins }
23}
24
25/** The next id after the highest one with this prefix: n1, n2 … or p1, p2 … */
26function nextId(prefix: 'n' | 'p', ids: readonly string[]): string {
27 const top = ids.reduce((max, id) => {
28 const n = id.startsWith(prefix) ? Number(id.slice(1)) : NaN
29 return Number.isInteger(n) && n > max ? n : max
30 }, 0)
31 return `${prefix}${top + 1}`
32}
33
34export function addNote(saved: Saved, text: string, isDone = false): { saved: Saved; note: NoteItem } {
35 const note: NoteItem = { id: nextId('n', saved.notes.map(n => n.id)), text, isDone }
36 return { saved: { ...saved, notes: [...saved.notes, note] }, note }
37}
38
39export function addPin(saved: Saved, title: string, body: string): { saved: Saved; pin: PinItem } {
40 const pin: PinItem = { id: nextId('p', saved.pins.map(p => p.id)), title, body }
41 return { saved: { ...saved, pins: [...saved.pins, pin] }, pin }
42}
43
44export function setDone(saved: Saved, id: string, isDone: boolean): Saved {
45 return { ...saved, notes: saved.notes.map(n => (n.id === id ? { ...n, isDone } : n)) }
46}
47
48export function remove(saved: Saved, id: string): Saved {
49 return { notes: saved.notes.filter(n => n.id !== id), pins: saved.pins.filter(p => p.id !== id) }
50}
51
52/** Drops every ticked to-do; pins and open to-dos stay. */
53export function clearDone(saved: Saved): Saved {
54 return { ...saved, notes: saved.notes.filter(n => !n.isDone) }
55}
56
57export const has = (saved: Saved, id: string): boolean =>
58 saved.notes.some(n => n.id === id) || saved.pins.some(p => p.id === id)
59
60/** The saved list as the model reads it from list_items. */
61export function describe(saved: Saved): string {
62 if (saved.notes.length === 0 && saved.pins.length === 0) return 'No saved to-dos or pins.'
63 const lines: string[] = []
64 if (saved.notes.length > 0) {
65 lines.push('To-dos:')
66 for (const n of saved.notes) lines.push(`- ${n.id} [${n.isDone ? 'x' : ' '}] ${n.text}`)
67 }
68 if (saved.pins.length > 0) {
69 lines.push('Pins:')
70 for (const p of saved.pins) lines.push(`- ${p.id} ${p.title}`)
71 }
72 return lines.join('\n')
73}
74hooks/tasks.ts 72 lines1import type { TodoItem, TodoStatus } from '../types'
2
3/** The tools whose calls change the task list. */
4export const TASK_TOOLS = ['TaskCreate', 'TaskUpdate', 'TaskList', 'TodoWrite'] as const
5
6type Rec = Record<string, unknown>
7
8const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null
9const isStatus = (v: unknown): v is TodoStatus =>
10 v === 'pending' || v === 'in_progress' || v === 'completed'
11const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
12
13/**
14 * Folds one answered call into the list. `input` is what the model sent,
15 * `result` the tool's record; a call that errored leaves the list as it was.
16 */
17export function applyCall(
18 list: readonly TodoItem[],
19 tool: string,
20 input: Rec,
21 result: unknown,
22): TodoItem[] {
23 const tasks = list.filter(item => item.source === 'task')
24 const todos = list.filter(item => item.source === 'todo')
25
26 switch (tool) {
27 case 'TaskCreate': {
28 const task = isRec(result) && isRec(result.task) ? result.task : undefined
29 const id = str(task?.id)
30 if (!id) return [...list]
31 const subject = str(input.subject) ?? str(task?.subject) ?? id
32 return [...list, { id: `task:${id}`, subject, status: 'pending', source: 'task' }]
33 }
34 case 'TaskUpdate': {
35 if (isRec(result) && result.success === false) return [...list]
36 const id = `task:${str(input.taskId) ?? ''}`
37 if (input.status === 'deleted') return list.filter(item => item.id !== id)
38 return list.map(item =>
39 item.id === id
40 ? {
41 ...item,
42 subject: str(input.subject) ?? item.subject,
43 status: isStatus(input.status) ? input.status : item.status,
44 }
45 : item,
46 )
47 }
48 case 'TaskList': {
49 const listed = isRec(result) && Array.isArray(result.tasks) ? result.tasks : undefined
50 if (!listed) return [...list]
51 const fresh: TodoItem[] = listed.filter(isRec).flatMap(task => {
52 const id = str(task.id)
53 if (!id || !isStatus(task.status)) return []
54 return [{ id: `task:${id}`, subject: str(task.subject) ?? id, status: task.status, source: 'task' as const }]
55 })
56 return [...fresh, ...todos]
57 }
58 case 'TodoWrite': {
59 const written = Array.isArray(input.todos) ? input.todos : undefined
60 if (!written) return [...list]
61 const fresh: TodoItem[] = written.filter(isRec).flatMap((todo, i) => {
62 const subject = str(todo.content)
63 if (!subject || !isStatus(todo.status)) return []
64 return [{ id: `todo:${i}`, subject, status: todo.status, source: 'todo' as const }]
65 })
66 return [...tasks, ...fresh]
67 }
68 default:
69 return [...list]
70 }
71}
72types/index.d.ts 42 lines1export type TodoStatus = 'pending' | 'in_progress' | 'completed'
2
3/** One task, from the TaskCreate/TaskUpdate tools (`task`) or TodoWrite (`todo`). */
4export type TodoItem = {
5 id: string
6 subject: string
7 status: TodoStatus
8 source: 'task' | 'todo'
9}
10
11/** A to-do the person asked for; kept with the session. */
12export type NoteItem = {
13 id: string
14 text: string
15 isDone: boolean
16}
17
18/** A part of a conversation the person pinned; kept with the session. */
19export type PinItem = {
20 id: string
21 title: string
22 body: string
23}
24
25/** What the mod keeps per session in ~/.claude/to-do/sessions/<session id>.json. */
26export type Saved = {
27 notes: NoteItem[]
28 pins: PinItem[]
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'to-do': {
34 items: TodoItem[]
35 saved: Saved
36 expanded: string[]
37 /** Whether the pane shows its folded list of finished items. */
38 showDone: boolean
39 }
40 }
41}
42