Keeps a running list of open items from the conversation and shows it above the prompt.

English | 繁體中文
A Claude Code mod that keeps a running list of the open items in a conversation and shows it in a band above the prompt.
Long sessions leave decisions, promised follow-ups, and unanswered questions far back in the conversation, where they are easy to drop. The mod collects them as the conversation goes, so they stay in sight until they are settled.

sonnet alias, so the model follows the installed Claude Code version) and asks which current items the turn resolved and which new ones it opened. Subagent turns do not count.tasks.md stay off the list.cleanupPeriodDays, the setting for how long Claude Code keeps transcripts (30 days by default), is dropped. /clear empties the list.Each answered turn costs one extra Sonnet call. /todos refresh forks the main conversation, so it runs on the main model with the whole context.
Claude Code with mod support (>= 2.1.290).
Install it from this repository's marketplace:
claude plugin marketplace add cldotdev/claude-todo-list
claude plugin install todo-list@claude-todo-list
/todos Command| Command | Effect |
|---|---|
/todos | Lists the open items with their details. |
/todos refresh | Rebuilds the list from the whole conversation. |
/todos clear | Empties the list. |
/todos delete <numbers> | Deletes items by number, such as 2, 1-3, or 1,4 6. A number past the end of the list cancels the whole delete. |
/todos <prompt> | Sends the prompt with every item quoted and numbered above it, so the prompt can say "do 1 and 2, skip 3". |
The band above the prompt shows the numbered list while it has items.
| Key | Action |
|---|---|
Ctrl+X Tab | Focus the band. |
j/k, Tab/Shift+Tab | Move to the next or previous item. |
s | Select or unselect the focused item. |
a | Select every item, or clear the selection when every item is already selected. |
p | Paste the selected items, or the focused item when none is selected, into the prompt box. Each is quoted with its number in the band. |
o/Enter | Show the focused item's detail, or go back to the list. |
b | Ask /btw about the focused item. While Claude is working, the answer waits for the current turn to end. |
d | Delete the focused item; a second d confirms and Esc cancels. |
Esc | Return to the prompt. The band goes back to the list if it was showing an item's detail. |
| Path | Contents |
|---|---|
hooks/register.tsx | Event hooks, the /todos command, and the band. |
hooks/list.ts | Model prompts and reply parsing. |
types/index.d.ts | The mod's state contract. |
tests/ | Tests run by claude plugin test. |
claude plugin validate .
claude plugin test .
To try local changes, start Claude Code with the clone loaded for that session:
claude --plugin-dir /path/to/claude-todo-list
The clone replaces an installed todo-list@claude-todo-list for that session, so there is no need to disable or uninstall it first.
hooks/register.tsx 719 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PendingTurn, TodoItem, TodoListState } from '../types'
5import {
6 MAX_DONE,
7 SYSTEM,
8 applyChanges,
9 isItem,
10 incrementalPrompt,
11 normalizeParens,
12 parseItems,
13 parseNumbers,
14 prefixLines,
15 refreshPrompt,
16} from './list'
17
18const MODEL = 'sonnet'
19const STORE_PREFIX = 'session:'
20const DAY_MS = 24 * 60 * 60 * 1000
21// Claude Code's own default for cleanupPeriodDays.
22const DEFAULT_CLEANUP_DAYS = 30
23const COMPLETE_TIMEOUT_MS = 60_000
24// Rows take no hotkey: a digit hotkey also fires from an empty prompt, so a
25// message starting with "1." would quote the first item. A letter fires only
26// while the band holds the focus.
27const NEXT_KEY = 'j'
28const PREVIOUS_KEY = 'k'
29const DETAILS_KEY = 'o'
30const SELECT_KEY = 's'
31const SELECT_ALL_KEY = 'a'
32const PASTE_KEY = 'p'
33const ASK_KEY = 'b'
34const DELETE_KEY = 'd'
35// Not hotkeys: Enter presses the focused row's Button, which shows its detail.
36const ENTER_KEY = 'Enter'
37const LEAVE_KEY = 'Esc'
38// Characters the terminal draws two cells wide: CJK, Hangul, and full-width forms.
39const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/
40
41const charWidth = (ch: string) => (WIDE.test(ch) ? 2 : 1)
42const cellWidth = (text: string) => [...text].reduce((n, ch) => n + charWidth(ch), 0)
43
44// The longest start of `text` that fits in `cells`, with an ellipsis.
45function fit(text: string, cells: number): string {
46 let used = 1
47 let out = ''
48 for (const ch of text) {
49 used += charWidth(ch)
50 if (used > cells) {
51 break
52 }
53 out += ch
54 }
55 return `${out}…`
56}
57
58// A trailing parenthetical, such as an item's caveat, drawn apart from the rest.
59const NOTE = /^(.*?)\s*(\([^()]*\))$/
60const ROW_PREFIX = 'item-'
61// How often the band checks whether the person has left it with Esc.
62const LEAVE_CHECK_MS = 100
63// The width of a row's focus marker and the space after it, so the title and
64// the help line start where the item numbers do.
65const GUTTER = ' '
66// A theme key, so a selected row follows the person's theme.
67const SELECTED_COLOR = 'suggestion'
68// Palette index 1 (red), so the terminal theme picks the shade. A plugin's
69// color may not hold a colon, which rules out `ansi:red`.
70const DELETE_COLOR = 'ansi256(1)'
71
72const items = atom({ plugin: 'todo-list', key: 'items' } as const, [])
73const done = atom({ plugin: 'todo-list', key: 'done' } as const, [])
74// Titles: the item the band's focus ring stands on ('' while the ring is
75// outside the band), and the item whose detail the band shows.
76const focused = atom({ plugin: 'todo-list', key: 'focused' } as const, '')
77const detailed = atom({ plugin: 'todo-list', key: 'detailed' } as const, '')
78// The title of the item the first delete press armed; the next press deletes it.
79const deleting = atom({ plugin: 'todo-list', key: 'deleting' } as const, '')
80// Titles of the items picked for a paste. Session-only: never written to the store.
81const selected = atom({ plugin: 'todo-list', key: 'selected' } as const, [] as string[])
82// The last main-loop answer, which the next user message often replies to.
83const lastAnswer = atom({ plugin: 'todo-list', key: 'lastAnswer' } as const, '')
84// Finished turns not yet applied. They live in $.state, not the module, because
85// a hot reload cancels the module's timers: a turn queued as the reload lands
86// waits here, and session.start, which runs again after the reload, picks it up.
87const pending = atom({ plugin: 'todo-list', key: 'pending' } as const, [] as PendingTurn[])
88
89// An entry saved before items had a detail holds plain strings.
90type Stored = { items: (string | TodoItem)[]; done: string[]; updatedAt: number }
91
92const isStored = (value: unknown): value is Stored =>
93 typeof value === 'object' &&
94 value !== null &&
95 Array.isArray((value as Stored).items) &&
96 (value as Stored).items.every(one => typeof one === 'string' || isItem(one)) &&
97 Array.isArray((value as Stored).done) &&
98 typeof (value as Stored).updatedAt === 'number'
99
100const migrate = (stored: (string | TodoItem)[]): TodoItem[] =>
101 stored.map(one => (typeof one === 'string' ? { title: one, detail: '' } : one))
102
103// A saved list lives as long as Claude Code keeps the session's transcript,
104// since a session it has deleted cannot be resumed. Null for an invalid
105// setting, under which Claude Code pauses its own cleanup.
106async function maxAgeMs($: EngineInterface): Promise<number | null> {
107 const { cleanupPeriodDays: days = DEFAULT_CLEANUP_DAYS } = await $.settings.read()
108 return typeof days === 'number' && days >= 1 ? days * DAY_MS : null
109}
110
111const userTexts = new Map<string, string>()
112// Runs list updates one at a time, so two turns never race.
113let queue: Promise<unknown> = Promise.resolve()
114// Bumped by /clear so a job that started before it drops its result.
115let generation = 0
116// Whether a leave check is scheduled, so focus moves start only one.
117let isWatchingLeave = false
118
119// The selection as the list holds it now: a deleted item drops out.
120const pickedFrom = (list: readonly TodoItem[], titles: readonly string[]) =>
121 list.filter(one => titles.includes(one.title))
122
123async function setSelected($: EngineInterface, next: string[]) {
124 const same = (list: string[]) => list.length === next.length && list.every((title, i) => title === next[i])
125 if (!same(await read($, selected))) {
126 await update($, selected, () => next)
127 }
128}
129
130async function disarm($: EngineInterface) {
131 if ((await read($, deleting)) !== '') {
132 await update($, deleting, () => '')
133 }
134}
135
136// The dot, the selection, a pending delete, and the detail view last only
137// while the band holds the focus.
138async function leave($: EngineInterface) {
139 const [current, shown] = await Promise.all([read($, focused), read($, detailed)])
140 if (current !== '') {
141 await update($, focused, () => '')
142 }
143 if (shown !== '') {
144 await update($, detailed, () => '')
145 }
146 await setSelected($, [])
147 await disarm($)
148}
149
150// The band raises no event when the person leaves it with Esc. While the dot
151// is shown, a timer asks the engine to put the ring back on the focused row;
152// the engine refuses once the band no longer holds the keys, and the refusal
153// counts as leaving.
154function watchLeave($: EngineInterface, requestId: string) {
155 if (isWatchingLeave) {
156 return
157 }
158 isWatchingLeave = true
159 const check = async () => {
160 const [list, current] = await Promise.all([read($, items), read($, focused)])
161 const index = list.findIndex(one => one.title === current)
162 if (index !== -1) {
163 const { deny } = await $.ui.focus({ requestId, key: `${ROW_PREFIX}${index}` })
164 if (deny === undefined) {
165 schedule()
166 return
167 }
168 await leave($)
169 }
170 isWatchingLeave = false
171 }
172 const schedule = () => {
173 $.clock.after(LEAVE_CHECK_MS, () => {
174 check().catch(() => {
175 isWatchingLeave = false
176 })
177 })
178 }
179 schedule()
180}
181
182function enqueue<T>(job: () => Promise<T>): Promise<T> {
183 const run = queue.then(job)
184 queue = run.catch(() => undefined)
185 return run
186}
187
188async function persist($: EngineInterface) {
189 const key = STORE_PREFIX + (await $.session.id())
190 const state: TodoListState = {
191 items: await read($, items),
192 done: await read($, done),
193 }
194 if (state.items.length === 0 && state.done.length === 0) {
195 await $.store.delete(key)
196 return
197 }
198 await $.store.set(key, { ...state, updatedAt: await $.clock.now() })
199}
200
201async function commit($: EngineInterface, next: TodoItem[]) {
202 await update($, items, () => next)
203 await persist($)
204}
205
206async function tick($: EngineInterface, titles: readonly string[]) {
207 const ticked = new Set(titles)
208 await update($, items, list => list.filter(one => !ticked.has(one.title)))
209 await update($, done, list => [...list.filter(one => !ticked.has(one)), ...titles].slice(-MAX_DONE))
210 await persist($)
211}
212
213// `1. ` through `10. `, padded to the widest number so every title starts in
214// one column.
215const labelWidth = (last: number) => `${last}.`.length + 1
216const numberLabel = (number: number, width: number) => `${number}.`.padEnd(width)
217
218// The detail lines up under the title.
219function quoteBlock(item: TodoItem, number: number, width: number): string {
220 const label = numberLabel(number, width)
221 const text =
222 item.detail === ''
223 ? `${label}${item.title}`
224 : `${label}${item.title}\n${prefixLines(item.detail, ' '.repeat(label.length))}`
225 return `${prefixLines(text, '> ')}\n`
226}
227
228// Numbered as the list shows them, so a prompt can say "do 1 and 2, skip 3",
229// with a bare quote line between items.
230function quoteItems(list: readonly TodoItem[], picked: readonly TodoItem[]): string {
231 const numbers = picked.map(one => list.indexOf(one) + 1)
232 const width = labelWidth(Math.max(...numbers))
233 return picked.map((one, i) => quoteBlock(one, numbers[i]!, width)).join('>\n')
234}
235
236async function insertQuote($: EngineInterface, quoted: string): Promise<boolean> {
237 const filled = await $.prompt.fill({ text: `${quoted}\n`, mode: 'insert' })
238 if (!filled.isFilled) {
239 $.ui.toast('Could not insert into the prompt box. Close the dialog and try again.')
240 }
241 return filled.isFilled
242}
243
244// Pastes the selected items, or the focused one when nothing is selected, each
245// numbered as in the band.
246async function paste($: EngineInterface) {
247 await disarm($)
248 const [list, target, titles] = await Promise.all([read($, items), read($, focused), read($, selected)])
249 let picked = pickedFrom(list, titles)
250 if (picked.length === 0) {
251 picked = list.filter(one => one.title === target)
252 }
253 if (picked.length === 0) {
254 return
255 }
256 // A failed paste keeps the selection for the retry the toast asks for.
257 if (await insertQuote($, quoteItems(list, picked))) {
258 await setSelected($, [])
259 }
260}
261
262async function toggleSelected($: EngineInterface) {
263 await disarm($)
264 const [list, target, titles] = await Promise.all([read($, items), read($, focused), read($, selected)])
265 if (!list.some(one => one.title === target)) {
266 return
267 }
268 const rest = pickedFrom(list, titles).map(one => one.title)
269 await setSelected($, rest.includes(target) ? rest.filter(title => title !== target) : [...rest, target])
270}
271
272async function toggleAll($: EngineInterface) {
273 await disarm($)
274 const [list, titles] = await Promise.all([read($, items), read($, selected)])
275 if (list.length === 0) {
276 return
277 }
278 const isAll = pickedFrom(list, titles).length === list.length
279 await setSelected($, isAll ? [] : list.map(one => one.title))
280}
281
282// Asks the built-in /btw about the focused item. It is not awaited: /btw
283// resolves only once it has run, which waits for the turn in progress to end.
284async function askAbout($: EngineInterface, isWorking: boolean) {
285 await disarm($)
286 const target = await read($, focused)
287 if (!(await read($, items)).some(one => one.title === target)) {
288 return
289 }
290 const args = `Tell me more about this open item from our conversation: "${target}". What is it, why is it still open, and what would close it? Answer in the language of the conversation.`
291 $.command.run({ command: 'btw', args }).catch(() => {
292 $.ui.toast('Could not ask /btw about this item.')
293 })
294 if (isWorking) {
295 $.ui.toast('The /btw answer will appear once the current turn ends.')
296 }
297}
298
299// Puts the dot on an item. A delete armed on another item is cancelled, and
300// the detail view walks from one item's detail to the next.
301async function focusItem($: EngineInterface, title: string) {
302 const [current, shownTitle, armed] = await Promise.all([read($, focused), read($, detailed), read($, deleting)])
303 if (current !== title) {
304 await update($, focused, () => title)
305 }
306 if (armed !== '' && armed !== title) {
307 await update($, deleting, () => '')
308 }
309 if (shownTitle !== '' && shownTitle !== title) {
310 await update($, detailed, () => title)
311 }
312}
313
314// Moves the ring to the next or previous row, wrapping around as Tab does. The
315// move skips this plugin's own ui.focus hook, so the dot moves here; left
316// behind, the leave check would pull the ring back to the old row.
317async function moveFocus($: EngineInterface, requestId: string, step: 1 | -1) {
318 const [list, current] = await Promise.all([read($, items), read($, focused)])
319 const index = list.findIndex(one => one.title === current)
320 if (index === -1) {
321 return
322 }
323 const target = (index + step + list.length) % list.length
324 const { deny } = await $.ui.focus({ requestId, key: `${ROW_PREFIX}${target}` })
325 if (deny === undefined) {
326 await focusItem($, list[target]!.title)
327 }
328}
329
330// Switches the band between the list and the focused item's detail.
331async function toggleDetails($: EngineInterface) {
332 await disarm($)
333 if ((await read($, detailed)) !== '') {
334 await update($, detailed, () => '')
335 return
336 }
337 const target = await read($, focused)
338 if ((await read($, items)).some(one => one.title === target)) {
339 await update($, detailed, () => target)
340 }
341}
342
343// The first press arms the focused item; the second deletes it and moves the
344// focus to the next item, or the previous one after the last. The ring tracks
345// its stop by position, so after the last row goes it may stand on a hotkey
346// Button; the leave check puts it back on the focused row.
347async function deleteFocused($: EngineInterface) {
348 const [list, target] = await Promise.all([read($, items), read($, focused)])
349 const index = list.findIndex(one => one.title === target)
350 if (index === -1) {
351 return
352 }
353 if ((await read($, deleting)) !== target) {
354 await update($, deleting, () => target)
355 return
356 }
357 await tick($, [target])
358 await disarm($)
359 const title = (list[index + 1] ?? list[index - 1])?.title ?? ''
360 await update($, focused, () => title)
361 await update($, detailed, shown => (shown === '' ? '' : title))
362}
363
364// Resolves to the new list, or null when the list was left as it was.
365async function applyList(
366 $: EngineInterface,
367 parsed: TodoItem[] | null,
368 gen: number,
369): Promise<TodoItem[] | null> {
370 if (parsed === null || gen !== generation) {
371 return null
372 }
373 // Ticks made while the model was thinking win over its reply.
374 const ticked = new Set(await read($, done))
375 const next = parsed.filter(one => !ticked.has(one.title))
376 // Most turns change nothing; writing anyway would redraw the band and rewrite the store.
377 if (JSON.stringify(next) !== JSON.stringify(await read($, items))) {
378 await commit($, next)
379 }
380 return next
381}
382
383async function incremental($: EngineInterface, turn: PendingTurn, gen: number) {
384 try {
385 const sent = await read($, items)
386 const reply = await $.model.complete({
387 model: MODEL,
388 effort: 'medium',
389 system: SYSTEM,
390 prompt: incrementalPrompt({ ...turn, items: sent, done: await read($, done) }),
391 maxTokens: 8192,
392 timeoutMs: COMPLETE_TIMEOUT_MS,
393 })
394 if (reply.isAnswered) {
395 await applyList($, applyChanges(reply.text, sent), gen)
396 }
397 } catch {
398 // Keep the previous list.
399 }
400}
401
402// Applies the queued turns oldest first. A turn leaves the queue once tried,
403// whatever the outcome, so a reply that never parses cannot retry forever.
404async function runPending($: EngineInterface, gen: number) {
405 for (;;) {
406 const [turn] = await read($, pending)
407 if (turn === undefined) {
408 return
409 }
410 await incremental($, turn, gen)
411 await update($, pending, turns => turns.slice(1))
412 }
413}
414
415const schedulePending = ($: EngineInterface) => {
416 const gen = generation
417 // A timer outlives the dispatch that sets it, so the answer shows without waiting.
418 $.clock.after(0, () => {
419 void enqueue(() => runPending($, gen))
420 })
421}
422
423// The band's own keys work only while it holds the focus.
424function helpLine(isFocused: boolean, isArmed: boolean, isDetailed: boolean, selectedCount: number): string {
425 if (!isFocused) {
426 return 'Ctrl+X Tab to focus'
427 }
428 if (isArmed) {
429 return `${DELETE_KEY} confirm delete · ${LEAVE_KEY} cancel`
430 }
431 const pasting = selectedCount > 0 ? `${PASTE_KEY} paste ${selectedCount}` : `${PASTE_KEY} paste`
432 const toggle = isDetailed ? 'list' : 'details'
433 return `${NEXT_KEY}/${PREVIOUS_KEY} move · ${SELECT_KEY} select · ${SELECT_ALL_KEY} select all · ${pasting} · ${DETAILS_KEY}/${ENTER_KEY} show ${toggle} · ${ASK_KEY} ask /btw · ${DELETE_KEY} delete · ${LEAVE_KEY} leave`
434}
435
436const itemCount = (n: number) => `${n} ${n === 1 ? 'item' : 'items'}`
437
438async function refresh($: EngineInterface, gen: number): Promise<string> {
439 try {
440 const reply = await $.model.fork({ prompt: refreshPrompt(await read($, done)) })
441 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
442 return 'Refresh failed: the main conversation has not answered since startup or /clear, so there is nothing to fork. Send a message and try again.'
443 }
444 if (!reply.isAnswered) {
445 return `Refresh failed (${reply.reason}); the list is unchanged.`
446 }
447 const next = await applyList($, parseItems(reply.text), gen)
448 return next === null
449 ? 'Refresh failed (unparseable reply); the list is unchanged.'
450 : `Refreshed the list: ${itemCount(next.length)}.`
451 } catch {
452 return 'Refresh failed; the list is unchanged.'
453 }
454}
455
456export const register: Register = on => {
457 on('session.start', async ($, e, next) => {
458 await $.command.register({
459 name: 'todos',
460 description: 'Show, refresh or clear the open items list, delete some, or send them all with a prompt',
461 argumentHint: '[refresh|clear|delete <numbers>|<prompt>]',
462 })
463
464 const now = await $.clock.now()
465 const maxAge = await maxAgeMs($)
466 for (const key of await $.store.keys()) {
467 if (!key.startsWith(STORE_PREFIX)) {
468 continue
469 }
470 const stored = await $.store.get(key)
471 if (!isStored(stored) || (maxAge !== null && now - stored.updatedAt > maxAge)) {
472 await $.store.delete(key)
473 }
474 }
475
476 await update($, focused, () => '')
477 await update($, deleting, () => '')
478 if ((await read($, pending)).length > 0) {
479 schedulePending($)
480 }
481
482 const mine = await $.store.get(STORE_PREFIX + (await $.session.id()))
483 if (isStored(mine)) {
484 await update($, items, () => migrate(mine.items))
485 await update($, done, () => mine.done)
486 }
487
488 return next(e)
489 })
490
491 on('session.end', async ($, e, next) => {
492 if (e.reason === 'clear') {
493 generation += 1
494 userTexts.clear()
495 await update($, lastAnswer, () => '')
496 await update($, pending, () => [])
497 await update($, items, () => [])
498 await update($, done, () => [])
499 await update($, detailed, () => '')
500 await update($, deleting, () => '')
501 await update($, selected, () => [])
502 await $.store.delete(STORE_PREFIX + e.sessionId)
503 }
504
505 return next(e)
506 })
507
508 // Typing in the prompt box leaves the band without waiting for the leave check.
509 on('prompt.edit', async ($, e, next) => {
510 await leave($)
511
512 return next(e)
513 })
514
515 on('prompt.submit', async ($, e, next) => {
516 await leave($)
517
518 return next(e)
519 })
520
521 on('turn.start', (_$, e, next) => {
522 userTexts.set(e.turnId, e.text)
523
524 return next(e)
525 })
526
527 on('turn.complete', async ($, e, next) => {
528 const userText = userTexts.get(e.turnId) ?? ''
529 userTexts.delete(e.turnId)
530
531 if (e.agentId === undefined && e.reason === 'answer') {
532 const turn = { previousAnswer: await read($, lastAnswer), userText, answer: e.answer }
533 await update($, lastAnswer, () => e.answer)
534 await update($, pending, turns => [...turns, turn])
535 schedulePending($)
536 }
537
538 return next(e)
539 })
540
541 on('command.run', { command: 'todos' }, async ($, e) => {
542 const arg = e.args.trim()
543
544 if (arg === 'refresh') {
545 const gen = generation
546 return { text: await enqueue(() => refresh($, gen)) }
547 }
548 if (arg === 'clear') {
549 await enqueue(() => commit($, []))
550 return { text: 'Cleared the list.' }
551 }
552 const list = await read($, items)
553 // Any argument led by the word delete is a delete, so a mistyped number
554 // list is refused instead of going out as a prompt.
555 const deleteMatch = /^delete(?:\s+|$)(.*)$/s.exec(arg)
556 if (deleteMatch !== null) {
557 const parsed = parseNumbers(deleteMatch[1] ?? '', list.length)
558 if (parsed === null) {
559 return { text: 'Usage: /todos delete <numbers>, such as 2, 1-3, or 1,4 6. Nothing was removed.' }
560 }
561 if ('missing' in parsed) {
562 return { text: `No item ${parsed.missing}. Nothing was removed.` }
563 }
564 const picked = new Set(parsed.numbers)
565 const removed = list.flatMap((one, i) => (picked.has(i + 1) ? [{ number: i + 1, title: one.title }] : []))
566 await tick($, removed.map(one => one.title))
567 const lines = removed.map(one => `${one.number}. ${one.title}`)
568 return { text: [`Removed ${itemCount(removed.length)}:`, ...lines].join('\n') }
569 }
570 if (arg !== '') {
571 if (list.length === 0) {
572 return { text: 'No open items; the prompt was not sent.' }
573 }
574 const text = `${quoteItems(list, list)}\n${arg}`
575 // The engine refuses a submit from command.run, which holds the turn the
576 // prompt would wait for; a timer sends it once the command has answered.
577 $.clock.after(0, () => {
578 void $.prompt.submit({ text, asUser: true })
579 })
580 return { text: `Sent the prompt with ${itemCount(list.length)}.` }
581 }
582
583 if (list.length === 0) {
584 return { text: 'No open items.' }
585 }
586 const width = labelWidth(list.length)
587 const lines = list.flatMap((one, i) => {
588 const title = `${numberLabel(i + 1, width)}${one.title}`
589 return one.detail === '' ? [title] : [title, prefixLines(one.detail, ' '.repeat(width))]
590 })
591 return { text: ['Todos', ...lines].join('\n') }
592 })
593
594 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
595 if (e.element === undefined) {
596 await leave($)
597 return next(e)
598 }
599 if (e.plugin !== 'todo-list') {
600 return next(e)
601 }
602 const [list, current] = await Promise.all([read($, items), read($, focused)])
603 const last = list.length - 1
604 let index = Number(e.element.slice(ROW_PREFIX.length))
605 // The hidden hotkey Buttons are ring stops too. The event carries no
606 // direction, so the handler infers it from the row the ring leaves: Tab
607 // off the last row wraps to the first, and Shift+Tab off the first to the
608 // last.
609 if (!e.element.startsWith(ROW_PREFIX)) {
610 index = list[last]?.title === current ? 0 : last
611 }
612 const item = list[index]
613 if (item === undefined) {
614 return next(e)
615 }
616 await focusItem($, item.title)
617 watchLeave($, e.requestId)
618
619 return next({ ...e, element: `${ROW_PREFIX}${index}` })
620 })
621
622 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
623 const [list, current, openTitle, armed, selectedTitles] = await Promise.all([
624 read($, items),
625 read($, focused),
626 read($, detailed),
627 read($, deleting),
628 read($, selected),
629 ])
630 const picked = pickedFrom(list, selectedTitles)
631 const { isWorking } = e.props
632 // While one item's detail is shown, the band shows that item alone.
633 const opened = list.find(one => one.title === openTitle)
634 if (list.length === 0 || e.props.hasSurvey) {
635 return next(e)
636 }
637
638 const { Box, Button, Text } = $.ui.resolve(e)
639 const numberWidth = labelWidth(list.length)
640
641 return (
642 <Box flexDirection="column">
643 <Text dimColor>{'─'.repeat(e.props.bodyColumns)}</Text>
644 <Text bold>{GUTTER}Todos</Text>
645 {list.map((item, i) => {
646 // The focus ring tracks its stop by position, so every row keeps its
647 // Button in every view; dropping the hidden rows' Buttons would slide
648 // the ring onto the next stop, the first hotkey Button.
649 const button = (
650 <Button key={`${ROW_PREFIX}${i}`} label=" " plain onPress={() => toggleDetails($)} />
651 )
652 if (opened !== undefined && item !== opened) {
653 return (
654 <Box key={`row-${i}`} width={0} height={0} overflow="hidden">
655 {button}
656 </Box>
657 )
658 }
659 // Items stored before parentheses were normalized still need normalizing here.
660 const shown = normalizeParens(item.title)
661 const [, main = shown] = NOTE.exec(shown) ?? []
662 const room = e.props.bodyColumns - GUTTER.length - numberWidth
663 const isFocused = item.title === current
664 const color = picked.includes(item) ? SELECTED_COLOR : undefined
665 const isLong = !isFocused && opened === undefined && cellWidth(shown) > room
666 // The cut may end inside the note.
667 const cut = isLong ? fit(shown, room) : shown
668 const head = cut.slice(0, main.length)
669 const tail = cut.slice(main.length)
670 return (
671 <Box key={`row-${i}`} flexDirection="row">
672 {/* The focus ring always inverts a Button, so the row's Button takes
673 no cells and the dot beside it marks the focus instead. */}
674 <Box width={0} overflow="hidden">
675 {button}
676 </Box>
677 <Text>{isFocused ? '• ' : ' '}</Text>
678 <Text color={color}>{numberLabel(i + 1, numberWidth)}</Text>
679 <Text color={color} wrap={isLong ? 'truncate-end' : 'wrap'}>
680 {head}
681 {tail !== '' && <Text dimColor>{tail}</Text>}
682 </Text>
683 {item.title === armed && (
684 <Text bold color={DELETE_COLOR}>
685 {' '}
686 delete?
687 </Text>
688 )}
689 </Box>
690 )
691 })}
692 {opened !== undefined && (
693 <Box flexDirection="row">
694 <Text>{' '.repeat(GUTTER.length + numberWidth)}</Text>
695 <Text wrap="wrap">{opened.detail || '(No detail. Run /todos refresh to add one.)'}</Text>
696 </Box>
697 )}
698 <Box flexDirection="row">
699 <Text dimColor>
700 {GUTTER}
701 {helpLine(current !== '', armed !== '', opened !== undefined, picked.length)}
702 </Text>
703 {/* Holds the hotkeys out of sight: a drawn hotkey takes the accent color. */}
704 <Box width={0} overflow="hidden">
705 <Button key="next" label="next" hotkey={NEXT_KEY} plain onPress={() => moveFocus($, e.requestId, 1)} />
706 <Button key="previous" label="previous" hotkey={PREVIOUS_KEY} plain onPress={() => moveFocus($, e.requestId, -1)} />
707 <Button key="details" label="details" hotkey={DETAILS_KEY} plain onPress={() => toggleDetails($)} />
708 <Button key="paste" label="paste" hotkey={PASTE_KEY} plain onPress={() => paste($)} />
709 <Button key="select" label="select" hotkey={SELECT_KEY} plain onPress={() => toggleSelected($)} />
710 <Button key="select-all" label="select all" hotkey={SELECT_ALL_KEY} plain onPress={() => toggleAll($)} />
711 <Button key="ask" label="ask" hotkey={ASK_KEY} plain onPress={() => askAbout($, isWorking)} />
712 <Button key="delete" label="delete" hotkey={DELETE_KEY} plain onPress={() => deleteFocused($)} />
713 </Box>
714 </Box>
715 </Box>
716 )
717 })
718}
719hooks/list.ts 206 lines1import type { TodoItem } from '../types'
2
3const MAX_ITEMS = 99
4export const MAX_DONE = MAX_ITEMS
5const MAX_ITEM_LENGTH = 80
6export const MAX_DETAIL_LENGTH = 1000
7// A guard against pasted logs, far above any normal turn.
8const MAX_TEXT = 100_000
9
10const DEFINITION = `An open item is something still pending in this conversation:
11- Work the user or the agent deferred (for example "later", "not now", "先不做", "之後再處理", "next step").
12- A follow-up the agent promised, or a check it said it had not run yet.
13- A decision or question raised in the discussion and not settled yet, whoever raised it: a choice among options waiting for the user, a question either side asked that has no answer or conclusion yet, or a point left to decide later.
14
15Do not list:
16- The step being carried out right now. A pending decision or open question is never this step.
17- Anything raised and finished within the same turn.
18- Tasks already listed in an OpenSpec tasks.md.
19
20Remove an item once the conversation shows it is done or the user drops it.`
21
22const ITEM_STYLE = `Write each item as an object with a "title" and a "detail", both in the language the agent answers in. Call the side that answers the user "the agent", never "the assistant"; in Chinese, keep it as the English word "agent", lowercase mid-sentence.
23- "title": one short line (at most ${MAX_ITEM_LENGTH} characters). Use half-width parentheses with a space before the opening one. End a title that carries a status, such as awaiting the user's reply or not yet tested, with that status in parentheses, written in the title's language, and nothing after it, as in "<what to do> (<status>)".
24- "detail": what to do, why it matters, and which part of the discussion it came from (at most ${MAX_DETAIL_LENGTH} characters, newlines included), so a reader who lost the conversation can act on it. Do not repeat the title. Put each distinct point on its own line, separated by a newline in the JSON string, as plain text with no Markdown bullets or headings.`
25
26const LIST_FORMAT = `Reply with the complete list as a JSON array of {"title", "detail"} objects and nothing else: no code fence, no commentary, for example [{"title":"<title>","detail":"<detail>"}]. Reply with [] when nothing is open.
27${ITEM_STYLE}`
28
29const RESOLVE = `For every current item, decide whether this turn resolves it. Remove it when the turn answers it, decides it, completes it, or makes it moot, even when the turn does not name it. A user's reply to a question, or a choice among offered options, resolves that question. Keep an item only when it is still open after this turn.`
30
31const CHANGE_FORMAT = `Reply with a JSON object and nothing else: no code fence, no commentary. "remove" lists the numbers of the current items this turn resolves, and "add" lists the new open items as {"title", "detail"} objects, for example {"remove":[2],"add":[{"title":"<title>","detail":"<detail>"}]}. Reply with {"remove":[],"add":[]} when nothing changed.
32${ITEM_STYLE}`
33
34export const SYSTEM = `You maintain a to-do list of open items for a coding conversation.\n\n${DEFINITION}\n\n${RESOLVE}\n\n${CHANGE_FORMAT}`
35
36const HEAD_TEXT = 25_000
37
38// Keeps the tail, where an answer usually ends with the question it leaves open.
39const clip = (text: string) =>
40 text.length > MAX_TEXT
41 ? `${text.slice(0, HEAD_TEXT)}\n[truncated]\n${text.slice(HEAD_TEXT - MAX_TEXT)}`
42 : text
43
44const removedBlock = (done: readonly string[]) =>
45 `Items the user removed (never add them back):\n${done.length === 0 ? '(none)' : JSON.stringify(done)}`
46
47// Prefixes every line of a multi-line text, such as an item's detail.
48export const prefixLines = (text: string, prefix: string) =>
49 text
50 .split('\n')
51 .map(line => `${prefix}${line}`)
52 .join('\n')
53
54const numbered = (items: readonly TodoItem[]) =>
55 items.length === 0
56 ? '(none)'
57 : items
58 .map((one, i) => `${i + 1}. ${one.title}${one.detail === '' ? '' : `\n${prefixLines(one.detail, ' ')}`}`)
59 .join('\n')
60
61export function incrementalPrompt(input: {
62 items: readonly TodoItem[]
63 done: readonly string[]
64 previousAnswer: string
65 userText: string
66 answer: string
67}): string {
68 return [
69 `Current list:\n${numbered(input.items)}`,
70 removedBlock(input.done),
71 `Agent final answer of the previous turn, which this turn's user message may reply to:\n${clip(input.previousAnswer) || '(none)'}`,
72 `User message of this turn:\n${clip(input.userText) || '(none)'}`,
73 `Agent final answer of this turn:\n${clip(input.answer) || '(none)'}`,
74 'Return the changes.',
75 ].join('\n\n')
76}
77
78export function refreshPrompt(done: readonly string[]): string {
79 return [
80 'This request comes from the todo-list plugin, not from the user. Do not continue the conversation or call tools; answer only this request. Rebuild the to-do list of open items from the whole conversation above.',
81 DEFINITION,
82 removedBlock(done),
83 LIST_FORMAT,
84 ].join('\n\n')
85}
86
87const FULL_WIDTH_PUNCTUATION = ',。、;:!?'
88
89// Turns full-width parentheses into half-width ones, spaced as Taiwan prose
90// spaces them: a space outside each, none next to full-width punctuation.
91const SPACED_OPEN = new RegExp(`([${FULL_WIDTH_PUNCTUATION}])\\s+\\(`, 'g')
92const UNSPACED_CLOSE = new RegExp(`\\)(?=[^\\s${FULL_WIDTH_PUNCTUATION})」』])`, 'g')
93
94export const normalizeParens = (text: string) =>
95 text
96 .replace(/\s*(\s*/g, ' (')
97 .replace(/\s*)/g, ')')
98 .replace(SPACED_OPEN, '$1(')
99 .replace(UNSPACED_CLOSE, ') ')
100 .trim()
101
102const tidy = (text: string) => normalizeParens(text.replace(/\s+/g, ' '))
103
104// Keeps the line breaks of a detail, tidying each line and dropping empty ones.
105const tidyDetail = (text: string) =>
106 text
107 .replace(/\r\n?/g, '\n')
108 .split('\n')
109 .map(tidy)
110 .filter(line => line !== '')
111 .join('\n')
112 .slice(0, MAX_DETAIL_LENGTH)
113 .trimEnd()
114
115// Normalizes, trims, deduplicates by title and caps a list of items.
116function cleanItems(items: readonly TodoItem[]): TodoItem[] {
117 const seen = new Set<string>()
118 const kept: TodoItem[] = []
119 for (const one of items) {
120 const title = tidy(one.title).slice(0, MAX_ITEM_LENGTH)
121 if (title !== '' && !seen.has(title)) {
122 seen.add(title)
123 kept.push({ title, detail: tidyDetail(one.detail) })
124 }
125 }
126 return kept.slice(0, MAX_ITEMS)
127}
128
129function parseJson(text: string, open: string, close: string): unknown {
130 const start = text.indexOf(open)
131 const end = text.lastIndexOf(close)
132 if (start === -1 || end < start) {
133 return undefined
134 }
135 try {
136 return JSON.parse(text.slice(start, end + 1))
137 } catch {
138 return undefined
139 }
140}
141
142export const isItem = (value: unknown): value is TodoItem =>
143 typeof value === 'object' &&
144 value !== null &&
145 typeof (value as TodoItem).title === 'string' &&
146 typeof (value as TodoItem).detail === 'string'
147
148const isItems = (value: unknown): value is TodoItem[] => Array.isArray(value) && value.every(isItem)
149
150// Returns null when the reply is not a JSON array of {title, detail} objects.
151export function parseItems(text: string): TodoItem[] | null {
152 const parsed = parseJson(text, '[', ']')
153 return isItems(parsed) ? cleanItems(parsed) : null
154}
155
156// Resolves item numbers such as "2", "1-3" or "1,4 6" against a list of
157// `count` items. Returns the numbers ascending and distinct, the smallest
158// number past the list when there is one, or null when the text is not such
159// a list.
160export function parseNumbers(
161 text: string,
162 count: number,
163): { numbers: number[] } | { missing: number } | null {
164 const tokens = text.split(/[\s,]+/).filter(token => token !== '')
165 if (tokens.length === 0) {
166 return null
167 }
168 const numbers = new Set<number>()
169 let missing = Infinity
170 for (const token of tokens) {
171 const match = /^(\d+)(?:-(\d+))?$/.exec(token)
172 if (match === null) {
173 return null
174 }
175 const from = Number(match[1])
176 const to = Number(match[2] ?? match[1])
177 if (from < 1 || to < from) {
178 return null
179 }
180 if (to > count) {
181 missing = Math.min(missing, Math.max(from, count + 1))
182 }
183 for (let n = from; n <= Math.min(to, count); n += 1) {
184 numbers.add(n)
185 }
186 }
187 return missing === Infinity ? { numbers: [...numbers].sort((a, b) => a - b) } : { missing }
188}
189
190// Applies a {"remove": [...], "add": [...]} reply to the list it was asked
191// about; returns null when the reply has another shape or names no such item.
192export function applyChanges(text: string, items: readonly TodoItem[]): TodoItem[] | null {
193 const parsed = parseJson(text, '{', '}') as { remove?: unknown; add?: unknown } | undefined
194 const remove = parsed?.remove
195 const add = parsed?.add
196 if (
197 !Array.isArray(remove) ||
198 !remove.every(n => Number.isInteger(n) && n >= 1 && n <= items.length) ||
199 !isItems(add)
200 ) {
201 return null
202 }
203 const gone = new Set(remove as number[])
204 return cleanItems([...items.filter((_, i) => !gone.has(i + 1)), ...add])
205}
206types/index.d.ts 25 lines1export type TodoItem = { title: string; detail: string }
2
3// One finished turn waiting to update the list.
4export type PendingTurn = { previousAnswer: string; userText: string; answer: string }
5
6export type TodoListState = {
7 items: TodoItem[]
8 done: string[]
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'todo-list': {
14 items: TodoItem[]
15 done: string[]
16 focused: string
17 detailed: string
18 deleting: string
19 selected: string[]
20 lastAnswer: string
21 pending: PendingTurn[]
22 }
23 }
24}
25