A Todoist-style task manager for Claude Code: a pane, a /todo command and tools Claude can call

A Todoist-style task manager that lives inside Claude Code: a side pane, a /todo slash command, and tools Claude itself can call, so you can say "add a task to call Marco tomorrow at 3pm" in plain language.
Tasks are stored locally in one file, ~/.claude/todo-list.json, shared by every session on your machine, however the plugin was loaded. Nothing is sent anywhere. On its first run the plugin carries over a list kept by an older version in ~/.claude/plugins/store/.
Not affiliated with or endorsed by Todoist / Doist. Built and tested with Claude Code 2.1.288.
Pay rent tomorrow at 9:30 p1 #Home @billstoday, tomorrow, mon, in 3 days, next week, 2026-10-20, 20/10 (day/month); Italian words work too (oggi, domani, lun, alle 15:00, ogni settimana)at 14:30, 3pmevery day, every week, every month, every weekday, every monevery month on the 31st falls on the last day of shorter months/todo edit↑ / ↓ / top (a terminal has no real drag and drop)▶ run here sends the task to the current session as a prompt⧉ new session opens a new Terminal window with claude started on the task (macOS)todo N todayAs a plugin from this repository:
claude plugin marketplace add mikcop/claude-code-todo-list
claude plugin install todo-list@claude-code-todo-list
Or load it for one session, without installing:
git clone https://github.com/mikcop/claude-code-todo-list
claude --plugin-dir ./claude-code-todo-list
| Command | What it does | ||
|---|---|---|---|
/todo | open the pane | ||
/todo <text> or /todo add <text> | add a task (quick-add syntax) | ||
/todo done <id> | complete a task (reopens it if done; a recurring one moves to its next date) | ||
/todo rm <id> | delete a task and its subtasks | ||
/todo edit <id> <text> | rewrite a task from a quick-add line | ||
| `/todo move <id> up\ | down\ | top` | reorder a task inside its project |
/todo list [view] | print tasks with ids; view is inbox, today, upcoming, all or a project name | ||
/todo project <name> | create a project | ||
/todo clear | delete completed tasks |
In the pane, click ○ to complete a task and ⋯ to open its actions (▶ run here, ⧉ new session, edit, +subtask, priority, ↑, ↓, top, delete).
Claude can also call todo_list, todo_add, todo_update, todo_complete and todo_delete (listed as mcp__todo-list__…), so natural-language requests work in chat.
⧉ new session needs macOS and the claude command on the PATH of Terminal; macOS may ask for permission to control Terminal the first time. The new session starts in the current session's working directory and does not have this plugin's tools unless the plugin is installed./todo command with a known verb and the wrong arguments prints its usage; any other text becomes a new task.The plugin is a hooks module: hooks/register.tsx. Its types live in types/index.d.ts.
claude plugin validate .
claude plugin test .
hooks/register.tsx 877 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Task } from '../types'
5
6const PANE = 'todo-list'
7const items = atom({ plugin: 'todo-list', key: 'items' } as const, [])
8const projects = atom({ plugin: 'todo-list', key: 'projects' } as const, [])
9const selected = atom({ plugin: 'todo-list', key: 'selected' } as const, 0)
10const editing = atom({ plugin: 'todo-list', key: 'editing' } as const, 0)
11const view = atom({ plugin: 'todo-list', key: 'view' } as const, 'inbox')
12const draft = atom({ plugin: 'todo-list', key: 'draft' } as const, '')
13const parent = atom({ plugin: 'todo-list', key: 'parent' } as const, 0)
14const showDone = atom({ plugin: 'todo-list', key: 'showDone' } as const, false)
15
16// reminders already shown this load
17const fired = new Set<string>()
18
19const PRI_COLOR: Record<number, string> = { 1: 'red', 2: 'yellow', 3: 'blue' }
20const DN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
21const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
22const DOW: Record<string, number> = {
23 mon: 1, lun: 1, tue: 2, mar: 2, wed: 3, mer: 3, thu: 4, gio: 4,
24 fri: 5, ven: 5, sat: 6, sab: 6, sun: 0, dom: 0,
25}
26const DAY =
27 'mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?|' +
28 'lun(?:ed[iì])?|mar(?:ted[iì])?|mer(?:coled[iì])?|gio(?:ved[iì])?|ven(?:erd[iì])?|sab(?:ato)?|dom(?:enica)?'
29
30// ---------- dates (pure) ----------
31
32const pad = (n: number) => String(n).padStart(2, '0')
33const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
34
35function fromYmd(s: string) {
36 const [y, m, d] = s.split('-').map(Number)
37 return new Date(y as number, (m as number) - 1, d as number, 12)
38}
39
40function addDays(s: string, n: number) {
41 const d = fromYmd(s)
42 d.setDate(d.getDate() + n)
43 return ymd(d)
44}
45
46function nextDow(from: string, dow: number, inclusive: boolean) {
47 let diff = (dow - fromYmd(from).getDay() + 7) % 7
48 if (diff === 0 && !inclusive) diff = 7
49 return addDays(from, diff)
50}
51
52function nextWeekday(from: string, inclusive: boolean) {
53 let s = inclusive ? from : addDays(from, 1)
54 while ([0, 6].includes(fromYmd(s).getDay())) s = addDays(s, 1)
55 return s
56}
57
58// a real calendar day: no 31/02, no month 13
59function isDate(s: string) {
60 const [y, m, d] = s.split('-').map(Number)
61 const x = new Date(y as number, (m as number) - 1, d as number, 12)
62 return x.getFullYear() === y && x.getMonth() === (m as number) - 1 && x.getDate() === d
63}
64
65// the same day next month, or its last day when the month is shorter (31 Jan -> 28 Feb)
66function addMonth(s: string) {
67 const d = fromYmd(s)
68 const day = d.getDate()
69 d.setDate(1)
70 d.setMonth(d.getMonth() + 1)
71 d.setDate(Math.min(day, new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()))
72 return ymd(d)
73}
74
75function nextOnce(due: string, recur: string) {
76 if (recur === 'day') return addDays(due, 1)
77 if (recur === 'week') return addDays(due, 7)
78 if (recur === 'weekday') return nextWeekday(due, false)
79 if (recur.startsWith('dow:')) return nextDow(due, Number(recur.slice(4)), false)
80 return addMonth(due)
81}
82
83// the next occurrence after today: an overdue recurring task skips the dates it missed
84function nextDue(due: string, recur: string, today: string) {
85 let s = nextOnce(due, recur)
86 while (s <= today) s = nextOnce(s, recur)
87 return s
88}
89
90function dueDay(due: string, today: string) {
91 if (due === today) return 'Today'
92 if (due === addDays(today, 1)) return 'Tomorrow'
93 const d = fromYmd(due)
94 const ahead = Math.round((d.getTime() - fromYmd(today).getTime()) / 86400000)
95 if (ahead > 1 && ahead < 7) return DN[d.getDay()] as string
96 return `${DN[d.getDay()]} ${d.getDate()} ${MN[d.getMonth()]}`
97}
98
99const dueLabel = (due: string, today: string, time: string) =>
100 dueDay(due, today) + (time ? ` ${time}` : '')
101
102const isLate = (t: Task, today: string, hm: string) =>
103 !t.isDone && t.due !== '' && (t.due < today || (t.due === today && t.time !== '' && t.time < hm))
104
105function recurText(recur: string) {
106 if (recur.startsWith('dow:')) return `every ${(DN[Number(recur.slice(4))] as string).toLowerCase()}`
107 return `every ${recur}`
108}
109
110// a task as the quick-add line that would recreate it: what the edit field shows
111function toQuick(t: Task) {
112 return [
113 t.text,
114 t.priority < 4 ? `p${t.priority}` : '',
115 t.recur ? recurText(t.recur) : '',
116 t.due,
117 t.time ? `at ${t.time}` : '',
118 t.project !== 'Inbox' ? `#${t.project.replace(/ /g, '_')}` : '',
119 ...t.labels.map(l => `@${l}`),
120 ]
121 .filter(Boolean)
122 .join(' ')
123}
124
125// ---------- quick add (pure) ----------
126
127// a day/month date in the line (20/10, 20/10/27) as [ymd], or null when there is none or it is not a real day
128function dayMonth(s: string, today: string): [string] | null {
129 const m = s.match(/\s(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?(?=\s)/)
130 if (!m) return null
131 const y = m[3] ? (m[3].length === 2 ? 2000 + Number(m[3]) : Number(m[3])) : fromYmd(today).getFullYear()
132 const at = (yy: number) => `${yy}-${pad(Number(m[2]))}-${pad(Number(m[1]))}`
133 let due = at(y)
134 if (!m[3] && due < today) due = at(y + 1)
135 return isDate(due) ? [due] : null
136}
137
138function parseQuick(input: string, today: string) {
139 let s = ` ${input} `
140 const take = (re: RegExp) => {
141 const m = s.match(re)
142 if (m) s = s.replace(re, ' ')
143 return m
144 }
145
146 let priority: 1 | 2 | 3 | 4 = 4
147 const pm = take(/\sp([1-4])(?=\s)/i)
148 if (pm) priority = Number(pm[1]) as 1 | 2 | 3 | 4
149
150 let project = ''
151 const hm = take(/\s#(\S+)(?=\s)/)
152 if (hm) project = (hm[1] as string).replace(/_/g, ' ')
153
154 const labels: string[] = []
155 for (let m = take(/\s@(\S+)(?=\s)/); m; m = take(/\s@(\S+)(?=\s)/)) labels.push(m[1] as string)
156
157 let time = ''
158 const timeRes: [RegExp, number, number, number][] = [
159 [/\s(?:at|alle|ore)\s+(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?(?=\s)/i, 1, 2, 3],
160 [/\s(\d{1,2}):(\d{2})\s*(am|pm)?(?=\s)/i, 1, 2, 3],
161 [/\s(\d{1,2})\s*(am|pm)(?=\s)/i, 1, 0, 2],
162 ]
163 for (const [re, hi, mi, ai] of timeRes) {
164 const m = s.match(re)
165 if (!m) continue
166 let h = Number(m[hi])
167 const mm = mi ? Number(m[mi] ?? 0) : 0
168 const ap = (m[ai] ?? '').toLowerCase()
169 if (ap === 'pm' && h < 12) h += 12
170 if (ap === 'am' && h === 12) h = 0
171 if (h > 23 || mm > 59) continue
172 time = `${pad(h)}:${pad(mm)}`
173 s = s.replace(re, ' ')
174 break
175 }
176
177 let due = ''
178 let recur = ''
179 const rm = take(
180 new RegExp(
181 `\\s(?:every|ogni)\\s+(?:(giorno feriale|weekdays?|day|giorno|week|settimana|month|mese)|(${DAY}))(?=\\s)`,
182 'i',
183 ),
184 )
185 if (rm) {
186 if (rm[2]) {
187 const dow = DOW[rm[2].slice(0, 3).toLowerCase()] as number
188 recur = `dow:${dow}`
189 due = nextDow(today, dow, true)
190 } else {
191 const k = (rm[1] as string).toLowerCase()
192 recur = /feriale|weekday/.test(k) ? 'weekday' : /week|settimana/.test(k) ? 'week' : /month|mese/.test(k) ? 'month' : 'day'
193 due = recur === 'weekday' ? nextWeekday(today, true) : today
194 }
195 }
196 {
197 let m: RegExpMatchArray | null
198 let dm: [string] | null
199 if (take(/\s(?:today|oggi)(?=\s)/i)) due = today
200 else if (take(/\sdopodomani(?=\s)/i)) due = addDays(today, 2)
201 else if (take(/\s(?:tomorrow|domani)(?=\s)/i)) due = addDays(today, 1)
202 else if (take(/\s(?:next week|prossima settimana)(?=\s)/i)) due = nextDow(today, 1, false)
203 else if ((m = take(/\sin\s+(\d+)\s+(days?|giorni|giorno|weeks?|settimane|settimana)(?=\s)/i)))
204 due = addDays(today, Number(m[1]) * (/^(w|s)/i.test(m[2] as string) ? 7 : 1))
205 else if ((m = s.match(/\s(\d{4}-\d{2}-\d{2})(?=\s)/)) && isDate(m[1] as string)) {
206 take(/\s(\d{4}-\d{2}-\d{2})(?=\s)/)
207 due = m[1] as string
208 } else if ((dm = dayMonth(s, today))) {
209 take(/\s(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?(?=\s)/)
210 due = dm[0]
211 } else if ((m = take(new RegExp(`\\s(${DAY})(?=\\s)`, 'i'))))
212 due = nextDow(today, DOW[(m[1] as string).slice(0, 3).toLowerCase()] as number, false)
213 }
214
215 if (time && !due) due = today
216
217 return { text: s.replace(/\s+/g, ' ').trim(), priority, project, labels, due, time, recur }
218}
219
220// ---------- views (pure) ----------
221
222type Ctx = { today: string; hm: string }
223const isFlatView = (w: string) => w === 'today' || w === 'upcoming'
224
225// the rows of a view: inbox/project keep the manual order, today/upcoming sort by date, time, priority
226function select(list: Task[], which: string, c: Ctx, withDone: boolean) {
227 const w = which.toLowerCase()
228 let out: Task[]
229 if (w === 'inbox') out = list.filter(t => t.project === 'Inbox')
230 else if (w === 'today') out = list.filter(t => !t.isDone && t.due !== '' && t.due <= c.today)
231 else if (w === 'upcoming') out = list.filter(t => !t.isDone && t.due !== '')
232 else if (w === '' || w === 'all') out = list
233 else {
234 const name = w.startsWith('p:') ? w.slice(2) : w
235 out = list.filter(t => t.project.toLowerCase() === name)
236 }
237 if (!withDone || w === 'today' || w === 'upcoming') out = out.filter(t => !t.isDone)
238 if (!isFlatView(w)) return out
239 return [...out].sort(
240 (a, b) =>
241 (a.due || '9999').localeCompare(b.due || '9999') ||
242 (a.time || '99:99').localeCompare(b.time || '99:99') ||
243 a.priority - b.priority,
244 )
245}
246
247// a name a person or the model typed -> a view id
248function resolveView(which: string, projs: string[]) {
249 const w = which.trim()
250 const lw = w.toLowerCase()
251 if (['', 'all', 'inbox', 'today', 'upcoming'].includes(lw)) return lw || 'all'
252 if (lw.startsWith('p:')) return w
253 return `p:${projs.find(x => x.toLowerCase() === lw) ?? w}`
254}
255
256function descendants(list: Task[], id: number): number[] {
257 const kids = list.filter(t => t.parent === id).map(t => t.id)
258 return [...kids, ...kids.flatMap(k => descendants(list, k))]
259}
260
261function norm(t: any): Task {
262 return {
263 id: Number(t.id),
264 text: String(t.text ?? ''),
265 isDone: !!t.isDone,
266 priority: [1, 2, 3, 4].includes(t.priority) ? t.priority : 4,
267 project: String(t.project ?? 'Inbox'),
268 labels: Array.isArray(t.labels) ? t.labels.map(String) : [],
269 due: String(t.due ?? ''),
270 time: String(t.time ?? ''),
271 recur: String(t.recur ?? ''),
272 parent: Number(t.parent ?? 0),
273 }
274}
275
276function line(t: Task) {
277 return (
278 `${t.id} [${t.isDone ? 'x' : ' '}] p${t.priority} ${t.text}` +
279 (t.due ? ` | due ${t.due}${t.time ? ` ${t.time}` : ''}` : '') +
280 (t.recur ? ` | repeats ${t.recur}` : '') +
281 ` | #${t.project}` +
282 t.labels.map(l => ` @${l}`).join('') +
283 (t.parent ? ` | subtask of ${t.parent}` : '')
284 )
285}
286
287// ---------- operations (take $) ----------
288
289async function todayStr($: any) {
290 return ymd(new Date(await $.clock.now()))
291}
292
293async function nowCtx($: any): Promise<Ctx> {
294 const d = new Date(await $.clock.now())
295 return { today: ymd(d), hm: `${pad(d.getHours())}:${pad(d.getMinutes())}` }
296}
297
298async function showStatus($: any, list: Task[]) {
299 const c = await nowCtx($)
300 const n = list.filter(t => !t.isDone && t.due !== '' && t.due <= c.today).length
301 $.ui.status(n === 0 ? undefined : `todo ${n} today`)
302}
303
304// The list lives in one file every session reads and writes: the plugin's own
305// $.store is kept per load source, so two sessions could each see a different list.
306let lastText = ''
307
308async function sharedPath($: any) {
309 return `${(await $.env.get('HOME')) ?? ''}/.claude/todo-list.json`
310}
311
312async function persist($: any) {
313 const text = JSON.stringify({ items: await read($, items), projects: await read($, projects) }, null, 2)
314 if (text === lastText) return
315 lastText = text
316 await $.fs.write(await sharedPath($), text)
317}
318
319// takes in what other sessions saved since this one last looked, so a change here does not undo theirs
320async function sync($: any) {
321 let text: string
322 try {
323 text = (await $.fs.read(await sharedPath($))) as string
324 } catch {
325 return
326 }
327 if (text === lastText) return
328 let data: any
329 try {
330 data = JSON.parse(text)
331 } catch {
332 return
333 }
334 lastText = text
335 if (Array.isArray(data.items)) {
336 const next = data.items.map(norm)
337 if (JSON.stringify(next) !== JSON.stringify(await read($, items))) await update($, items, () => next)
338 }
339 if (Array.isArray(data.projects)) {
340 const next = data.projects.map(String)
341 if (JSON.stringify(next) !== JSON.stringify(await read($, projects))) await update($, projects, () => next)
342 }
343}
344
345async function save($: any, next: Task[]) {
346 await update($, items, () => next)
347 await persist($)
348 await showStatus($, next)
349}
350
351async function saveProjects($: any, next: string[]) {
352 await update($, projects, () => next)
353 await persist($)
354}
355
356async function addTask(
357 $: any,
358 input: string,
359 ctx: { project?: string; due?: string; parent?: number },
360): Promise<Task | null> {
361 await sync($)
362 const today = await todayStr($)
363 const p = parseQuick(input, today)
364 if (!p.text) return null
365 const list = await read($, items)
366 const projs = await read($, projects)
367 const par = ctx.parent ? list.find(t => t.id === ctx.parent) : undefined
368
369 let project = p.project || ctx.project || 'Inbox'
370 if (par) project = par.project
371 const known = ['Inbox', ...projs].find(x => x.toLowerCase() === project.toLowerCase())
372 if (known) project = known
373 else await saveProjects($, [...projs, project])
374
375 const task: Task = {
376 id: list.reduce((m: number, t: Task) => Math.max(m, t.id), 0) + 1,
377 text: p.text,
378 isDone: false,
379 priority: p.priority,
380 project,
381 labels: p.labels,
382 due: p.due || ctx.due || '',
383 time: p.time,
384 recur: p.recur,
385 parent: par ? par.id : 0,
386 }
387 await save($, [...list, task])
388 return task
389}
390
391async function addFromPane($: any, input: string) {
392 const v = await read($, view)
393 const today = await todayStr($)
394 const ctx = {
395 project: v.startsWith('p:') ? v.slice(2) : undefined,
396 due: v === 'today' ? today : undefined,
397 parent: await read($, parent),
398 }
399 await addTask($, input, ctx)
400 await update($, parent, () => 0)
401}
402
403async function completeTask($: any, id: number): Promise<string> {
404 await sync($)
405 const list = await read($, items)
406 const t = list.find((x: Task) => x.id === id)
407 if (!t) return `No task ${id}`
408 if (!t.isDone && t.recur && t.due) {
409 const due = nextDue(t.due, t.recur, await todayStr($))
410 await save($, list.map((x: Task) => (x.id === id ? { ...x, due } : x)))
411 return `Done: ${t.text} (next: ${due})`
412 }
413 const ids = new Set([id, ...(t.isDone ? [] : descendants(list, id))])
414 await save($, list.map((x: Task) => (ids.has(x.id) ? { ...x, isDone: !t.isDone } : x)))
415 return `${t.isDone ? 'Reopened' : 'Completed'}: ${t.text}`
416}
417
418async function deleteTask($: any, id: number): Promise<string> {
419 await sync($)
420 const list = await read($, items)
421 const t = list.find((x: Task) => x.id === id)
422 if (!t) return `No task ${id}`
423 const gone = new Set([id, ...descendants(list, id)])
424 await update($, selected, (s: number) => (gone.has(s) ? 0 : s))
425 await update($, parent, (s: number) => (gone.has(s) ? 0 : s))
426 if (gone.has(await read($, editing))) await cancelInput($)
427 await save($, list.filter((x: Task) => !gone.has(x.id)))
428 return `Deleted: ${t.text}`
429}
430
431async function cyclePriority($: any, id: number) {
432 await sync($)
433 const list = await read($, items)
434 await save(
435 $,
436 list.map((x: Task) => (x.id === id ? { ...x, priority: (x.priority === 1 ? 4 : x.priority - 1) as Task['priority'] } : x)),
437 )
438}
439
440async function deleteProject($: any, name: string) {
441 await sync($)
442 const list = await read($, items)
443 await save($, list.map((x: Task) => (x.project === name ? { ...x, project: 'Inbox' } : x)))
444 await saveProjects($, (await read($, projects)).filter((p: string) => p !== name))
445 await update($, view, () => 'inbox')
446}
447
448async function listText($: any, which: string) {
449 await sync($)
450 const c = await nowCtx($)
451 const id = resolveView(which, await read($, projects))
452 try {
453 const rows = select(await read($, items), id, c, false)
454 return `Now: ${c.today} ${c.hm}.\n` + (rows.length ? rows.map(line).join('\n') : 'No tasks.')
455 } catch (err) {
456 return String((err as Error).message)
457 }
458}
459
460async function updateTask($: any, id: number, input: string): Promise<string> {
461 await sync($)
462 const c = await nowCtx($)
463 const p = parseQuick(input, c.today)
464 const list = await read($, items)
465 const t = list.find((x: Task) => x.id === id)
466 if (!t) return `No task ${id}`
467 if (!p.text) return 'Nothing to change.'
468 let project = t.project
469 if (p.project) {
470 const projs = await read($, projects)
471 const known = ['Inbox', ...projs].find(x => x.toLowerCase() === p.project.toLowerCase())
472 project = known ?? p.project
473 if (!known) await saveProjects($, [...projs, project])
474 }
475 const next: Task = { ...t, text: p.text, priority: p.priority, labels: p.labels, due: p.due, time: p.time, recur: p.recur, project }
476 // subtasks follow their task to its new project
477 const kids = new Set(descendants(list, id))
478 await save($, list.map((x: Task) => (x.id === id ? next : kids.has(x.id) ? { ...x, project } : x)))
479 return `Updated: ${line(next)}`
480}
481
482async function moveTask($: any, id: number, dir: string): Promise<string> {
483 await sync($)
484 const list = await read($, items)
485 const idx = list.findIndex((x: Task) => x.id === id)
486 if (idx < 0) return `No task ${id}`
487 const t = list[idx] as Task
488 const sib = list
489 .map((x: Task, i: number) => (x.parent === t.parent && x.project === t.project && x.isDone === t.isDone ? i : -1))
490 .filter((i: number) => i >= 0)
491 const pos = sib.indexOf(idx)
492 const to = dir === 'up' ? pos - 1 : dir === 'down' ? pos + 1 : dir === 'top' ? 0 : -1
493 if (to < 0 || to >= sib.length || to === pos) return `Task ${id} stays where it is.`
494 const next = [...list]
495 if (dir === 'top') {
496 next.splice(idx, 1)
497 next.splice(sib[0] as number, 0, t)
498 } else {
499 next[idx] = list[sib[to] as number] as Task
500 next[sib[to] as number] = t
501 }
502 await save($, next)
503 return `Moved ${dir}: ${t.text}`
504}
505
506async function startEdit($: any, id: number) {
507 const t = (await read($, items)).find((x: Task) => x.id === id)
508 if (!t) return
509 await update($, editing, () => id)
510 await update($, draft, () => toQuick(t))
511}
512
513async function cancelInput($: any) {
514 await update($, editing, () => 0)
515 await update($, parent, () => 0)
516 await update($, draft, () => '')
517}
518
519async function submitInput($: any, input: string) {
520 const ed = await read($, editing)
521 if (ed) await updateTask($, ed, input)
522 else await addFromPane($, input)
523 await cancelInput($)
524}
525
526// the prompt that asks Claude to carry a task out, with what the list knows about it
527function taskPrompt(list: Task[], t: Task, canComplete: boolean) {
528 const subs = descendants(list, t.id)
529 .map(id => list.find(x => x.id === id))
530 .filter((x): x is Task => !!x && !x.isDone)
531 return [
532 `Work on this task from my to-do list (id ${t.id}): ${t.text}`,
533 `Project: ${t.project}` +
534 (t.due ? `, due ${t.due}${t.time ? ` ${t.time}` : ''}` : '') +
535 (t.priority < 4 ? `, priority p${t.priority}` : '') +
536 (t.labels.length ? `, labels ${t.labels.map(l => `@${l}`).join(' ')}` : ''),
537 subs.length ? `Subtasks:\n${subs.map(x => `- ${x.text}`).join('\n')}` : '',
538 canComplete ? 'When it is done, mark it complete with the todo_complete tool.' : '',
539 ]
540 .filter(Boolean)
541 .join('\n')
542}
543
544// hands a task to the session that is open now: a turn of its own once the session is idle
545async function runHere($: any, id: number) {
546 const list = await read($, items)
547 const t = list.find((x: Task) => x.id === id)
548 if (!t || t.isDone) return
549 void $.prompt.submit({ text: taskPrompt(list, t, true), asUser: true })
550 $.ui.toast(`Sent to this session: ${t.text}`)
551}
552
553const shq = (x: string) => `'${x.replace(/'/g, `'\\''`)}'`
554const asq = (x: string) => x.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
555
556// opens a Terminal window with an interactive claude started on the task: a session of its own.
557// The prompt goes through a temp file, so no task text ever reaches a shell or AppleScript string.
558async function runInNewSession($: any, id: number) {
559 const list = await read($, items)
560 const t = list.find((x: Task) => x.id === id)
561 if (!t || t.isDone) return
562 const file = `/tmp/todo-list-task-${t.id}-${await $.clock.now()}.txt`
563 const cwd = await $.session.cwd()
564 await $.fs.write(file, taskPrompt(list, t, false))
565 const shell = `cd ${shq(cwd)} && P="$(cat ${shq(file)})" && rm -f ${shq(file)} && claude "$P"`
566 const run = await $.process.run([
567 'osascript',
568 '-e', 'tell application "Terminal"',
569 '-e', 'activate',
570 '-e', `do script "${asq(shell)}"`,
571 '-e', 'end tell',
572 ])
573 $.ui.toast(
574 run.exitCode === 0
575 ? `Opened a new session for: ${t.text}`
576 : `Could not open Terminal: ${String(run.stderr).trim().slice(0, 120)}`,
577 )
578}
579
580// a reminder for each timed task whose minute has come
581async function remind($: any) {
582 const c = await nowCtx($)
583 for (const t of await read($, items)) {
584 const key = `${t.id}:${t.due}:${t.time}`
585 if (t.isDone || t.due !== c.today || t.time !== c.hm || fired.has(key)) continue
586 fired.add(key)
587 $.ui.toast(`${t.time} ${t.text}`, { timeoutMs: 15000 })
588 }
589}
590
591// ---------- the mod ----------
592
593export const register: Register = on => {
594 on('session.start', async ($, e, next) => {
595 await $.command.register({
596 name: 'todo',
597 description:
598 'Tasks: /todo [<quick add> | done <id> | rm <id> | edit <id> <text> | move <id> up|down|top | list [view] | project <name> | clear]',
599 })
600 await $.tool.register({
601 name: 'todo_list',
602 description:
603 "List the user's tasks with ids. view: inbox | today | upcoming | all (default) | a project name.",
604 inputSchema: { type: 'object', properties: { view: { type: 'string' } } },
605 })
606 await $.tool.register({
607 name: 'todo_add',
608 description:
609 "Add a task. Quick-add syntax: 'Pay rent tomorrow at 9:30 p1 #Home @bills' (p1-p4 priority, #project, @label, " +
610 "dates like today/tomorrow/mon/in 3 days/2026-10-20/20/10, times like 'at 14:30' or '3pm', recurrence like 'every week').",
611 inputSchema: {
612 type: 'object',
613 properties: { text: { type: 'string' }, parent: { type: 'number', description: 'id of the parent task' } },
614 required: ['text'],
615 },
616 })
617 await $.tool.register({
618 name: 'todo_update',
619 description:
620 "Rewrite a task from a full quick-add line (same syntax as todo_add): text, priority, date, time, recurrence and labels are " +
621 "replaced by what the line says; the project stays unless the line has #project.",
622 inputSchema: {
623 type: 'object',
624 properties: { id: { type: 'number' }, text: { type: 'string' } },
625 required: ['id', 'text'],
626 },
627 })
628 await $.tool.register({
629 name: 'todo_complete',
630 description: 'Complete a task by id (a recurring task moves to its next date; a done task is reopened).',
631 inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] },
632 })
633 await $.tool.register({
634 name: 'todo_delete',
635 description: 'Delete a task and its subtasks by id.',
636 inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] },
637 })
638
639 if (await $.fs.exists(await sharedPath($))) {
640 await sync($)
641 await showStatus($, await read($, items))
642 } else {
643 // first run with the shared file: carry over the list this load source kept in $.store
644 const stored = (await $.store.get('items')) as unknown[] | undefined
645 const storedProjects = (await $.store.get('projects')) as string[] | undefined
646 await saveProjects($, Array.isArray(storedProjects) ? storedProjects.map(String) : [])
647 await save($, Array.isArray(stored) ? stored.map(norm) : [])
648 }
649 await $.store.delete('filters')
650 // every 3s: pick up other sessions' changes; every 20s: keep the status line on today's date, fire reminders
651 $.clock.every(3000, () => sync($))
652 $.clock.every(20000, async () => {
653 await showStatus($, await read($, items))
654 await remind($)
655 })
656
657 return next(e)
658 })
659
660 on('tool.call', { tool: 'mcp__todo-list__todo_list' }, async ($, e) => ({
661 result: await listText($, String((e as any).view ?? 'all')),
662 }))
663
664 on('tool.call', { tool: 'mcp__todo-list__todo_add' }, async ($, e) => {
665 const t = await addTask($, String((e as any).text ?? ''), { parent: Number((e as any).parent ?? 0) })
666 return { result: t ? `Added: ${line(t)}` : 'Nothing to add.' }
667 })
668
669 on('tool.call', { tool: 'mcp__todo-list__todo_update' }, async ($, e) => ({
670 result: await updateTask($, Number((e as any).id), String((e as any).text ?? '')),
671 }))
672
673 on('tool.call', { tool: 'mcp__todo-list__todo_complete' }, async ($, e) => ({
674 result: await completeTask($, Number((e as any).id)),
675 }))
676
677 on('tool.call', { tool: 'mcp__todo-list__todo_delete' }, async ($, e) => ({
678 result: await deleteTask($, Number((e as any).id)),
679 }))
680
681 on('command.run', { command: 'todo' }, async ($, e) => {
682 const args = e.args.trim()
683 const [verb = '', ...rest] = args.split(/\s+/)
684 const arg = rest.join(' ')
685
686 if (verb === '') {
687 await $.ui.open({ id: PANE, title: 'To-do' })
688 return { text: 'To-do pane opened.' }
689 }
690 // a known verb with the wrong arguments says how to use it, instead of becoming a task
691 const usage: Record<string, string> = {
692 done: '/todo done <id>',
693 rm: '/todo rm <id>',
694 edit: '/todo edit <id> <new text>',
695 move: '/todo move <id> up|down|top',
696 project: '/todo project <name>',
697 }
698 const id = Number(rest[0])
699 const isBad =
700 (['done', 'rm'].includes(verb) && !(rest.length === 1 && Number.isInteger(id))) ||
701 (verb === 'edit' && !(rest.length > 1 && Number.isInteger(id))) ||
702 (verb === 'move' && !(rest.length === 2 && Number.isInteger(id) && ['up', 'down', 'top'].includes(rest[1] as string))) ||
703 (verb === 'project' && !arg)
704 if (isBad) return { text: `Usage: ${usage[verb]}` }
705
706 if (verb === 'list') return { text: await listText($, arg || 'all') }
707 if (verb === 'done') return { text: await completeTask($, id) }
708 if (verb === 'rm') return { text: await deleteTask($, id) }
709 if (verb === 'edit') return { text: await updateTask($, id, rest.slice(1).join(' ')) }
710 if (verb === 'move') return { text: await moveTask($, id, rest[1] as string) }
711 if (verb === 'project') {
712 await sync($)
713 const projs = await read($, projects)
714 const known = ['Inbox', ...projs].find(x => x.toLowerCase() === arg.toLowerCase())
715 if (!known) await saveProjects($, [...projs, arg])
716 return { text: `Project ready: ${known ?? arg}` }
717 }
718 if (verb === 'clear') {
719 await sync($)
720 const list = await read($, items)
721 await save($, list.filter((t: Task) => !t.isDone))
722 return { text: 'Cleared completed tasks.' }
723 }
724
725 const t = await addTask($, verb === 'add' ? arg : args, {})
726 return { text: t ? `Added: ${line(t)}` : 'Nothing to add.' }
727 })
728
729 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
730 const { Box, Text, Button, Input } = $.ui.resolve(e)
731 const c = await nowCtx($)
732 const { today, hm } = c
733 const list = await read($, items)
734 const projs = await read($, projects)
735 const v = await read($, view)
736 const par = await read($, parent)
737 const sel = await read($, selected)
738 const ed = await read($, editing)
739 const withDone = await read($, showDone)
740 const text = await read($, draft)
741
742 const count = (w: string) => {
743 try {
744 return select(list, w, c, false).length
745 } catch {
746 return 0
747 }
748 }
749 const isProject = v.startsWith('p:')
750 const isFlat = isFlatView(v)
751 const title =
752 v === 'inbox' ? 'Inbox' : v === 'today' ? 'Today' : v === 'upcoming' ? 'Upcoming' : v.slice(2)
753 let rows: Task[] = []
754 let problem = ''
755 try {
756 rows = select(list, v, c, withDone)
757 } catch (err) {
758 problem = String((err as Error).message)
759 }
760 const roots = isFlat ? rows : rows.filter(t => !t.parent || !rows.some(x => x.id === t.parent))
761 const parentTask = list.find((t: Task) => t.id === par)
762 const editTask = list.find((t: Task) => t.id === ed)
763
764 const action = (key: string, label: string, run: () => unknown) => (
765 <Button key={key} plain dimColor label={label} onPress={run} />
766 )
767
768 const row = (t: Task, depth: number): any[] => [
769 <Box key={`row-${t.id}`} flexDirection="column" paddingLeft={depth * 2}>
770 <Box gap={1} alignItems="flex-start">
771 <Button key={`tog-${t.id}`} plain label={t.isDone ? '●' : '○'} onPress={() => completeTask($, t.id)} />
772 {t.priority < 4 && <Text bold color={PRI_COLOR[t.priority]}>p{t.priority}</Text>}
773 <Box flexGrow={1} flexShrink={1}>
774 <Text wrap="wrap" strikethrough={t.isDone} dimColor={t.isDone}>{t.text}</Text>
775 </Box>
776 <Button
777 key={`sel-${t.id}`}
778 plain
779 dimColor={sel !== t.id}
780 label="⋯"
781 onPress={() => update($, selected, (x: number) => (x === t.id ? 0 : t.id))}
782 />
783 </Box>
784 {(!!t.due || t.labels.length > 0 || isFlat) && (
785 <Box gap={1} flexWrap="wrap" paddingLeft={2}>
786 {!!t.due && (
787 <Text color={isLate(t, today, hm) ? 'red' : t.due === today ? 'green' : 'yellow'}>
788 {t.recur ? '↻ ' : ''}{dueLabel(t.due, today, t.time)}
789 </Text>
790 )}
791 {t.labels.length > 0 && <Text color="cyan">{t.labels.map(l => `@${l}`).join(' ')}</Text>}
792 {isFlat && <Text dimColor>#{t.project}</Text>}
793 </Box>
794 )}
795 </Box>,
796 ...(sel === t.id
797 ? [
798 <Box key={`act-${t.id}`} gap={1} flexWrap="wrap" paddingLeft={depth * 2 + 2}>
799 {!t.isDone && action(`run-${t.id}`, '▶ run here', () => runHere($, t.id))}
800 {!t.isDone && action(`new-${t.id}`, '⧉ new session', () => runInNewSession($, t.id))}
801 {action(`edit-${t.id}`, 'edit', () => startEdit($, t.id))}
802 {action(`sub-${t.id}`, '+subtask', () => update($, parent, () => t.id))}
803 {action(`pri-${t.id}`, 'priority', () => cyclePriority($, t.id))}
804 {!isFlat && action(`up-${t.id}`, '↑', () => moveTask($, t.id, 'up'))}
805 {!isFlat && action(`down-${t.id}`, '↓', () => moveTask($, t.id, 'down'))}
806 {!isFlat && action(`top-${t.id}`, 'top', () => moveTask($, t.id, 'top'))}
807 {action(`del-${t.id}`, 'delete', () => deleteTask($, t.id))}
808 </Box>,
809 ]
810 : []),
811 ...(isFlat ? [] : rows.filter(x => x.parent === t.id).flatMap(k => row(k, depth + 1))),
812 ]
813
814 const body: any[] = []
815 let lastGroup = ''
816 for (const t of roots) {
817 if (v === 'today' || v === 'upcoming') {
818 const group = t.due < today ? 'Overdue' : dueDay(t.due, today)
819 if ((v === 'upcoming' || group === 'Overdue') && group !== lastGroup) {
820 body.push(<Text key={`grp-${group}`} bold>{group}</Text>)
821 lastGroup = group
822 }
823 }
824 body.push(...row(t, 0))
825 }
826
827 const tab = (id: string, label: string, n: number) => (
828 <Button
829 key={`view-${id}`}
830 plain={v !== id ? true : undefined}
831 variant={v === id ? 'primary' : undefined}
832 label={n > 0 ? `${label} ${n}` : label}
833 onPress={() => update($, view, () => id)}
834 />
835 )
836
837 const inputKey = ed ? 'edit' : 'new'
838 const inputLabel = ed ? '✎ ' : '+ '
839 const placeholder = ed ? 'Edit, then Enter' : 'Add a task…'
840
841 return (
842 <Box flexDirection="column">
843 <Box gap={1} flexWrap="wrap">
844 {tab('inbox', 'Inbox', count('inbox'))}
845 {tab('today', 'Today', count('today'))}
846 {tab('upcoming', 'Upcoming', count('upcoming'))}
847 {projs.map((p: string) => tab(`p:${p}`, `#${p}`, count(`p:${p}`)))}
848 </Box>
849 <Input
850 key={inputKey}
851 label={inputLabel}
852 placeholder={placeholder}
853 value={text}
854 onInput={(x: string) => update($, draft, () => x)}
855 onSubmit={(x: string) => submitInput($, x)}
856 />
857 {(!!editTask || !!parentTask) && (
858 <Box gap={1} flexWrap="wrap">
859 <Text dimColor>
860 {editTask ? `Editing: ${editTask.text}` : `Subtask of: ${parentTask?.text}`}
861 </Text>
862 {action('cancel-input', 'cancel', () => cancelInput($))}
863 </Box>
864 )}
865 <Text bold>{title}</Text>
866 {!!problem && <Text color="red">{problem}</Text>}
867 {!problem && body.length === 0 && <Text dimColor>Nothing here.</Text>}
868 {body}
869 <Box gap={1} flexWrap="wrap">
870 {!isFlat && action('toggle-done', withDone ? 'Hide completed' : 'Show completed', () => update($, showDone, (x: boolean) => !x))}
871 {isProject && action('del-project', 'Delete project', () => deleteProject($, v.slice(2)))}
872 </Box>
873 </Box>
874 )
875 })
876}
877types/index.d.ts 28 lines1export type Task = {
2 id: number
3 text: string
4 isDone: boolean
5 priority: 1 | 2 | 3 | 4
6 project: string
7 labels: string[]
8 due: string
9 time: string
10 recur: string
11 parent: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'todo-list': {
17 items: Task[]
18 projects: string[]
19 view: string
20 draft: string
21 parent: number
22 selected: number
23 editing: number
24 showDone: boolean
25 }
26 }
27}
28