A persistent TODO list in a pane, with /todo and a model tool

Keep a TODO list next to your Claude Code chat. Add, finish, edit and remove items in a pane, with /todo, or by asking Claude. The list lives in plain Markdown files, so it survives every session and stays readable in any editor.

/todo runs instantly. It changes the list without a model turn.~/.claude/todo.md (global) and TODO.md at the project's git root.Type these at the Claude Code prompt:
/plugin install todo --marketplace wlsdnen/claude-code-todo
Press y to add the marketplace, then pick a scope.
Requirements: the Claude Code CLI 2.1.287 or later, the Desktop app's Code tab, or the VS Code extension 2.1.299 or later. Panes are not drawn in claude -p, Agent SDK or cloud sessions.
| Key | Action |
|---|---|
| select a row | open it and show its note |
d | mark done / not done |
e | edit the text and the note |
x | remove |
| bottom field | add a new item |
Use ctrl+x tab or a click to move focus into the pane.
/todo command/todo open or close the pane
/todo add Fix flaky test +2d add to the default list
/todo -g add Ship docs !high fri add to the global list
/todo -p add Write notes add to the project list
/todo done 2 mark item 2 done
/todo edit 2 Ship docs v2 !low change item 2
/todo rm 3 remove item 3
Item numbers follow the order on screen. Without -p or -g, a command uses the default list: the project list inside a git repo, else the global list. The pane opens on the same list, and Claude's tool uses it too.
Put them at the end of the text.
!high, !mid (the default), !lowtoday, tomorrow, mon…sun, next week, +3d, +2w, 10/25, 2026-10-25. Korean words also work: 오늘, 내일, 모레, 금요일.The plugin gives Claude one tool, todo. Its description tells Claude to use it only when you ask about your TODO list, not to add items on its own. This tool is separate from Claude's built-in session task list.
The plugin makes no network requests and sends no data anywhere.
git rev-parse --show-toplevel in the session's folder, to find the project root.HOME, LC_ALL, LC_MESSAGES and LANG, and the Claude Code language setting, to find the files and pick the UI language.~/.claude/todo.md and TODO.md at the project root. It creates them on the first add.See PRIVACY.md.
- [ ] Reply to PR review !high due:2026-10-25
2 of 3 comments need code changes
- [ ] Deploy checklist
- [x] Write meeting notes
An indented line under an item is its note. Lines that are not items, such as headings, stay as they are. You can edit the file by hand, and the pane picks up changes Claude makes with its Write or Edit tools.
git clone https://github.com/wlsdnen/claude-code-todo
claude --plugin-dir ./claude-code-todo # run Claude Code with the plugin loaded
claude plugin test ./claude-code-todo # run the tests
claude plugin validate ./claude-code-todo
hooks/register.tsx 420 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Scope } from '../types'
5import { pickLang, STRINGS } from './i18n'
6import type { Strings } from './i18n'
7import {
8 MARK,
9 parseAddArgs,
10 parseCommand,
11 parseDue,
12 parseFile,
13 serializeFile,
14 shortDate,
15 sortTodos,
16 todosOf,
17 ymd,
18} from './todo'
19import type { Fields, Line, Priority, Todo } from './todo'
20
21const PANE = 'todo'
22const OPEN_KEY = 'paneOpen'
23const TODO_FILE = /(^|\/)(todo|TODO)\.md$/
24
25// null until the user picks a tab: the pane then opens on defaultScope.
26const tab = atom({ plugin: 'todo', key: 'tab' } as const, null as Scope | null)
27const selected = atom({ plugin: 'todo', key: 'selected' } as const, null as number | null)
28const editing = atom({ plugin: 'todo', key: 'editing' } as const, false)
29
30type $ = EngineInterface
31
32const today = async ($: $) => new Date(await $.clock.now())
33
34let stringsCache: Strings | undefined
35
36// shortcut: read once per load (session.start refreshes it); a changed language shows after the next reload.
37async function strings($: $, isFresh = false): Promise<Strings> {
38 if (stringsCache && !isFresh) return stringsCache
39 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
40 stringsCache = STRINGS[pickLang(await $.settings.read({ key: 'language' }), locale)]
41 return stringsCache
42}
43
44let rootCache: { cwd: string; root: string; isRepo: boolean } | undefined
45
46async function locate($: $) {
47 const cwd = await $.session.cwd()
48 if (rootCache?.cwd === cwd) return rootCache
49 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
50 const top = git.exitCode === 0 ? git.stdout.trim() : ''
51 rootCache = { cwd, root: top || cwd, isRepo: !!top }
52 return rootCache
53}
54
55const projectRoot = async ($: $) => (await locate($)).root
56
57// The list used when no scope is named: the project's inside a git repo, else the global one.
58const defaultScope = async ($: $): Promise<Scope> => ((await locate($)).isRepo ? 'project' : 'global')
59
60async function pathOf($: $, scope: Scope): Promise<string> {
61 if (scope === 'project') return `${await projectRoot($)}/TODO.md`
62 return `${await $.env.get('HOME')}/.claude/todo.md`
63}
64
65// A displayed path never shows the home directory, so output pasted elsewhere does not carry the user name.
66async function shownPath($: $, scope: Scope): Promise<string> {
67 const path = await pathOf($, scope)
68 const home = await $.env.get('HOME')
69 return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
70}
71
72async function load($: $, scope: Scope): Promise<Line[]> {
73 const path = await pathOf($, scope)
74 // A read that fails for any other reason throws, so a write never replaces a file we could not read.
75 return (await $.fs.exists(path)) ? parseFile(await $.fs.read(path)) : []
76}
77
78async function save($: $, scope: Scope, lines: Line[]) {
79 const text = serializeFile(lines)
80 await $.fs.write(await pathOf($, scope), text.endsWith('\n') ? text : `${text}\n`)
81 $.ui.invalidate('ui.render')
82}
83
84const sorted = async ($: $, scope: Scope) => sortTodos(todosOf(await load($, scope)))
85
86function insert(lines: Line[], todo: Todo) {
87 let i = lines.length
88 while (i > 0 && (lines[i - 1] as { raw?: string }).raw === '') i--
89 lines.splice(i, 0, { todo })
90}
91
92function applyFields(todo: Todo, fields: Fields) {
93 if (fields.text) todo.text = fields.text
94 if (fields.priority) todo.priority = fields.priority
95 if (fields.due) todo.due = fields.due
96}
97
98// Runs `fn` on the n-th item in display order and answers the list after it; `missing` when n is out of range.
99async function onItem(
100 $: $,
101 scope: Scope,
102 n: number,
103 fn: (lines: Line[], todo: Todo) => Line[] | void,
104): Promise<{ missing: true } | { todo: Todo; items: Todo[] }> {
105 const lines = await load($, scope)
106 const todo = sortTodos(todosOf(lines))[n - 1]
107 if (!todo) return { missing: true }
108 const saved = fn(lines, todo) ?? lines
109 await save($, scope, saved)
110 return { todo, items: sortTodos(todosOf(saved)) }
111}
112
113const without = (lines: Line[], todo: Todo) => lines.filter(l => !('todo' in l && l.todo === todo))
114
115function rowText(todo: Todo, n: number, now: Date, overdue: string): string {
116 const due = todo.due ? ` ${shortDate(todo.due, now)}${!todo.done && todo.due < ymd(now) ? ` ${overdue}` : ''}` : ''
117 return `${n} [${todo.done ? 'x' : ' '}] ${MARK[todo.priority]} ${todo.text}${due}`
118}
119
120async function listText($: $, scope: Scope): Promise<string> {
121 const now = await today($)
122 const items = await sorted($, scope)
123 const head = `TODO (${scope}, ${await shownPath($, scope)})`
124 if (items.length === 0) return `${head}\n(empty)`
125 return [head, ...items.map((t, i) => rowText(t, i + 1, now, 'overdue') + (t.memo ? `\n ${t.memo.replace(/\n/g, '\n ')}` : ''))].join('\n')
126}
127
128async function togglePane($: $, s: Strings): Promise<string> {
129 if ((await $.ui.panes()).some(p => p.id === PANE)) {
130 await $.ui.close({ id: PANE })
131 return s.closed
132 }
133 await $.store.set(OPEN_KEY, true)
134 await $.ui.open({ id: PANE, title: 'TODO' })
135 return s.opened
136}
137
138async function addItem($: $, scope: Scope, fields: Fields, memo?: string): Promise<Todo> {
139 const lines = await load($, scope)
140 const todo: Todo = { done: false, text: fields.text, priority: fields.priority ?? 'mid' }
141 if (fields.due) todo.due = fields.due
142 if (memo) todo.memo = memo
143 insert(lines, todo)
144 await save($, scope, lines)
145 return todo
146}
147
148async function position($: $, scope: Scope, todo: Todo): Promise<number> {
149 const items = await sorted($, scope)
150 const i = items.findIndex(t => t.text === todo.text && t.done === todo.done)
151 return i + 1
152}
153
154const TOOL_DESCRIPTION = [
155 "The user's persistent TODO list, kept in Markdown files: ~/.claude/todo.md (scope 'global') and TODO.md at the project's git root (scope 'project'). Without scope, the tool uses 'project' inside a git repo and 'global' elsewhere.",
156 'This is NOT the session task list (TodoWrite). Use this tool only when the user asks to see or change their TODO list; never add items on your own.',
157 'Items are numbered in display order (open before done, then priority high > mid > low, then due date). Call list first to get the numbers.',
158 "due takes the same words as /todo: 오늘, 내일, 모레, 금요일, today, tomorrow, fri, next week, +3d, +2w, 10/25, 2026-10-25. An empty string clears due or memo.",
159].join(' ')
160
161const INPUT_SCHEMA = {
162 type: 'object',
163 properties: {
164 action: { enum: ['list', 'add', 'update', 'remove'] },
165 scope: { enum: ['global', 'project'] },
166 n: { type: 'integer', minimum: 1, description: 'Item number in display order (update, remove).' },
167 text: { type: 'string' },
168 priority: { enum: ['high', 'mid', 'low'] },
169 due: { type: 'string' },
170 memo: { type: 'string' },
171 done: { type: 'boolean' },
172 },
173 required: ['action'],
174}
175
176type ToolInput = {
177 action: 'list' | 'add' | 'update' | 'remove'
178 scope?: Scope
179 n?: number
180 text?: string
181 priority?: Priority
182 due?: string
183 memo?: string
184 done?: boolean
185}
186
187async function runTool($: $, input: ToolInput): Promise<string> {
188 const scope = input.scope ?? (await defaultScope($))
189 const now = await today($)
190 const due = input.due ? parseDue(input.due, now) : undefined
191 if (input.due && !due) return `Unknown due date: ${input.due}`
192 if (input.action === 'list') return listText($, scope)
193 if (input.action === 'add') {
194 if (!input.text?.trim()) return 'add needs text.'
195 await addItem($, scope, { text: input.text.trim(), priority: input.priority, due }, input.memo)
196 return listText($, scope)
197 }
198 if (!input.n) return `${input.action} needs n.`
199 const done = await onItem($, scope, input.n, (lines, todo) => {
200 if (input.action === 'remove') return without(lines, todo)
201 applyFields(todo, { text: input.text?.trim() ?? '', priority: input.priority, due })
202 if (input.due === '') delete todo.due
203 if (input.memo !== undefined) input.memo ? (todo.memo = input.memo) : delete todo.memo
204 if (input.done !== undefined) todo.done = input.done
205 })
206 return 'missing' in done ? `No item ${input.n} in the ${scope} list.` : listText($, scope)
207}
208
209export const register: Register = on => {
210 on('session.start', async ($, e, next) => {
211 const started = await next(e)
212 const s = await strings($, true)
213 await $.command.register({ name: 'todo', description: s.description, argumentHint: s.argumentHint })
214 await $.tool.register({ name: 'todo', description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
215 if ((await $.store.get(OPEN_KEY)) === true) void $.ui.open({ id: PANE, title: 'TODO' })
216 return started
217 })
218
219 on('ui.close', async ($, e, next) => {
220 if (e.id === PANE && e.origin.kind !== 'unload') await $.store.set(OPEN_KEY, false)
221 return next(e)
222 }).catch(($, e, next) => next(e))
223
224 on('command.run', { command: 'todo' }, async ($, e) => {
225 const now = await today($)
226 const s = await strings($)
227 const cmd = parseCommand(e.args, now, await defaultScope($))
228 if (cmd.kind === 'toggle') return { text: await togglePane($, s) }
229 if (cmd.kind === 'error') return { text: s[cmd.code] }
230 if (cmd.kind === 'unknown') return { text: s.unknown(cmd.verb) }
231 const where = `(${s[cmd.scope]})`
232 if (cmd.kind === 'add') {
233 const todo = await addItem($, cmd.scope, cmd.fields)
234 return { text: `+ ${rowText(todo, await position($, cmd.scope, todo), now, s.overdue)} ${where}` }
235 }
236 const done = await onItem($, cmd.scope, cmd.n, (lines, todo) => {
237 if (cmd.kind === 'rm') return without(lines, todo)
238 if (cmd.kind === 'edit') applyFields(todo, cmd.fields)
239 else todo.done = true
240 })
241 if ('missing' in done) return { text: s.missing(s[cmd.scope], cmd.n) }
242 const verb = cmd.kind === 'rm' ? s.remove : cmd.kind === 'done' ? s.done : s.edit
243 return { text: `${verb}: ${done.todo.text} ${where}` }
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247 if (e.props.hasSurvey) return next(e)
248 const now = await today($)
249 const open = sortTodos([...todosOf(await load($, 'project')), ...todosOf(await load($, 'global'))]).filter(t => !t.done)
250 const first = open[0]
251 if (!first) return next(e)
252 const overdue = open.filter(t => t.due && t.due < ymd(now)).length
253 const s = await strings($)
254 const { Box, Text } = $.ui.resolve(e)
255 return (
256 <Box>
257 <Text wrap="truncate-end">
258 <Text dimColor>TODO </Text>
259 <Text bold>{s.left(open.length)}</Text>
260 <Text dimColor> · </Text>
261 {s.next} {first.text}
262 {overdue > 0 && <Text color="error"> · {s.overdueCount(overdue)}</Text>}
263 </Text>
264 </Box>
265 )
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const elements = $.ui.resolve(e)
270 const { Box, Text, Button } = elements
271 // The mobile surface draws no Input: there the pane is read and pressed only.
272 const Input = 'Input' in elements ? elements.Input : undefined
273 const now = await today($)
274 const s = await strings($)
275 const scope = (await read($, tab)) ?? (await defaultScope($))
276 const picked = await read($, selected)
277 const isEditing = await read($, editing)
278 const items = await sorted($, scope)
279 const pick = picked !== null && picked <= items.length ? picked : null
280 const root = await projectRoot($)
281 // Every press leaves a row selected, so d/e/x stay drawn and the pane keeps the keys, and moves the ring onto it.
282 // The selected row is keyed `sel-N`, a key the drawing before the press lacks: $.ui.focus then waits for the
283 // new drawing instead of landing on the old one, which the following redraw re-places (onto Delete).
284 // shortcut: the move is best-effort (the test kit cannot answer it); the kept selection is what holds the keys.
285 const moveRing = (key: string) => $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
286 const focusRow = async (n: number | null) => {
287 await update($, selected, () => n)
288 await moveRing(n ? `sel-${n}` : 'add')
289 }
290 const setTab = (next: Scope) => async () => {
291 await update($, tab, () => next)
292 await update($, editing, () => false)
293 await focusRow((await sorted($, next)).length > 0 ? 1 : null)
294 }
295 const act = (n: number, fn: (lines: Line[], todo: Todo) => Line[] | void) => async () => {
296 await update($, editing, () => false)
297 const done = await onItem($, scope, n, fn)
298 if ('missing' in done) return focusRow(null)
299 const at = done.items.indexOf(done.todo)
300 await focusRow(at >= 0 ? at + 1 : Math.min(n, done.items.length) || null)
301 }
302 // Where the ring starts when the pane takes the keyboard: the edit field, the selected row, the first row, else the add field.
303 const startKey = isEditing && pick ? 'edit' : pick ? `sel-${pick}` : items.length > 0 ? 'row-1' : 'add'
304 const auto = (key: string) => (key === startKey ? (true as const) : undefined)
305
306 return (
307 <Box flexDirection="column">
308 <Box>
309 <Button key="tab-global" label={s.global} variant={scope === 'global' ? 'primary' : 'secondary'} onPress={setTab('global')} />
310 <Text> </Text>
311 <Button
312 key="tab-project"
313 label={`${s.project} (${root.split('/').at(-1)})`}
314 variant={scope === 'project' ? 'primary' : 'secondary'}
315 onPress={setTab('project')}
316 />
317 </Box>
318 {items.length === 0 && <Text dimColor>{s.empty}</Text>}
319 {items.map((todo, i) => {
320 const n = i + 1
321 const isOverdue = !todo.done && !!todo.due && todo.due < ymd(now)
322 const rowKey = pick === n ? `sel-${n}` : `row-${n}`
323 const row = (
324 <Button
325 key={rowKey}
326 plain
327 autoFocus={auto(rowKey)}
328 dimColor={todo.done}
329 onPress={async () => {
330 await update($, editing, () => false)
331 await update($, selected, () => (pick === n ? null : n))
332 await moveRing(pick === n ? `row-${n}` : `sel-${n}`)
333 }}
334 >
335 {pick === n ? '▶ ' : ' '}[{todo.done ? 'x' : ' '}] <Text color={todo.priority === 'high' ? 'error' : todo.priority === 'mid' ? 'warning' : 'subtle'}>{MARK[todo.priority]}</Text> {todo.text}
336 {todo.due && <Text color={isOverdue ? 'error' : 'warning'}> {shortDate(todo.due, now)}{isOverdue ? ` ${s.overdue}` : ''}</Text>}
337 </Button>
338 )
339 if (pick !== n) return row
340 const line = [todo.text, todo.priority === 'mid' ? '' : `!${todo.priority}`, todo.due ?? ''].filter(Boolean).join(' ')
341 return (
342 <Box key={`open-${n}`} flexDirection="column">
343 {row}
344 {isEditing && Input ? (
345 <Box flexDirection="column">
346 <Input
347 key="edit"
348 label={s.editLabel}
349 value={line}
350 autoFocus={auto('edit')}
351 onSubmit={(value: string) =>
352 act(n, (_, t) => {
353 const fields = parseAddArgs(value, now)
354 if (!fields.text) return
355 t.text = fields.text
356 t.priority = fields.priority ?? 'mid'
357 if (fields.due) t.due = fields.due
358 else delete t.due
359 })()
360 }
361 />
362 <Input
363 key="memo"
364 label={s.memoLabel}
365 value={todo.memo ?? ''}
366 rows={2}
367 onSubmit={(value: string) =>
368 act(n, (_, t) => {
369 if (value.trim()) t.memo = value.trimEnd()
370 else delete t.memo
371 })()
372 }
373 />
374 <Text dimColor>{s.editHint}</Text>
375 </Box>
376 ) : (
377 <Box flexDirection="column">
378 {todo.memo && <Text dimColor> {todo.memo}</Text>}
379 <Box>
380 <Text> </Text>
381 <Button key="done" hotkey="d" label={todo.done ? s.undone : s.done} onPress={act(n, (_, t) => void (t.done = !t.done))} />
382 <Text> </Text>
383 {Input && <Button key="edit-start" hotkey="e" label={s.edit} onPress={async () => {
384 await update($, editing, () => true)
385 await moveRing('edit')
386 }} />}
387 <Text> </Text>
388 <Button key="remove" hotkey="x" label={s.remove} onPress={act(n, without)} />
389 </Box>
390 </Box>
391 )}
392 </Box>
393 )
394 })}
395 {Input && (
396 <Input
397 key="add"
398 autoFocus={auto('add')}
399 placeholder={s.addPlaceholder}
400 submitLabel={s.addSubmit}
401 onSubmit={async (value: string) => {
402 const fields = parseAddArgs(value, now)
403 if (fields.text) await addItem($, scope, fields)
404 }}
405 />
406 )}
407 </Box>
408 )
409 })
410
411 on('tool.call', async ($, e, next) => {
412 if (e.tool === 'mcp__todo__todo') {
413 return { result: await runTool($, e as unknown as ToolInput) }
414 }
415 const ran = await next(e)
416 if ((e.tool === 'Write' || e.tool === 'Edit') && TODO_FILE.test(e.file_path)) $.ui.invalidate('ui.render')
417 return ran
418 }).catch(($, e, next) => next(e))
419}
420hooks/i18n.ts 68 lines1export type Lang = 'ko' | 'en'
2
3const KO = {
4 global: '전역',
5 project: '프로젝트',
6 empty: '비어 있음',
7 overdue: '지남',
8 left: (n: number) => `${n}개 남음`,
9 next: '다음:',
10 overdueCount: (n: number) => `${n}개 마감 지남`,
11 done: '완료',
12 undone: '미완료',
13 edit: '수정',
14 remove: '삭제',
15 editLabel: '수정: ',
16 memoLabel: '메모: ',
17 editHint: 'Enter 저장 · 한 칸씩 저장됩니다',
18 addPlaceholder: '+ 새 항목… (예: 배포 체크 !high 금요일)',
19 addSubmit: '추가',
20 opened: 'TODO pane을 열었습니다.',
21 closed: 'TODO pane을 닫았습니다.',
22 missing: (scope: string, n: number) => `${scope} 목록에 ${n}번 항목이 없습니다.`,
23 usageAdd: '사용법: /todo [-p|-g] add <내용> [!high|!mid|!low] [마감]',
24 usageDoneRm: '사용법: /todo [-p|-g] done|rm <번호>',
25 usageEdit: '사용법: /todo [-p|-g] edit <번호> <내용>',
26 unknown: (verb: string) => `알 수 없는 명령: ${verb}. add, done, rm, edit 중 하나를 쓰세요.`,
27 description: 'TODO 목록: pane 열기/닫기, add · done · rm · edit (-p 프로젝트, -g 전역)',
28 argumentHint: '[-p|-g] [add <내용> [!high|!mid|!low] [마감] | done <n> | rm <n> | edit <n> <내용>]',
29}
30
31export type Strings = typeof KO
32
33const EN: Strings = {
34 global: 'Global',
35 project: 'Project',
36 empty: 'Empty',
37 overdue: 'overdue',
38 left: n => `${n} left`,
39 next: 'next:',
40 overdueCount: n => `${n} overdue`,
41 done: 'Done',
42 undone: 'Undo',
43 edit: 'Edit',
44 remove: 'Delete',
45 editLabel: 'Edit: ',
46 memoLabel: 'Memo: ',
47 editHint: 'Enter saves · each field saves on its own',
48 addPlaceholder: '+ New item… (e.g. ship release !high fri)',
49 addSubmit: 'add',
50 opened: 'Opened the TODO pane.',
51 closed: 'Closed the TODO pane.',
52 missing: (scope, n) => `The ${scope.toLowerCase()} list has no item ${n}.`,
53 usageAdd: 'Usage: /todo [-p|-g] add <text> [!high|!mid|!low] [due]',
54 usageDoneRm: 'Usage: /todo [-p|-g] done|rm <n>',
55 usageEdit: 'Usage: /todo [-p|-g] edit <n> <text>',
56 unknown: verb => `Unknown command: ${verb}. Use add, done, rm or edit.`,
57 description: 'TODO list: open/close the pane, add · done · rm · edit (-p project, -g global)',
58 argumentHint: '[-p|-g] [add <text> [!high|!mid|!low] [due] | done <n> | rm <n> | edit <n> <text>]',
59}
60
61export const STRINGS: Record<Lang, Strings> = { ko: KO, en: EN }
62
63// The Claude Code `language` setting wins when set; else the POSIX locale (LC_ALL, LC_MESSAGES, LANG).
64export function pickLang(setting: unknown, locale: string | undefined): Lang {
65 if (typeof setting === 'string' && setting.trim()) return /^(ko|korean|한국)/i.test(setting.trim()) ? 'ko' : 'en'
66 return locale?.toLowerCase().startsWith('ko') ? 'ko' : 'en'
67}
68hooks/todo.ts 170 lines1import type { Scope } from '../types'
2
3export type Priority = 'high' | 'mid' | 'low'
4
5export type Todo = {
6 done: boolean
7 text: string
8 priority: Priority
9 due?: string
10 memo?: string
11}
12
13// A file is its lines in order; lines that are not items are kept verbatim.
14export type Line = { todo: Todo } | { raw: string }
15
16export type Fields = { text: string; priority?: Priority; due?: string }
17
18export type Command =
19 | { kind: 'toggle' }
20 | { kind: 'add'; scope: Scope; fields: Fields }
21 | { kind: 'done' | 'rm'; scope: Scope; n: number }
22 | { kind: 'edit'; scope: Scope; n: number; fields: Fields }
23 | { kind: 'error'; code: 'usageAdd' | 'usageDoneRm' | 'usageEdit' }
24 | { kind: 'unknown'; verb: string }
25
26const ITEM = /^- \[( |x|X)\] (.*)$/
27const PRIORITY = /(?:^|\s)!(high|mid|low)(?=\s|$)/
28const DUE = /(?:^|\s)due:(\d{4}-\d{2}-\d{2})(?=\s|$)/
29const RANK: Record<Priority, number> = { high: 0, mid: 1, low: 2 }
30
31export function parseFile(text: string): Line[] {
32 const lines: Line[] = []
33 for (const line of text.split('\n')) {
34 const last = lines.at(-1)
35 if (last && 'todo' in last && line.startsWith(' ') && line.trim()) {
36 const memo = line.slice(2)
37 last.todo.memo = last.todo.memo === undefined ? memo : `${last.todo.memo}\n${memo}`
38 continue
39 }
40 const m = ITEM.exec(line)
41 if (!m) {
42 lines.push({ raw: line })
43 continue
44 }
45 let rest = m[2] ?? ''
46 const priority = PRIORITY.exec(rest)?.[1] as Priority | undefined
47 const due = DUE.exec(rest)?.[1]
48 rest = rest.replace(PRIORITY, ' ').replace(DUE, ' ').replace(/\s+/g, ' ').trim()
49 const todo: Todo = { done: m[1] !== ' ', text: rest, priority: priority ?? 'mid' }
50 if (due) todo.due = due
51 lines.push({ todo })
52 }
53 return lines
54}
55
56export function serializeTodo(t: Todo): string {
57 let line = `- [${t.done ? 'x' : ' '}] ${t.text}`
58 if (t.priority !== 'mid') line += ` !${t.priority}`
59 if (t.due) line += ` due:${t.due}`
60 const memo = t.memo ? t.memo.split('\n').map(m => `\n ${m}`).join('') : ''
61 return line + memo
62}
63
64export function serializeFile(lines: Line[]): string {
65 return lines.map(l => ('todo' in l ? serializeTodo(l.todo) : l.raw)).join('\n')
66}
67
68export const todosOf = (lines: Line[]): Todo[] =>
69 lines.flatMap(l => ('todo' in l ? [l.todo] : []))
70
71export function sortTodos(items: readonly Todo[]): Todo[] {
72 return [...items].sort(
73 (a, b) =>
74 Number(a.done) - Number(b.done) ||
75 RANK[a.priority] - RANK[b.priority] ||
76 (a.due ?? '9999').localeCompare(b.due ?? '9999'),
77 )
78}
79
80export const ymd = (d: Date): string =>
81 `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
82
83const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
84
85const WEEKDAYS: string[][] = [
86 ['일요일', 'sun', 'sunday'],
87 ['월요일', 'mon', 'monday'],
88 ['화요일', 'tue', 'tuesday'],
89 ['수요일', 'wed', 'wednesday'],
90 ['목요일', 'thu', 'thursday'],
91 ['금요일', 'fri', 'friday'],
92 ['토요일', 'sat', 'saturday'],
93]
94const OFFSETS: Record<string, number> = { 오늘: 0, today: 0, 내일: 1, tomorrow: 1, 모레: 2, 'next week': 7 }
95
96// Weekday words mean the next one after today; M/D already past rolls to next year.
97export function parseDue(word: string, today: Date): string | undefined {
98 const w = word.trim().toLowerCase()
99 const offset = OFFSETS[w]
100 if (offset !== undefined) return ymd(addDays(today, offset))
101 const rel = /^\+(\d+)([dw])$/.exec(w)
102 if (rel) return ymd(addDays(today, Number(rel[1]) * (rel[2] === 'w' ? 7 : 1)))
103 const day = WEEKDAYS.findIndex(names => names.includes(w))
104 if (day >= 0) return ymd(addDays(today, ((day - today.getDay() + 6) % 7) + 1))
105 const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(w)
106 if (iso) return validDate(Number(iso[1]), Number(iso[2]), Number(iso[3]))
107 const md = /^(\d{1,2})\/(\d{1,2})$/.exec(w)
108 if (md) {
109 const thisYear = validDate(today.getFullYear(), Number(md[1]), Number(md[2]))
110 if (!thisYear || thisYear >= ymd(today)) return thisYear
111 return validDate(today.getFullYear() + 1, Number(md[1]), Number(md[2]))
112 }
113 return undefined
114}
115
116function validDate(y: number, m: number, d: number): string | undefined {
117 const date = new Date(y, m - 1, d)
118 return date.getMonth() === m - 1 && date.getDate() === d ? ymd(date) : undefined
119}
120
121// Priority and due words are read only at the end, so "내일 회의 준비" keeps its text.
122export function parseAddArgs(input: string, today: Date): Fields {
123 const words = input.trim().split(/\s+/).filter(Boolean)
124 const fields: Fields = { text: '' }
125 while (words.length > 1) {
126 const last = words.at(-1) ?? ''
127 const pair = words.slice(-2).join(' ')
128 const p = /^!(high|mid|low)$/.exec(last)?.[1] as Priority | undefined
129 if (p && !fields.priority) {
130 fields.priority = p
131 words.pop()
132 } else if (!fields.due && parseDue(pair, today)) {
133 fields.due = parseDue(pair, today)
134 words.splice(-2)
135 } else if (!fields.due && parseDue(last, today)) {
136 fields.due = parseDue(last, today)
137 words.pop()
138 } else break
139 }
140 fields.text = words.join(' ')
141 return fields
142}
143
144const toIndex = (s: string | undefined): number => (s && /^\d+$/.test(s) ? Number(s) : NaN)
145
146// -p picks the project list and -g the global one; with neither, fallback.
147export function parseCommand(args: string, today: Date, fallback: Scope): Command {
148 const words = args.trim().split(/\s+/).filter(Boolean)
149 const scope: Scope = words.includes('-p') ? 'project' : words.includes('-g') ? 'global' : fallback
150 const [verb, ...rest] = words.filter(w => w !== '-p' && w !== '-g')
151 if (!verb) return { kind: 'toggle' }
152 if (verb === 'add') {
153 const fields = parseAddArgs(rest.join(' '), today)
154 return fields.text ? { kind: 'add', scope, fields } : { kind: 'error', code: 'usageAdd' }
155 }
156 if (verb !== 'done' && verb !== 'rm' && verb !== 'edit') return { kind: 'unknown', verb }
157 const n = toIndex(rest[0])
158 if (Number.isNaN(n) || n < 1) return { kind: 'error', code: verb === 'edit' ? 'usageEdit' : 'usageDoneRm' }
159 if (verb === 'edit') return { kind: 'edit', scope, n, fields: parseAddArgs(rest.slice(1).join(' '), today) }
160 return { kind: verb, scope, n }
161}
162
163// M/D for display; the year shows only when it differs from today's.
164export function shortDate(due: string, today: Date): string {
165 const [y, m, d] = due.split('-')
166 return `${y === String(today.getFullYear()) ? '' : `${y}/`}${Number(m)}/${Number(d)}`
167}
168
169export const MARK: Record<Priority, string> = { high: '!!!', mid: '!! ', low: '! ' }
170types/index.d.ts 12 lines1export type Scope = 'global' | 'project'
2
3declare module 'claude-code' {
4 interface PluginState {
5 todo: {
6 tab: Scope | null
7 selected: number | null
8 editing: boolean
9 }
10 }
11}
12