SLOPSHOPPER

todo

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

newpanebandcommandtoastprompt
v0.1.0no licenseupdated 2026-10-08Ivan97/cc-todo-mods/mods/todo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo
│ ┃ 待办 ✕ › fix the failing auth test and add an audit log call │ ┃ 📋 待办(未完成 0)[ 新增 ] [ 隐藏已完成 ] [ │ ┃ ⏺ Read(src/auth.ts) │ ┃ 暂无待办。按 n 新增,或在输入框输入 /todo ⎿ Read 6 lines │ ┃ 标题 快速添加。 ⏺ Update(src/auth.ts) │ ⎿ 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 │ ⎿ todo: 待办看板已打开。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 待办
📋 待办(未完成 0) [ 新增 ] [ 隐藏已完成 ] [ ✕ ] 暂无待办。按 n 新增,或在输入框输入 /todo 标题 快速添加。
Pane · todo-ask
已处理。
README

cc-todo-mods

Claude Code 的待办(todo)mod 合集。首个 mod:todo —— 一个活在 Claude Code 界面里的待办看板。

功能

  • 常驻摘要条:有未完成待办时,输入框上方自动显示一行摘要(未完成数 · 重要紧急数 · 最近截止项),按 o 或点击「看板」打开侧边栏
  • 侧边栏看板(/todo 打开):
  • 增删改查 + 详情查看
  • 字段:标题(必填)、备注(可空)、截止时间、标签、重要程度(重要紧急 / 重要不紧急 / 一般事件)、完成状态
  • 默认截止:当天 20:00;过了当天 18:00 则自动取次日 14:00(也支持 HH:mm、M-D HH:mm、YYYY-M-D HH:mm 手动输入)
  • 列表按重要程度分组(🔴 重要紧急 → 🟡 重要不紧急 → ⚪ 一般事件),组内未完成在前、截止倒序
  • 已逾期项红色高亮,已完成项可一键隐藏/显示
  • 快速添加:输入框直接 /todo 买牛奶 即可新增
  • 应用到输入框:详情页按 a,把该待办整理成一段提示词填入输入框(可继续编辑)
  • 提交后确认:应用过的待办在你提交提示词后(而非应用时)弹出确认框,一键标记完成(y)或跳过(n)
  • 持久化:待办跨会话保存(Claude Code 插件 store)

安装

三选一:

方式一:从 GitHub 安装(推荐)

在 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(开发调试)

# 从本仓库文件夹安装(文件夹型 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

License

MIT

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