待办看板:输入框上方常驻摘要 + 侧边栏增删改查,支持应用到输入框、提交后询问标记完成

Claude Code 的待办(todo)mod 合集。首个 mod:todo —— 一个活在 Claude Code 界面里的待办看板。
o 或点击「看板」打开侧边栏/todo 打开):HH:mm、M-D HH:mm、YYYY-M-D HH:mm 手动输入)/todo 买牛奶 即可新增a,把该待办整理成一段提示词填入输入框(可继续编辑)y)或跳过(n)三选一:
在 Claude Code 终端会话中输入:
/plugin install todo --marketplace Ivan97/cc-todo-mods
(先 y 添加 marketplace,回车选 user 作用域即可。)一条命令完成,不依赖本机路径;后续用 /plugin 菜单检查更新。
在 ~/.claude/settings.json 的 env 块加入:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cc-todo-mods/mods/todo"
}
}
每个 Claude Code 会话(任何项目目录)自动加载,/clear 后依然在;保存 mod 文件即热重载,适合开发迭代。改完重启 Claude Code 生效。注意:与方式一不要同时用,否则同一 mod 会被加载两份。
# 从本仓库文件夹安装(文件夹型 marketplace,编辑后 /reload-plugins 即生效)
claude plugin marketplace add /path/to/cc-todo-mods
# 然后在 Claude Code 里: /plugin install todo --marketplace cc-todo-mods
| 操作 | 入口 |
|---|---|
| 打开看板 | /todo,或摘要条按 o / 点「看板」 |
| 快速添加 | /todo 买牛奶(默认截止:当天 20:00,18 点后次日 14:00) |
| 新增(表单) | 看板内按 n 或点「新增」,可填标题/备注/截止/标签/重要程度 |
| 保存表单 | s(或回车提交任一输入框) |
| 查看详情 | 看板内点击待办标题 |
| 编辑 / 应用 / 完成 / 删除 | 详情页 e / a / 完成 / 删除 |
| 提交后确认完成 | 应用过的待办在提交提示词后弹确认框,y 标记完成 / n 跳过 |
数据与 /clear:待办存在 Claude Code 插件 store(~/.claude/plugins/store/),跨会话持久;/clear 或切换会话后自动恢复(摘要条与看板重新出现)。
# 校验 + 测试(引擎级 UI 测试)
claude plugin validate mods/todo
claude plugin test mods/todo
# 类型检查(首次运行前需已用任一方式加载过一次,引擎会在
# mods/todo/.claude-plugin/types/ 铺好类型环境)
cd mods/todo && bunx tsc -p . --noEmit
MIT
hooks/register.tsx 595 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Importance, Todo, TodoForm } from '../types'
5
6const PANE = 'todo'
7const ASK = 'todo-ask'
8
9// ---------- state refs ----------
10// $.state.set 只收本文件内的字面量 const 引用;atom 是 { ref, initial } 包装,只进 read/update
11const todosRef = { plugin: 'todo', key: 'todos' } as const
12const viewRef = { plugin: 'todo', key: 'view' } as const
13const formModeRef = { plugin: 'todo', key: 'formMode' } as const
14const formTargetIdRef = { plugin: 'todo', key: 'formTargetId' } as const
15const formRef = { plugin: 'todo', key: 'form' } as const
16const formErrorRef = { plugin: 'todo', key: 'formError' } as const
17const hideDoneRef = { plugin: 'todo', key: 'hideDone' } as const
18const detailIdRef = { plugin: 'todo', key: 'detailId' } as const
19const appliedRef = { plugin: 'todo', key: 'applied' } as const
20const askRef = { plugin: 'todo', key: 'ask' } as const
21const hydratedRef = { plugin: 'todo', key: 'hydrated' } as const
22
23const todos = atom(todosRef, [])
24const view = atom(viewRef, 'list')
25const formMode = atom(formModeRef, 'new')
26const formTargetId = atom(formTargetIdRef, null)
27const form = atom(formRef, {
28 title: '',
29 notes: '',
30 due: '',
31 tags: '',
32 importance: 'general',
33} as TodoForm)
34const formError = atom(formErrorRef, null)
35const hideDone = atom(hideDoneRef, false)
36const detailId = atom(detailIdRef, null)
37const applied = atom(appliedRef, null)
38const ask = atom(askRef, null)
39/** 本 session 是否已从 $.store 恢复过 todos(atom 未写入时读初始值,永非 undefined,须靠哨兵判断) */
40const hydrated = atom(hydratedRef, false)
41
42// ---------- importance ----------
43const IMPORTANCES: readonly Importance[] = ['urgent-important', 'important', 'general']
44const IMPORTANCE_LABEL: Record<Importance, string> = {
45 'urgent-important': '🔴 重要紧急',
46 important: '🟡 重要不紧急',
47 general: '⚪ 一般事件',
48}
49const IMPORTANCE_PLAIN: Record<Importance, string> = {
50 'urgent-important': '重要紧急',
51 important: '重要不紧急',
52 general: '一般事件',
53}
54const IMPORTANCE_ORDER: Record<Importance, number> = {
55 'urgent-important': 0,
56 important: 1,
57 general: 2,
58}
59
60// ---------- pure helpers (exported for tests) ----------
61
62/** 默认截止:当天 20:00;过了当天 18:00 则次日 14:00 */
63export function defaultDue(now: Date): string {
64 const d = new Date(now.getFullYear(), now.getMonth(), now.getDate())
65 if (now.getHours() >= 18) {
66 d.setDate(d.getDate() + 1)
67 d.setHours(14, 0, 0, 0)
68 } else {
69 d.setHours(20, 0, 0, 0)
70 }
71 return d.toISOString()
72}
73
74/** 解析截止输入:'' → 默认;HH:mm;M-D [HH:mm];YYYY-M-D [HH:mm];解析失败返回 null */
75export function parseDueInput(text: string, now: Date): string | null {
76 const t = text.trim()
77 if (t === '') return defaultDue(now)
78
79 let m = /^(\d{1,2}):(\d{2})$/.exec(t)
80 if (m) {
81 const h = Number(m[1])
82 const mi = Number(m[2])
83 if (h > 23 || mi > 59) return null
84 const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, mi)
85 if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1)
86 return d.toISOString()
87 }
88
89 m = /^(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/.exec(t)
90 if (m) {
91 const mo = Number(m[1])
92 const day = Number(m[2])
93 const h = m[3] === undefined ? 20 : Number(m[3])
94 const mi = m[4] === undefined ? 0 : Number(m[4])
95 if (mo < 1 || mo > 12 || h > 23 || mi > 59) return null
96 const d = new Date(now.getFullYear(), mo - 1, day, h, mi)
97 if (d.getMonth() !== mo - 1 || d.getDate() !== day) return null
98 if (d.getTime() < now.getTime()) d.setFullYear(d.getFullYear() + 1)
99 return d.toISOString()
100 }
101
102 m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/.exec(t)
103 if (m) {
104 const y = Number(m[1])
105 const mo = Number(m[2])
106 const day = Number(m[3])
107 const h = m[4] === undefined ? 20 : Number(m[4])
108 const mi = m[5] === undefined ? 0 : Number(m[5])
109 if (mo < 1 || mo > 12 || h > 23 || mi > 59) return null
110 const d = new Date(y, mo - 1, day, h, mi)
111 if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) return null
112 return d.toISOString()
113 }
114
115 return null
116}
117
118/** 排序:重要程度分组(重要紧急 → 重要不紧急 → 一般),组内未完成在前,截止倒序(晚的在前) */
119export function sortTodos(list: readonly Todo[]): Todo[] {
120 return [...list].sort((a, b) => {
121 const g = IMPORTANCE_ORDER[a.importance] - IMPORTANCE_ORDER[b.importance]
122 if (g !== 0) return g
123 if (a.done !== b.done) return a.done ? 1 : -1
124 return Date.parse(b.due) - Date.parse(a.due)
125 })
126}
127
128export function isOverdue(todo: Todo, nowMs: number): boolean {
129 return !todo.done && Date.parse(todo.due) <= nowMs
130}
131
132/** 展示:今天/明天 HH:mm;同年 M-D HH:mm;跨年带年份 */
133export function formatDue(iso: string, nowMs: number): string {
134 const d = new Date(iso)
135 const now = new Date(nowMs)
136 const two = (n: number) => String(n).padStart(2, '0')
137 const hm = `${two(d.getHours())}:${two(d.getMinutes())}`
138 const sameDay = (a: Date, b: Date) =>
139 a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
140 if (sameDay(d, now)) return `今天 ${hm}`
141 const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
142 if (sameDay(d, tomorrow)) return `明天 ${hm}`
143 if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}-${d.getDate()} ${hm}`
144 return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${hm}`
145}
146
147function newId(): string {
148 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
149}
150
151function blankForm(): TodoForm {
152 return { title: '', notes: '', due: '', tags: '', importance: 'general' }
153}
154
155function truncate(text: string, max: number): string {
156 const chars = Array.from(text)
157 return chars.length <= max ? text : `${chars.slice(0, max).join('')}…`
158}
159
160/** hydrateTodos 的最小依赖集,便于单测 */
161export type HydrateDeps = {
162 wasHydrated(): Promise<boolean>
163 markHydrated(): Promise<unknown>
164 readStoredTodos(): Promise<unknown>
165 writeTodos(list: Todo[]): Promise<unknown>
166}
167
168/**
169 * 每个 session 从 $.store 恢复一次 todos。
170 * 两个前提:atom 未写入时读到的是初始值(永非 undefined),不能靠值判断;
171 * /clear 后进程续用新 session id 且不再触发 session.start,恢复点须含渲染路径。
172 */
173export async function hydrateTodos(deps: HydrateDeps): Promise<void> {
174 if (await deps.wasHydrated()) return
175 const stored = await deps.readStoredTodos()
176 await deps.writeTodos(Array.isArray(stored) ? (stored as Todo[]) : [])
177 await deps.markHydrated()
178}
179
180/** 接线版:永不 reject,session.start 可 await,渲染路径可 fire-and-forget */
181const hydrate = ($: EngineInterface): Promise<void> =>
182 hydrateTodos({
183 wasHydrated: () => read($, hydrated),
184 markHydrated: () => $.state.set(hydratedRef, true),
185 readStoredTodos: () => $.store.get('todos'),
186 writeTodos: list => $.state.set(todosRef, list),
187 }).catch(() => {})
188
189// ---------- hooks ----------
190export const register: Register = on => {
191 on('session.start', async ($, e, next) => {
192 await $.command.register({
193 name: 'todo',
194 description: '待办看板:/todo 打开,/todo <标题> 快速添加',
195 })
196 await hydrate($)
197 return next(e)
198 })
199
200 // /clear 后进程换新 session id、不再触发 session.start,但 classic.SessionStart
201 // 会以 source='clear' 触发;渲染期禁止写 state,恢复必须挂在事件上
202 on('classic.SessionStart', async ($, e, next) => {
203 await hydrate($)
204 return next(e)
205 })
206
207 on('command.run', { command: 'todo' }, async ($, e) => {
208 await hydrate($)
209 const args = e.args.trim()
210 if (args !== '') {
211 const now = new Date(await $.clock.now())
212 const todo: Todo = {
213 id: newId(),
214 title: truncate(args, 120),
215 notes: null,
216 due: defaultDue(now),
217 tags: [],
218 importance: 'general',
219 done: false,
220 createdAt: now.toISOString(),
221 doneAt: null,
222 }
223 const list = await update($, todos, ts => [todo, ...ts])
224 await $.store.set('todos', list)
225 $.ui.toast(`已添加待办:${todo.title}`)
226 }
227 void $.ui.open({ id: PANE, title: '待办' })
228 return { text: args === '' ? '待办看板已打开。' : `已添加待办:${truncate(args, 120)}` }
229 })
230
231 // 提交(而非应用)后,若提交内容含被应用的待办标题,弹窗询问是否标记完成
232 on('prompt.submit', async ($, e, next) => {
233 const entered = await next(e)
234 const appliedTodo = await read($, applied)
235 if (appliedTodo != null) {
236 await $.state.set(appliedRef, null)
237 if (e.text.includes(appliedTodo.title)) {
238 await $.state.set(askRef, appliedTodo)
239 void $.ui.open({ id: ASK, title: '待办完成确认', focus: true, closeOnEscape: true, rows: 5 })
240 }
241 }
242 return entered
243 })
244
245 // Esc 关闭确认框时清掉询问状态
246 on('ui.close', { id: ASK }, async ($, e, next) => {
247 await $.state.set(askRef, null)
248 return next(e)
249 })
250
251 // ---------- 输入框上方摘要 band ----------
252 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
253 const list = (await read($, todos)) ?? []
254 const openItems = list.filter(t => !t.done)
255 if (openItems.length === 0 || e.props.hasSurvey) return next(e)
256
257 const nowMs = await $.clock.now()
258 const urgentCount = openItems.filter(t => t.importance === 'urgent-important').length
259 const sorted = [...openItems].sort((a, b) => Date.parse(a.due) - Date.parse(b.due))
260 const soonest = sorted[0]
261 if (!soonest) return next(e)
262
263 const { Box, Button, Text } = $.ui.resolve(e)
264 const dueText = isOverdue(soonest, nowMs) ? `⚠${formatDue(soonest.due, nowMs)}` : formatDue(soonest.due, nowMs)
265
266 return (
267 <Box>
268 <Text dimColor>
269 📋 待办 {openItems.length}
270 {urgentCount > 0 ? ` · 🔴${urgentCount}` : ''} · ⏰ {dueText} {truncate(soonest.title, 16)}{' '}
271 </Text>
272 <Button
273 key="open-todo"
274 label="看板"
275 hotkey="o"
276 onPress={() => void $.ui.open({ id: PANE, title: '待办', focus: true })}
277 />
278 </Box>
279 )
280 })
281
282 // ---------- 侧边栏看板 ----------
283 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
284 // mobile 元素表暂无 Input/Select,条件解构并在表单视图降级
285 const elements = $.ui.resolve(e)
286 const { Box, Button, Text } = elements
287 const Input = 'Input' in elements ? elements.Input : undefined
288 const Select = 'Select' in elements ? elements.Select : undefined
289 const nowMs = await $.clock.now()
290 const now = new Date(nowMs)
291 const list = (await read($, todos)) ?? []
292 const v = (await read($, view)) ?? 'list'
293 const hideDoneValue = (await read($, hideDone)) ?? false
294 const detailIdValue = await read($, detailId)
295 const formValue = (await read($, form)) ?? blankForm()
296 const formModeValue = (await read($, formMode)) ?? 'new'
297 const formTargetIdValue = await read($, formTargetId)
298 const formErrorValue = await read($, formError)
299 const detail = detailIdValue != null ? list.find(t => t.id === detailIdValue) : undefined
300 const target = formTargetIdValue != null ? list.find(t => t.id === formTargetIdValue) : undefined
301
302 // ----- actions -----
303 const openFocused = () => void $.ui.open({ id: PANE, title: '待办', focus: true })
304 const goList = async () => {
305 await $.state.set(viewRef, 'list')
306 }
307 const openDetail = async (id: string) => {
308 await $.state.set(detailIdRef, id)
309 await $.state.set(viewRef, 'detail')
310 }
311 const startNew = async () => {
312 await $.state.set(formRef, blankForm())
313 await $.state.set(formModeRef, 'new')
314 await $.state.set(formTargetIdRef, null)
315 await $.state.set(formErrorRef, null)
316 await $.state.set(viewRef, 'form')
317 openFocused()
318 }
319 const startEdit = async (id: string) => {
320 const t = list.find(x => x.id === id)
321 if (!t) return
322 await $.state.set(formRef, {
323 title: t.title,
324 notes: t.notes ?? '',
325 due: '',
326 tags: t.tags.join(', '),
327 importance: t.importance,
328 })
329 await $.state.set(formModeRef, 'edit')
330 await $.state.set(formTargetIdRef, id)
331 await $.state.set(formErrorRef, null)
332 await $.state.set(viewRef, 'form')
333 openFocused()
334 }
335 const setForm = (patch: Partial<TodoForm>) => {
336 void update($, form, f => ({ ...(f ?? blankForm()), ...patch }))
337 }
338 const toggle = async (id: string) => {
339 const doneAt = new Date(await $.clock.now()).toISOString()
340 const next = await update($, todos, ts =>
341 ts.map(t => (t.id === id ? { ...t, done: !t.done, doneAt: !t.done ? doneAt : null } : t)),
342 )
343 await $.store.set('todos', next)
344 }
345 const remove = async (id: string) => {
346 const next = await update($, todos, ts => ts.filter(t => t.id !== id))
347 await $.store.set('todos', next)
348 $.ui.toast('已删除待办')
349 await goList()
350 }
351 const saveForm = async () => {
352 const f = (await read($, form)) ?? blankForm()
353 const mode = (await read($, formMode)) ?? 'new'
354 const targetId = await read($, formTargetId)
355 const editing = mode === 'edit' && targetId != null ? list.find(t => t.id === targetId) : undefined
356 const title = f.title.trim()
357 if (title === '') {
358 await $.state.set(formErrorRef, '标题必填')
359 return
360 }
361 const due =
362 f.due.trim() === '' && editing ? editing.due : parseDueInput(f.due, now)
363 if (due == null) {
364 await $.state.set(formErrorRef, '截止时间无法解析(支持 HH:mm、M-D HH:mm、YYYY-M-D HH:mm,留空用默认)')
365 return
366 }
367 const tags = f.tags
368 .split(/[,,;;\s]+/)
369 .map(s => s.trim().replace(/^#/, ''))
370 .filter(s => s !== '')
371 const notes = f.notes.trim() === '' ? null : f.notes.trim()
372 if (editing) {
373 const next = await update($, todos, ts =>
374 ts.map(t => (t.id === editing.id ? { ...t, title, notes, due, tags, importance: f.importance } : t)),
375 )
376 await $.store.set('todos', next)
377 $.ui.toast('已保存')
378 await $.state.set(formErrorRef, null)
379 await openDetail(editing.id)
380 } else {
381 const todo: Todo = {
382 id: newId(),
383 title,
384 notes,
385 due,
386 tags,
387 importance: f.importance,
388 done: false,
389 createdAt: new Date(await $.clock.now()).toISOString(),
390 doneAt: null,
391 }
392 const next = await update($, todos, ts => [todo, ...ts])
393 await $.store.set('todos', next)
394 $.ui.toast('已添加待办')
395 await $.state.set(formErrorRef, null)
396 await openDetail(todo.id)
397 }
398 }
399 const applyTodo = async (t: Todo) => {
400 const lines = [`请处理待办:${t.title}`]
401 if (t.notes != null) lines.push(`备注:${t.notes}`)
402 lines.push(`优先级:${IMPORTANCE_PLAIN[t.importance]};截止:${formatDue(t.due, nowMs)}`)
403 if (t.tags.length > 0) lines.push(`标签:${t.tags.map(x => `#${x}`).join(' ')}`)
404 const filled = await $.prompt.fill({ text: lines.join('\n'), mode: 'insert' })
405 if (filled.isFilled) {
406 await $.state.set(appliedRef, { id: t.id, title: t.title })
407 $.ui.toast('已应用到输入框;提交后可标记完成')
408 void $.ui.close({ id: PANE })
409 } else {
410 $.ui.toast(filled.refusal === 'dialog' ? '请先按 Esc 让键盘回到输入框,再应用' : '当前无法写入输入框')
411 }
412 }
413
414 // ----- form view -----
415 if (v === 'form') {
416 if (Input === undefined || Select === undefined) {
417 return (
418 <Box flexDirection="column">
419 <Text dimColor>当前界面暂不支持输入字段(mobile),请在终端或桌面端填写表单。</Text>
420 </Box>
421 )
422 }
423 const isEdit = formModeValue === 'edit' && target != null
424 return (
425 <Box flexDirection="column" gap={1}>
426 <Text bold>{isEdit ? '编辑待办' : '新增待办'}</Text>
427 <Input
428 key="form-title"
429 label="标题"
430 placeholder="必填"
431 value={formValue.title}
432 autoFocus
433 onInput={value => setForm({ title: value })}
434 onSubmit={() => void saveForm()}
435 />
436 <Input
437 key="form-notes"
438 label="备注"
439 placeholder="可留空"
440 value={formValue.notes}
441 onInput={value => setForm({ notes: value })}
442 onSubmit={() => void saveForm()}
443 />
444 <Input
445 key="form-due"
446 label="截止"
447 placeholder={
448 isEdit && target
449 ? `留空保持不变(当前 ${formatDue(target.due, nowMs)})`
450 : '留空=今天20:00(18点后为明天14:00)'
451 }
452 value={formValue.due}
453 onInput={value => setForm({ due: value })}
454 onSubmit={() => void saveForm()}
455 />
456 <Input
457 key="form-tags"
458 label="标签"
459 placeholder="逗号分隔,可留空"
460 value={formValue.tags}
461 onInput={value => setForm({ tags: value })}
462 onSubmit={() => void saveForm()}
463 />
464 <Select
465 key="form-importance"
466 label="重要程度"
467 value={formValue.importance}
468 options={IMPORTANCES.map(imp => ({ value: imp, label: IMPORTANCE_PLAIN[imp] }))}
469 onSelect={value => setForm({ importance: value as Importance })}
470 />
471 {formErrorValue != null && <Text color="error">{formErrorValue}</Text>}
472 <Box gap={1}>
473 <Button key="form-save" label="保存" hotkey="s" variant="primary" onPress={() => void saveForm()} />
474 <Button
475 key="form-cancel"
476 label="取消"
477 onPress={() => void $.state.set(viewRef, isEdit ? 'detail' : 'list')}
478 />
479 </Box>
480 </Box>
481 )
482 }
483
484 // ----- detail view -----
485 if (v === 'detail' && detail) {
486 const t = detail
487 return (
488 <Box flexDirection="column" gap={1}>
489 <Text bold>{t.title}</Text>
490 <Text dimColor>状态:{t.done ? '✓ 已完成' : isOverdue(t, nowMs) ? '⚠ 已逾期' : '进行中'}</Text>
491 <Text>重要程度:{IMPORTANCE_LABEL[t.importance]}</Text>
492 <Text>截止:{formatDue(t.due, nowMs)}{isOverdue(t, nowMs) ? '(已逾期)' : ''}</Text>
493 <Text>标签:{t.tags.length > 0 ? t.tags.map(x => `#${x}`).join(' ') : '—'}</Text>
494 <Text>备注:{t.notes ?? '—'}</Text>
495 <Box gap={1}>
496 <Button key="detail-apply" label="应用" hotkey="a" variant="primary" onPress={() => void applyTodo(t)} />
497 <Button key="detail-edit" label="编辑" hotkey="e" onPress={() => void startEdit(t.id)} />
498 <Button key="detail-toggle" label={t.done ? '重开' : '完成'} onPress={() => void toggle(t.id)} />
499 <Button key="detail-del" label="删除" onPress={() => void remove(t.id)} />
500 <Button key="detail-back" label="返回" onPress={() => void goList()} />
501 </Box>
502 </Box>
503 )
504 }
505
506 // ----- list view (default) -----
507 const shown = sortTodos(hideDoneValue ? list.filter(t => !t.done) : list)
508 const groups = IMPORTANCES.map(imp => ({ imp, items: shown.filter(t => t.importance === imp) })).filter(
509 g => g.items.length > 0,
510 )
511 const openCount = list.filter(t => !t.done).length
512 return (
513 <Box flexDirection="column">
514 <Box justifyContent="space-between" marginBottom={1}>
515 <Text bold>📋 待办(未完成 {openCount})</Text>
516 <Box gap={1}>
517 <Button key="list-new" label="新增" hotkey="n" variant="primary" onPress={() => void startNew()} />
518 <Button
519 key="list-hide"
520 label={hideDoneValue ? '显示已完成' : '隐藏已完成'}
521 onPress={() => void update($, hideDone, h => !h)}
522 />
523 <Button key="list-close" label="✕" onPress={() => void $.ui.close({ id: PANE })} />
524 </Box>
525 </Box>
526 {groups.length === 0 && (
527 <Text dimColor>暂无待办。按 n 新增,或在输入框输入 /todo 标题 快速添加。</Text>
528 )}
529 {groups.map(({ imp, items }) => (
530 <Box key={imp} flexDirection="column">
531 <Text
532 color={imp === 'urgent-important' ? 'error' : imp === 'important' ? 'warning' : undefined}
533 dimColor={imp === 'general'}
534 >
535 {IMPORTANCE_LABEL[imp]}({items.length})
536 </Text>
537 {items.map(t => (
538 <Box key={t.id}>
539 <Button key={`done-${t.id}`} plain label={t.done ? '☑' : '☐'} onPress={() => void toggle(t.id)} />
540 <Text dimColor={t.done} color={!t.done && isOverdue(t, nowMs) ? 'error' : undefined}>
541 {formatDue(t.due, nowMs)}{' '}
542 </Text>
543 <Button
544 key={`open-${t.id}`}
545 plain
546 label={t.title}
547 dimColor={t.done}
548 onPress={() => void openDetail(t.id)}
549 />
550 {t.tags.length > 0 && <Text dimColor> {t.tags.map(x => `#${x}`).join(' ')}</Text>}
551 </Box>
552 ))}
553 </Box>
554 ))}
555 </Box>
556 )
557 })
558
559 // ---------- 提交后确认对话框 ----------
560 on('ui.render', { component: 'Pane', requestId: ASK }, async ($, e) => {
561 const { Box, Button, Text } = $.ui.resolve(e)
562 const asking = (await read($, ask)) ?? null
563 if (asking == null) {
564 return (
565 <Box flexDirection="column">
566 <Text dimColor>已处理。</Text>
567 </Box>
568 )
569 }
570 const markDone = async () => {
571 const doneAt = new Date(await $.clock.now()).toISOString()
572 const next = await update($, todos, ts =>
573 ts.map(t => (t.id === asking.id ? { ...t, done: true, doneAt } : t)),
574 )
575 await $.store.set('todos', next)
576 await $.state.set(askRef, null)
577 $.ui.toast(`✓ 已完成:${asking.title}`)
578 void $.ui.close({ id: ASK })
579 }
580 const skip = async () => {
581 await $.state.set(askRef, null)
582 void $.ui.close({ id: ASK })
583 }
584 return (
585 <Box flexDirection="column" gap={1}>
586 <Text>已提交「{truncate(asking.title, 40)}」相关任务。标记该待办为完成?</Text>
587 <Box gap={1}>
588 <Button key="ask-yes" label="标记完成" hotkey="y" variant="primary" onPress={() => void markDone()} />
589 <Button key="ask-no" label="跳过" hotkey="n" onPress={() => void skip()} />
590 </Box>
591 </Box>
592 )
593 })
594}
595types/index.d.ts 48 lines1export type Importance = 'urgent-important' | 'important' | 'general'
2
3export type Todo = {
4 id: string
5 title: string
6 /** 备注,可空 */
7 notes: string | null
8 /** 截止时间,ISO 字符串 */
9 due: string
10 tags: string[]
11 importance: Importance
12 done: boolean
13 createdAt: string
14 doneAt: string | null
15}
16
17/** 表单编辑态(截止/标签为原始输入文本) */
18export type TodoForm = {
19 title: string
20 notes: string
21 due: string
22 tags: string
23 importance: Importance
24}
25
26export type TodoApplied = { id: string; title: string } | null
27
28declare module 'claude-code' {
29 interface PluginState {
30 todo: {
31 todos: Todo[]
32 view: 'list' | 'detail' | 'form'
33 formMode: 'new' | 'edit'
34 formTargetId: string | null
35 form: TodoForm
36 formError: string | null
37 hideDone: boolean
38 detailId: string | null
39 /** 已应用到输入框、等待提交的待办 */
40 applied: TodoApplied
41 /** 提交后正在询问是否标记完成的待办 */
42 ask: TodoApplied
43 /** 本 session 是否已从 $.store 恢复过 todos */
44 hydrated: boolean
45 }
46 }
47}
48