Claude records unfinished work in Docs/todos.md; /todos shows the open list.

A Claude Code mod that keeps a to-do list of unfinished work in your project. When Claude leaves a partial implementation, hits a design question nobody has decided, or hears you put something off, it records a reminder in Docs/todos.md. A later session, or you, picks it up from there.
/todos opens a pane to finish, reprioritize, undo, or hand reminders to Claude./todos --scan fills the list from the TODOs and stubs already in the code.Contents: Install · Getting started · How it works · Fill the list · The line above the prompt · The /todos pane · Commands · Token use · Settings · Update
/plugin install reminders --marketplace noash-xrc/claude-tools
Answer y to add the marketplace, then pick a scope.
/todos --scan once, so the list starts with the unfinished work already in the project. See Fill the list./todos to see the list, mark items done, or hand one to Claude.Claude records reminders on its own. It calls add_reminder the moment it leaves a stub or a placeholder value, meets an undecided question, or hears you say "later" or "not now". Each reminder has a priority, a date, and usually a pointer to the code, such as src/net/retry.ts:42:
Each reminder is written to stand on its own. Before writing one, Claude loads the writing-reminders skill, so the line makes sense to a later session that has none of the original conversation. The file stays in one language: that of the reminders already in it, else the project's docs, else your note.
Claude keeps the list accurate. When Claude edits a file that an open reminder points at, it is told about that reminder along with the edit's result. If the change finishes the work, Claude marks the reminder done, and a toast names it: Done: Checkout crashes when the cart is empty. If the change makes the reminder inaccurate, Claude tells you. Each reminder is pointed out once per session.
The file is plain Markdown. Reminders live in Docs/todos.md (see Settings to change it), one per line, so you can read it, edit it by hand, and commit it:
## Open
- [ ] P1 2026-10-06 Checkout crashes when the cart is empty. (at src/checkout/order.ts:42)
- [ ] P3 2026-10-07 Settings page spacing differs from the rest of the app.
## Done
- [x] P1 2026-10-01 Login redirect loops on expired session. (done 2026-10-07)
The Done section keeps the last 20. Hand edits are fine as long as each line keeps this format.
A new install starts with no reminders. There are two ways to add them yourself.
/todos --scan has Claude collect the unfinished work already in the project:
todo:, @todo, TODO(name)), in every comment syntax the project usesClaude searches the files git tracks, skipping vendored and generated code and build output. It reads the code around each finding, drops what is stale, and merges findings about the same work. It adds at most 30 reminders, the ones that matter most, and skips any an open reminder already covers. It changes no code. Its reply says how many it added and what it left out.
/todos --scan src/api scans only that path. Running the scan again later adds only what is new.
The scan costs tokens. It is the one part of the plugin that uses a noticeable amount: from about 10,000 tokens in a small project to 100,000 or more in a large one. Scanning one folder at a time keeps each run smaller. See Token use.
/todos add csv export breaks on commas sends your rough note to Claude. Claude words it, looks up the code for exact names and the pointer, picks the priority, and records it. The work itself waits for later.
You can also use the Add button in the pane, or tell Claude "add a reminder: ..." in the chat.
Todos: 6 open · 2 blocking · /todos
> _
The line sits at the top right of the prompt, with the blocking count in red. If another mod draws there too, such as a usage meter, its drawing stays and the line goes under it. It updates whenever a reminder is added, finished or moved, and hides itself when nothing is open. Before a project has any reminders, it reads No reminders yet · /todos --scan collects the TODOs in the code.
Turn it off with /todos --header off, or with the "Reminders line above the prompt" setting in /config.
Todos [Add] 6 open · 2 blocking
▾ P1 Blocking 2
› [Done] Checkout crashes when the cart is empty. [?] [Ask]
[P1] src/checkout/order.ts:42 1d
The total divides by the item count before the
empty-cart check. Start at order.ts:42.
[Done] Retry limit has no value. [?] [Ask]
[P1] src/net/retry.ts:42 · file missing 5w
▸ P3 Polish 4
▾ Recently done
[Undo] Login redirect loops on expired session. today
↑↓ item · Tab button · Enter press · Esc close
Open reminders are grouped by priority. Each shows its text, the file it points at, and how long ago it was recorded.
| Key | What it does |
|---|---|
| ↑ ↓ | Move between lines: the title, section headers, reminders and done items. On a reminder, the focus stays in the same column ([Done], [P#], [?] or [Ask]). |
| Tab, Shift+Tab | Move between the buttons of the focused reminder: [Done], [P#], [?], [Ask], wrapping. |
| Enter | Presses the focused button. |
| Esc | Closes the pane. |
› marks the line holding the focus. In the Ask and Add fields, ↑↓ and Tab leave the field as usual.
← and → do nothing in the pane: Claude Code doesn't let plugins bind them, so they reach the prompt underneath. To use them like Shift+Tab and Tab, add this to ~/.claude/keybindings.json. It applies to every plugin's pane:
{
"bindings": [
{ "context": "Pane", "bindings": { "left": "abovePrompt:previous", "right": "abovePrompt:next" } }
]
}
If the file already has a bindings list, add the Pane entry to it.
| Button | What it does |
|---|---|
| ▾ / ▸ | Folds a section to its header, or unfolds it. The pane remembers folded sections across sessions. |
| Done | Marks the reminder finished and moves it to the Done section of the file. |
| P1 / P2 / P3 | Steps the reminder to the next priority and moves it to that section. |
| Undo | Puts a done reminder back where it was, with its priority, date and pointer. The pane lists the last 3 done; to undo an older one, ask Claude. |
| ? | Explains the reminder in one or two sentences under it, in italics: what is unfinished and where to start. It takes at most 3 lines. A small, fast model (Haiku) writes it from the reminder and the 40 lines of code around its pointer only, so it costs little and adds nothing to your conversation with Claude. The explanation is kept for the session; press ? again to hide or show it. |
| Ask | Hands the reminder to Claude. A field opens under it: type how you want it done, or leave it empty to let Claude choose. Enter sends it, and Claude marks the reminder done when the work is finished. Press Ask again to close the field. |
| Add | Opens a field under the title for a rough note. Enter sends it to Claude, which words and records it, like /todos add. |
The pane flags reminders that may be stale: a pointer whose file no longer exists reads file missing, and an age of 30 days or more is drawn in yellow.
| Command | What it does | Uses tokens | |
|---|---|---|---|
/todos | Opens the pane. | No | |
/todos add <note> | Sends your note to Claude, which words it and records it. | Yes, a little | |
/todos add | With no note, opens the pane with the Add field ready. | No | |
/todos --scan [path] | Has Claude fill the list from the unfinished work in the code. | Yes, a lot | |
| `/todos --header on\ | off` | Shows or hides the line above the prompt. Bare, it flips. | No |
/todos --update | Updates the plugin. See Update. | No |
Each argument works with or without the leading --: /todos scan is the same as /todos --scan.
These never call Claude, so they cost no tokens:
/todos pane, and its Done, P1/P2/P3, Undo and fold buttons, which edit the file directly/todos, /todos add with no note, /todos --header and /todos --updateA command's one-line reply, such as "The reminders line above the prompt is off.", is part of the conversation, so Claude reads it along with your next message: a few tokens.
Rough figures (a token is about 4 characters of English):
| What | When | Tokens |
|---|---|---|
| The text that tells Claude the reminder tools exist and when to use them | Every session | about 650 |
The writing-reminders skill | The first time Claude writes a reminder in a session | about 1,100 |
| Recording a reminder | Each reminder | about 100 |
| Listing reminders | When you ask Claude about open work | about 25 per open reminder |
| The note after Claude edits a file a reminder points at | Once per reminder per session | about 50, plus 20 per reminder |
| The ? button | The first press on a reminder in a session, on Haiku, outside the conversation | about 700 in, at most 100 out |
Ask, Add, /todos add <note> | Each time | about 100, plus the code Claude reads to do or record the work |
/todos --scan | Only when you run it | from about 10,000 in a small project to 100,000 or more in a large one |
On average, a session that records or finishes a few reminders spends about 2,000 tokens on the plugin, most of it the skill. A session that never touches reminders spends about 650.
The scan's cost grows with the number of findings, since Claude reads the code around each one. To keep it down, scan one folder at a time with /todos --scan <path>; a later scan only adds what is new.
These figures are estimates from the size of the text the plugin gives Claude, not measured usage.
Both are in /config, under the plugin.
| Setting | Default | What it does |
|---|---|---|
Reminders file (path) | empty | Where reminders are kept, relative to the project. |
Reminders line above the prompt (showHeader) | on | Shows the line at the top right of the prompt. |
With the path empty, the plugin uses the project's docs folder however it is spelled (docs, Docs), so a lowercase docs/ doesn't get a second Docs/ beside it on Linux. With no such folder, it uses Docs/todos.md.
Installed before 0.5.0? The plugin moved from the
horien-remindersmarketplace tonoash-tools, and/todos --updatein older versions still looks for the old one. Move once by hand:claude plugin uninstall reminders@horien-reminders claude plugin marketplace remove horien-remindersThen install again as in Install. Your reminders stay in the project's
todos.md.
Run /todos --update. It refreshes the noash-tools marketplace, updates the plugin at the scope it was installed with, and prints what changed. Restart Claude Code to load the new version.
By hand, the same two steps are:
claude plugin marketplace update noash-tools
claude plugin update reminders@noash-tools
A plugin installed at user scope is updated once for every project. One installed at project or local scope is updated in each project that has it.
The writing-reminders skill adapts unslop by Lauren Tan and writing-for-agents by Matt Pocock, both MIT licensed. See CREDITS.md.
MIT
hooks/register.tsx 841 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2
3import {
4 add, age, complete, EMPTY, format, parseDone, parseOpen, PATH, pointerFile, reopen, setPriority, type Priority, type Todo,
5} from './todos'
6
7const PANE = 'todos'
8
9const GROUPS: { priority: Priority; label: string; color: 'error' | 'warning' | 'subtle' }[] = [
10 { priority: 'P1', label: 'Blocking', color: 'error' },
11 { priority: 'P2', label: 'Incomplete', color: 'warning' },
12 { priority: 'P3', label: 'Polish', color: 'subtle' },
13]
14
15// What the priority button on a row steps to.
16const NEXT_PRIORITY: Record<Priority, Priority> = { P1: 'P2', P2: 'P3', P3: 'P1' }
17
18// Done items the pane offers to undo.
19const RECENT = 3
20
21// Columns the left side of a row holds: the focus mark and "[Done]", the priority button under it.
22const LEFT = 8
23// Columns the right side of a row holds: a gap, then "[?] [Ask]" above the age ("today", "11mo").
24const RIGHT = 10
25// Columns a done item's age holds, after a gap.
26const AGE = 6
27// Days after which an item's age is drawn in the warning color.
28const OLD_DAYS = 30
29
30const rowKey = (t: Todo) => `done:${t.text}`
31const prioKey = (t: Todo) => `prio:${t.text}`
32const askKey = (t: Todo) => `ask:${t.text}`
33const whyKey = (t: Todo) => `why:${t.text}`
34const howKey = (t: Todo) => `how:${t.text}`
35const undoKey = (t: Todo) => `undo:${t.text}`
36const foldKey = (section: string) => `fold:${section}`
37const ADD = 'add'
38const NOTE = 'add:note'
39
40// The button the focus ring is on; the row around it is drawn highlighted.
41let focused: string | undefined
42// The pane's buttons and fields as last drawn, one array per line: the title, a section header, an item's
43// [Done] [P#] [?] [Ask], its Ask field, a done item. ↑↓ move between lines, Tab and Shift+Tab along one.
44let lines: string[][] = []
45// The place on a line ↑↓ keep: 0 [Done], 1 [P#], 2 [?], 3 [Ask]; a one-button line leaves it as it was.
46let column = 0
47// The item whose Ask was pressed; its row shows the field for how Claude should do it.
48let asking: string | undefined
49// Whether Add was pressed; the header shows the field for the person's note. Never open with `asking`.
50let adding = false
51// The item whose [?] was pressed; its row shows the explanation under it.
52let explaining: string | undefined
53// Explanations this session, by item text: the reply, or undefined while it is being written.
54const explained = new Map<string, string | undefined>()
55// The pane's width as last drawn, to count the rows an explanation wraps to.
56let width = 60
57// Sections folded in the pane (P1, P2, P3, done), kept in $.store across sessions.
58let folded: Set<string> | undefined
59// Reminders already pointed out to Claude this session, so each edit to their file doesn't repeat them.
60const told = new Set<string>()
61
62// The `path` and `showHeader` options; set by register.
63let configured = ''
64let showHeader = true
65// The reminders file for this project, found once per session.
66let where: Promise<string> | undefined
67
68// The `path` option, else docs/todos.md in an existing docs folder spelled any case, else Docs/todos.md.
69const detect = async ($: EngineInterface) =>
70{
71 if (configured) return configured
72 const dirs = await $.fs.list().then(
73 entries => entries.filter(x => x.kind === 'dir' && x.name.toLowerCase() === 'docs').map(x => x.name),
74 () => [] as string[],
75 )
76 for (const dir of dirs) if (await $.fs.exists(`${dir}/todos.md`)) return `${dir}/todos.md`
77 return dirs.length ? `${dirs[0]}/todos.md` : PATH
78}
79const filePath = ($: EngineInterface) => (where ??= detect($))
80
81const getFolded = async ($: EngineInterface) =>
82{
83 if (folded) return folded
84 const kept = await $.store.get('folded').catch(() => undefined)
85 return (folded ??= new Set(Array.isArray(kept) ? kept.filter(x => typeof x === 'string') : []))
86}
87
88// Rows an explanation may take under its item, so the list stays short.
89const EXPLANATION_ROWS = 3
90
91// An explanation as drawn: one paragraph cut to EXPLANATION_ROWS rows beside the left column, at the last
92// sentence that fits, else with an ellipsis.
93export const clampExplanation = (text: string, columns: number) =>
94{
95 const flat = text.replace(/\s+/g, ' ').trim()
96 const room = Math.max(20, columns - LEFT - 1) * EXPLANATION_ROWS - EXPLANATION_ROWS * 4
97 if (flat.length <= room) return flat
98 const cut = flat.slice(0, room)
99 const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('? '), cut.lastIndexOf('! '))
100 return end > room / 2 ? cut.slice(0, end + 1) : `${cut.slice(0, room - 1).trimEnd()}…`
101}
102
103// The rows an explanation takes under its item, wrapped beside the row's left column.
104const explanationRows = (text: string) =>
105 Math.max(1, Math.ceil(clampExplanation(text, width).length / Math.max(20, width - LEFT - 1)))
106
107// Rows the pane needs: title, each section's header and the gap above it, two lines per item in an
108// unfolded section, the Ask or Add field, an open explanation, footer.
109const paneRows = (open: Todo[], recent: Todo[], fold: Set<string>) =>
110{
111 let rows = 1 + 1 + 2
112 if (explaining !== undefined) rows += explanationRows(explained.get(explaining) ?? 'Explaining...')
113 for (const g of GROUPS)
114 {
115 const n = open.filter(t => t.priority === g.priority).length
116 if (n) rows += 2 + (fold.has(g.priority) ? 0 : n * 2)
117 }
118 if (recent.length) rows += 2 + (fold.has('done') ? 0 : recent.length)
119 return Math.max(4, rows)
120}
121
122// Where ↑ (`by` -1) or ↓ (1) takes the focus from `at`: the line above or below, wrapping, at `column` or the
123// last button short of it; from nothing, the first or the last line.
124export const vertical = (lines: string[][], at: string | undefined, by: number, column: number) =>
125{
126 const i = at === undefined ? -1 : lines.findIndex(l => l.includes(at))
127 const line = lines[i < 0 ? (by > 0 ? 0 : lines.length - 1) : (i + by + lines.length) % lines.length]
128 return line[Math.min(column, line.length - 1)]
129}
130
131// Where Tab (`by` 1) or Shift+Tab (-1) takes the focus from `at`: the next or previous button on its line, wrapping.
132export const across = (lines: string[][], at: string, by: number) =>
133{
134 const line = lines.find(l => l.includes(at))
135 return line ? line[(line.indexOf(at) + by + line.length) % line.length] : at
136}
137
138// The Add and Ask fields, where Tab and the arrows keep the engine's meaning.
139const isField = (key: string) => key === NOTE || key.startsWith('how:')
140
141// Repo-relative or absolute, either slash, any case: comparable.
142const norm = (p: string) => p.trim().replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
143const sameFile = (edited: string, file: string) => edited === file || edited.endsWith(`/${file}`)
144
145// The prompt Ask sends: the item as recorded, then the person's directions or a go-ahead to decide.
146export const resolvePrompt = (t: Todo, how: string, path = PATH) =>
147{
148 const facts = [t.priority, `recorded ${t.date}`, t.at && `at ${t.at}`].filter(Boolean).join(', ')
149 const directions = how.trim()
150 ? `How I want it done: ${how.trim()}`
151 : 'I have no specific directions: take the initiative and pick the approach you judge best.'
152 return [
153 `Resolve this reminder from ${path}:`,
154 `"${t.text}" (${facts})`,
155 '',
156 directions,
157 '',
158 'When the work is finished, mark it done with complete_reminder.',
159 ].join('\n')
160}
161
162// The prompt Add sends: the person's rough note, for Claude to word, place and prioritise.
163export const addPrompt = (note: string, path = PATH) =>
164 [
165 `Add a reminder to ${path} for this note of mine:`,
166 `"${note.trim()}"`,
167 '',
168 'Load the reminders:writing-reminders skill and word the reminder by it.',
169 'Read the code the note concerns to get the exact names and the at pointer, and pick the priority.',
170 'Then call add_reminder. Only record it; the work itself waits for later.',
171 ].join('\n')
172
173// What [?] asks the small model, kept short: it pays for these words and the code, never the conversation.
174export const EXPLAIN_SYSTEM =
175 'You explain one entry of a project\'s to-do list to its developer. In one or two short sentences, at most 40 '
176 + 'words of plain text, no Markdown: what is unfinished and where to start. Use only what is given; when the code does '
177 + 'not show it, say what to check. Write in the language of the entry.'
178
179// Lines of code [?] sends on each side of the line the pointer names; with no line, the file's first lines.
180const CONTEXT = 20
181
182// The [?] request: the item as recorded, then the numbered code around its pointer when the file is there.
183export const explainPrompt = (t: Todo, code?: string) =>
184{
185 const entry = `Entry (${t.priority}, recorded ${t.date}): "${t.text}"`
186 if (!t.at) return entry
187 if (code === undefined) return `${entry}\nAt ${t.at}: the file is missing.`
188 const line = Number(/^:(\d+)/.exec(t.at.trim().slice(pointerFile(t.at).length))?.[1] ?? 0)
189 const all = code.split(/\r?\n/)
190 const to = Math.min(all.length, line ? line + CONTEXT : CONTEXT * 2)
191 // A line past the file's end, the code having moved since, shows the file's last lines.
192 const from = Math.max(1, Math.min(line ? line - CONTEXT : 1, to - CONTEXT * 2 + 1))
193 const shown = all.slice(from - 1, to).map((l, i) => `${from + i} ${l.slice(0, 200)}`).join('\n')
194 return `${entry}\nAt ${t.at}. Lines ${from}-${to} of ${pointerFile(t.at)}:\n${shown}`
195}
196
197// Reminders one scan records at most; the rest are listed in Claude's reply.
198const SCAN_LIMIT = 30
199
200// The prompt `/todos --scan` sends: collect the unfinished work already in the code, optionally under `within`.
201export const scanPrompt = (within: string, path = PATH) =>
202 [
203 `Fill ${path} with the unfinished work already in this project${within.trim() ? `, looking only in ${within.trim()}` : ''}.`,
204 '',
205 'Look for:',
206 '- TODO, FIXME, HACK and XXX in comments, in any case or form (todo:, @todo, TODO(name), "// todo handle this"),',
207 ' in every comment syntax the project uses (//, #, /* */, <!-- -->, --);',
208 '- comments that say in words that work was left: "for now", "temporary", "workaround", "not yet", "later",',
209 ' or the same in the language the comments are written in;',
210 '- stubs: code that throws "not implemented", returns a placeholder value, or is left empty for later;',
211 '- skipped or disabled tests;',
212 '- known issues and open questions written in the README or other docs.',
213 '',
214 'Search tracked files only (git grep in a git repository) and skip vendored, generated and build output.',
215 'Call list_reminders first and skip anything an open reminder already covers.',
216 'Read the code around each finding before judging it: drop what is stale or already done,',
217 'and merge findings that describe the same piece of work into one reminder.',
218 'Load the reminders:writing-reminders skill and word each reminder by it, with an at pointer and a priority.',
219 `Then call add_reminder for each, at most ${SCAN_LIMIT}: if there are more, keep the ones that matter most.`,
220 'Only record them; change no code.',
221 'Finish with how many you added per priority, and what you left out and why.',
222 ].join('\n')
223
224// What Claude reads after editing a file that open reminders point at.
225export const pointerNote = (items: Todo[], path = PATH) =>
226 [
227 `Open reminders in ${path} point at the file you just changed:`,
228 ...items.map(t => `- ${t.priority} ${t.text} (at ${t.at})`),
229 'If your change finishes one, call complete_reminder for it. If it makes one inaccurate, tell the user.',
230 ].join('\n')
231
232const USAGE = 'Usage: /todos [add <note> | --scan [path] | --header on|off | --update]'
233
234const addDescription = (path: string) => `Record unfinished work in ${path} so a later session can pick it up.
235Call it the moment you:
236- leave a partial implementation (a stub, a skipped branch, a placeholder value),
237- hit a design question nobody has decided (a balance number, an unspecified edge case),
238- hear the user defer something ("later", "not now").
239Don't call it for every ponytail: comment; /ponytail-debt covers those.
240priority: P1 = something is broken or blocks other work, P2 = a feature is incomplete, P3 = polish or cleanup.
241at: optional repo-relative pointer to the partial code, e.g. src/api/orders.ts:42.
242Word the text by the reminders:writing-reminders skill; load it before your first reminder in a session.
243The text must make sense with no context from this conversation.`
244
245const completeDescription = (path: string) => `Mark an open reminder in ${path} done; it moves to the Done section.
246match: a case-insensitive piece of the reminder text that matches exactly one open item.
247If the item's pointer leads to a TODO/FIXME/ponytail: comment or a TODO note in memory/, delete that marker in the same change.`
248
249const reopenDescription = (path: string) => `Move a reminder in ${path} from Done back to Open, keeping its priority and date.
250Call it when the user says an item was marked done by mistake, or the work turns out unfinished.
251match: a case-insensitive piece of the reminder text that matches exactly one Done item.`
252
253const today = async ($: EngineInterface) =>
254{
255 const d = new Date(await $.clock.now())
256 const pad = (n: number) => String(n).padStart(2, '0')
257 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
258}
259
260const load = async ($: EngineInterface) =>
261{
262 const path = await filePath($)
263 return (await $.fs.exists(path)) ? $.fs.read(path) : EMPTY
264}
265
266const save = async ($: EngineInterface, md: string) =>
267{
268 await $.fs.write(await filePath($), md)
269 $.ui.invalidate('ui.render')
270}
271
272const closeItem = async ($: EngineInterface, match: string) =>
273{
274 const r = complete(await load($), match, await today($))
275 if (!r.error) await save($, r.md)
276 return r
277}
278
279const reopenItem = async ($: EngineInterface, match: string) =>
280{
281 const r = reopen(await load($), match)
282 if (!r.error) await save($, r.md)
283 return r
284}
285
286// Opens the pane sized to its content, or resizes it when already open.
287const openPane = async ($: EngineInterface) =>
288{
289 const md = await load($)
290 const rows = paneRows(parseOpen(md), parseDone(md).slice(0, RECENT), await getFolded($))
291 await $.ui.open({ id: PANE, title: 'Todos', focus: true, closeOnEscape: true, rows })
292}
293
294// [?]: shows the item's explanation under it, or hides it. The first press asks the small model with the item
295// and the code around its pointer alone, no conversation, and keeps the reply for the session.
296const explain = async ($: EngineInterface, t: Todo) =>
297{
298 explaining = explaining === t.text ? undefined : t.text
299 if (explaining === undefined || explained.has(t.text))
300 {
301 $.ui.invalidate('ui.render')
302 return openPane($).catch(() => undefined)
303 }
304 explained.set(t.text, undefined)
305 $.ui.invalidate('ui.render')
306 await openPane($).catch(() => undefined)
307 const file = t.at && pointerFile(t.at)
308 const code = file && (await $.fs.exists(file)) ? await $.fs.read(file).catch(() => undefined) : undefined
309 const r = await $.model.complete({
310 model: 'haiku',
311 system: EXPLAIN_SYSTEM,
312 prompt: explainPrompt(t, code),
313 maxTokens: 100,
314 effort: 'low',
315 timeoutMs: 30_000,
316 }).catch((err: unknown) => ({ isAnswered: false as const, reason: String(err) }))
317 if (r.isAnswered) explained.set(t.text, r.text.trim())
318 else explained.delete(t.text)
319 if (!r.isAnswered && explaining === t.text) explaining = undefined
320 if (!r.isAnswered) $.ui.toast(`Could not explain: ${r.reason}`)
321 $.ui.invalidate('ui.render')
322 // Resized to the reply only while the pane is still up: a pane closed meanwhile stays closed.
323 const isUp = await $.ui.panes().then(ps => ps.some(p => p.id === PANE), () => false)
324 if (isUp) await openPane($).catch(() => undefined)
325}
326
327// The marketplace this plugin is published in.
328const MARKETPLACE = 'noash-tools'
329
330// `/todos --update`: refresh the marketplace, then update the installed copy, the two `claude plugin`
331// commands a person would run. The CLI picks the install's scope; a new version loads on restart.
332const updatePlugin = async ($: EngineInterface) =>
333{
334 const id = `${$.plugin.name}@${MARKETPLACE}`
335 const run = async (argv: string[]) =>
336 {
337 const r = await $.process.run(argv, { timeoutMs: 120_000 })
338 return { ok: r.exitCode === 0, out: `${r.stdout}\n${r.stderr}`.trim() }
339 }
340 // An installed plugin runs from the plugin cache; anywhere else is a folder loaded with --plugin-dir.
341 const fromFolder = !/[\\/]plugins[\\/]cache[\\/]/.test($.plugin.root)
342 const note = fromFolder
343 ? `\nThis session runs the plugin from ${$.plugin.root}, so the update reaches the installed copy other sessions load.`
344 : ''
345
346 $.ui.status('Checking for a reminders update...')
347 try
348 {
349 const market = await run(['claude', 'plugin', 'marketplace', 'update', MARKETPLACE])
350 if (!market.ok) return `Could not refresh the ${MARKETPLACE} marketplace:\n${market.out}`
351 const update = await run(['claude', 'plugin', 'update', id])
352 return (update.ok ? update.out : `Could not update ${id}:\n${update.out}`) + note
353 }
354 catch (err)
355 {
356 return `Could not run the claude CLI: ${err instanceof Error ? err.message : String(err)}`
357 }
358 finally
359 {
360 $.ui.status(undefined)
361 }
362}
363
364// After Claude edits a file (Edit, Write, NotebookEdit), name the open reminders that point at it, once
365// each per session. An edit to the reminders file itself redraws the pane and the line above the prompt.
366const afterEdit = async <R extends ToolCallResult>($: EngineInterface, e: object, r: R): Promise<R> =>
367{
368 if (r.deny || r.isError) return r
369 try
370 {
371 const input = e as { file_path?: unknown; notebook_path?: unknown }
372 const edited = norm(String(input.file_path ?? input.notebook_path ?? ''))
373 const path = await filePath($)
374 if (!edited) return r
375 if (sameFile(edited, norm(path)))
376 {
377 $.ui.invalidate('ui.render')
378 return r
379 }
380 const hits = parseOpen(await load($)).filter(t => t.at && !told.has(t.text) && sameFile(edited, norm(pointerFile(t.at))))
381 if (!hits.length) return r
382 hits.forEach(t => told.add(t.text))
383 return { ...r, context: [...(r.context ?? []), pointerNote(hits, path)] } as R
384 }
385 catch
386 {
387 return r
388 }
389}
390
391// `/todos --header on|off`: the showHeader option, kept in settings; bare, it flips.
392const setHeader = async ($: EngineInterface, value: boolean) =>
393{
394 const r = await $.config.set({ key: `${$.plugin.name}.showHeader`, value })
395 if (r.deny) return `Could not change the reminders line: ${r.deny}`
396 showHeader = value
397 $.ui.invalidate('ui.render')
398 return value
399 ? 'The reminders line above the prompt is on.'
400 : 'The reminders line above the prompt is off. /todos --header on brings it back.'
401}
402
403export const register: Register = (on, options) =>
404{
405 configured = typeof options.path === 'string' ? options.path.trim() : ''
406 showHeader = options.showHeader !== false
407 where = undefined
408
409 on('session.start', async ($, e, next) =>
410 {
411 where = undefined
412 told.clear()
413 const path = await filePath($)
414 await $.command.register({
415 name: 'todos',
416 description: `Show open reminders from ${path}; add <note> records one, --scan fills it from the code`,
417 argumentHint: '[add <note> | --scan [path] | --header on|off | --update]',
418 })
419 await $.tool.register({
420 name: 'add_reminder',
421 description: addDescription(path),
422 inputSchema: {
423 type: 'object',
424 properties: {
425 text: { type: 'string' },
426 priority: { type: 'string', enum: ['P1', 'P2', 'P3'] },
427 at: { type: 'string' },
428 },
429 required: ['text', 'priority'],
430 },
431 })
432 await $.tool.register({
433 name: 'list_reminders',
434 description: `List open reminders from ${path}, sorted P1 to P3. Call only when the user asks about open work or reminders.`,
435 })
436 await $.tool.register({
437 name: 'complete_reminder',
438 description: completeDescription(path),
439 inputSchema: { type: 'object', properties: { match: { type: 'string' } }, required: ['match'] },
440 })
441 await $.tool.register({
442 name: 'reopen_reminder',
443 description: reopenDescription(path),
444 inputSchema: { type: 'object', properties: { match: { type: 'string' } }, required: ['match'] },
445 })
446 return next(e)
447 })
448
449 on('command.run', { command: 'todos' }, async ($, e) =>
450 {
451 // A command can't submit while its own run holds the prompt, so the prompt is sent once it ends.
452 const sendAfter = (text: string) =>
453 {
454 $.clock.after(0, () => void $.prompt.submit({ text, asUser: true }).catch(err => $.ui.toast(`Could not send to Claude: ${err}`)))
455 return {}
456 }
457
458 const args = e.args.trim()
459 const path = await filePath($)
460 if (/^(--)?update$/.test(args)) return { text: await updatePlugin($) }
461 const header = /^(?:--)?header(?:\s+(on|off))?$/i.exec(args)
462 if (header) return { text: await setHeader($, header[1] ? header[1].toLowerCase() === 'on' : !showHeader) }
463 const scan = /^(?:--)?scan(?:\s+(.*))?$/s.exec(args)
464 if (scan) return sendAfter(scanPrompt(scan[1] ?? '', path))
465 // `/todos add <note>` sends the note; `/todos add` alone opens the pane with the Add field.
466 const note = /^(?:--)?add(?:\s+(.*))?$/s.exec(args)
467 if (note?.[1]?.trim()) return sendAfter(addPrompt(note[1], path))
468 if (args && !note) return { text: USAGE }
469
470 asking = undefined
471 adding = !!note
472 focused = undefined
473 column = 0
474 explaining = undefined
475 await openPane($)
476 if (adding) await $.ui.focus({ requestId: PANE, key: NOTE }).catch(() => undefined)
477 return { text: 'Todos pane opened.' }
478 })
479
480 // Tab and Shift+Tab step the engine's ring through every button, the last one's next being the engine's own
481 // stops; here they step along the focused line instead, wrapping. In the Add and Ask fields they keep their
482 // meaning. The marker follows: `focused` is set before `next`, which draws the pane, and put back on a refusal.
483 on('ui.focus', { requestId: PANE }, async ($, e, next) =>
484 {
485 let to = e.element
486 if (e.origin.kind === 'person' && focused !== undefined && !isField(focused))
487 {
488 const ring = lines.flat()
489 const i = ring.indexOf(focused)
490 const by = i < 0 ? 0 : to === ring[i + 1] ? 1 : to === ring[i - 1] ? -1 : 0
491 if (by) to = across(lines, focused, by)
492 }
493 const line = to === undefined ? undefined : lines.find(l => l.includes(to!))
494 if (line && line.length > 1) column = line.indexOf(to!)
495
496 if (to !== e.element)
497 {
498 if (to === focused) return {}
499 // Bound for one of the engine's stops: its move can't be turned onto a button, so the plugin makes its own.
500 if (e.element === undefined)
501 {
502 $.clock.after(0, () => void $.ui.focus({ requestId: PANE, key: to! }).catch(() => undefined))
503 return {}
504 }
505 }
506 const was = focused
507 focused = to
508 const r = await next(to === e.element ? e : { ...e, element: to })
509 if (r.deny) focused = was
510 $.ui.invalidate('ui.render')
511 return r
512 }).catch(($, e, next) => (next.called ? {} : next(e)))
513
514 // ↑↓ move to the line above or below, keeping the column. The engine raises them as a scroll of one row: the
515 // pane is drawn a row taller than its window so that it always does, and they never reach the ring as Tab
516 // does. The engine scrolls to keep the focus in view. The wheel (it has a pointer) and the page keys scroll.
517 on('ui.scroll', { requestId: PANE }, async ($, e, next) =>
518 {
519 if (e.origin.kind !== 'person' || e.pointer || Math.abs(e.by) !== 1 || !lines.length) return next(e)
520 const r = await $.ui.focus({ requestId: PANE, key: vertical(lines, focused, e.by, column) }).catch(() => ({ deny: 'failed' }))
521 return r.deny ? next(e) : {}
522 }).catch(($, e, next) => (next.called ? {} : next(e)))
523
524 on('tool.call', { tool: 'mcp__reminders__add_reminder' }, async ($, e) =>
525 {
526 const priority = (['P1', 'P2', 'P3'].includes(String(e.priority)) ? e.priority : 'P2') as Priority
527 const at = typeof e.at === 'string' && e.at.trim() ? e.at.trim() : undefined
528 const item = { priority, date: await today($), text: String(e.text ?? ''), at }
529 const r = add(await load($), item)
530 if (r.error) return { result: r.error }
531 await save($, r.md)
532 return { result: `Added: ${format(item)}` }
533 })
534
535 on('tool.call', { tool: 'mcp__reminders__list_reminders' }, async $ =>
536 {
537 const open = parseOpen(await load($))
538 return { result: open.length ? open.map(format).join('\n') : 'No open reminders.' }
539 })
540
541 on('tool.call', { tool: 'mcp__reminders__complete_reminder' }, async ($, e) =>
542 {
543 const r = await closeItem($, String(e.match ?? ''))
544 if (r.error) return { result: r.error }
545 $.ui.toast(`Done: ${r.done!.text}`)
546 return { result: `Done: ${r.done!.text}` }
547 })
548
549 on('tool.call', { tool: 'mcp__reminders__reopen_reminder' }, async ($, e) =>
550 {
551 const r = await reopenItem($, String(e.match ?? ''))
552 return { result: r.error ?? `Reopened: ${r.reopened!.text}` }
553 })
554
555 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, e, await next(e)))
556 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, e, await next(e)))
557 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => afterEdit($, e, await next(e)))
558
559 // The line at the top right of the prompt: open and blocking counts, or a hint before the file exists.
560 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
561 {
562 if (!showHeader || e.props.hasSurvey) return next(e)
563 const path = await filePath($)
564 const exists = await $.fs.exists(path)
565 const open = exists ? parseOpen(await $.fs.read(path)) : []
566 if (exists && !open.length) return next(e)
567
568 // The band holds one tree: another mod's drawing beneath stays, with the line under it.
569 const beneath = await next(e)
570 const { Box, Text } = $.ui.resolve(e)
571 const blocking = open.filter(t => t.priority === 'P1').length
572 const line = (
573 <Box key="todos-line" width={e.props.bodyColumns} justifyContent="flex-end">
574 {exists ? (
575 <Text wrap="truncate-start">
576 <Text dimColor>Todos: {open.length} open</Text>
577 {blocking > 0 && <Text dimColor> · </Text>}
578 {blocking > 0 && <Text color="error">{blocking} blocking</Text>}
579 <Text dimColor> · /todos</Text>
580 </Text>
581 ) : (
582 <Text dimColor wrap="truncate-start">No reminders yet · /todos --scan collects the TODOs in the code</Text>
583 )}
584 </Box>
585 )
586 if (typeof beneath !== 'string' && beneath.type === 'engine') return line
587 return (
588 <Box key="todos-band" flexDirection="column">
589 {beneath}
590 {line}
591 </Box>
592 )
593 })
594
595 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
596 {
597 const { Box, Button, Input, Text } = $.ui.resolve(e)
598 const path = await filePath($)
599 const md = await load($)
600 const open = parseOpen(md)
601 const recent = parseDone(md).slice(0, RECENT)
602 const now = await today($)
603 const blocking = open.filter(t => t.priority === 'P1').length
604 const fold = await getFolded($)
605 const missing = new Set<string>()
606 for (const t of open) if (t.at && !(await $.fs.exists(pointerFile(t.at)))) missing.add(t.text)
607 const has = (key: string) => e.props.isFocused && focused === key
608 width = e.props.bodyColumns
609
610 lines = [[ADD], ...(adding ? [[NOTE]] : [])]
611 for (const g of GROUPS)
612 {
613 const items = open.filter(t => t.priority === g.priority)
614 if (!items.length) continue
615 lines.push([foldKey(g.priority)])
616 if (!fold.has(g.priority))
617 for (const t of items) lines.push([rowKey(t), prioKey(t), whyKey(t), askKey(t)], ...(asking === t.text ? [[howKey(t)]] : []))
618 }
619 if (recent.length)
620 {
621 lines.push([foldKey('done')])
622 if (!fold.has('done')) lines.push(...recent.map(t => [undoKey(t)]))
623 }
624
625 const markDone = async (t: Todo) =>
626 {
627 const i = open.indexOf(t)
628 const r = await closeItem($, t.text)
629 if (r.error) return $.ui.toast(r.error)
630 const rest = open.filter(o => o !== t)
631 const next = rest[Math.min(i, rest.length - 1)]
632 if (next) await $.ui.focus({ requestId: PANE, key: rowKey(next) }).catch(() => undefined)
633 }
634
635 // The priority button steps P1, P2, P3 and round; the item moves to that section, unfolded.
636 const cycle = async (t: Todo) =>
637 {
638 const to = NEXT_PRIORITY[t.priority]
639 const r = setPriority(await load($), t.text, to)
640 if (r.error) return $.ui.toast(r.error)
641 if (fold.delete(to)) await $.store.set('folded', [...fold]).catch(() => undefined)
642 await save($, r.md)
643 await $.ui.focus({ requestId: PANE, key: prioKey(t) }).catch(() => undefined)
644 }
645
646 // A section's arrow folds it to its header, or unfolds it; the pane resizes to fit.
647 const toggleFold = async (section: string) =>
648 {
649 if (!fold.delete(section)) fold.add(section)
650 await $.store.set('folded', [...fold]).catch(() => undefined)
651 $.ui.invalidate('ui.render')
652 await openPane($).catch(() => undefined)
653 }
654
655 // Ask opens the field under the row (pressed again, closes it); Enter in the field sends.
656 const ask = async (t: Todo) =>
657 {
658 asking = asking === t.text ? undefined : t.text
659 adding = false
660 $.ui.invalidate('ui.render')
661 if (asking) await $.ui.focus({ requestId: PANE, key: howKey(t) }).catch(() => undefined)
662 }
663
664 const send = async (t: Todo, how: string) =>
665 {
666 asking = undefined
667 await $.ui.close({ id: PANE }).catch(() => undefined)
668 await $.prompt.submit({ text: resolvePrompt(t, how, path), asUser: true })
669 }
670
671 // Add opens the field under the title (pressed again, closes it); Enter with a note sends it.
672 const toggleAdd = async () =>
673 {
674 adding = !adding
675 asking = undefined
676 $.ui.invalidate('ui.render')
677 if (adding) await $.ui.focus({ requestId: PANE, key: NOTE }).catch(() => undefined)
678 }
679
680 const sendNote = async (note: string) =>
681 {
682 adding = false
683 if (!note.trim()) return $.ui.invalidate('ui.render')
684 await $.ui.close({ id: PANE }).catch(() => undefined)
685 await $.prompt.submit({ text: addPrompt(note, path), asUser: true })
686 }
687
688 const undo = async (t: Todo) =>
689 {
690 const r = await reopenItem($, t.text)
691 if (r.error) return $.ui.toast(r.error)
692 fold.delete(t.priority)
693 await $.ui.focus({ requestId: PANE, key: rowKey(t) }).catch(() => undefined)
694 }
695
696 // The two columns left of every line: the marker on the line holding the focus, else blank.
697 const marker = (isFocused: boolean) => <Text color="claude">{isFocused ? '› ' : ' '}</Text>
698
699 // A section header: the marker, the fold arrow, then the title and count.
700 const sectionHeader = (section: string, title: JSX.Element) => (
701 <Box flexDirection="row">
702 {marker(has(foldKey(section)))}
703 <Button key={foldKey(section)} label={fold.has(section) ? '▸' : '▾'} plain dimColor={!has(foldKey(section))} onPress={() => toggleFold(section)} />
704 <Text> </Text>
705 {title}
706 </Box>
707 )
708
709 // [Done] with the priority button under it, right-aligned; the text and pointer; [?] [Ask] above the age;
710 // under them the explanation [?] opened, then the Ask field.
711 const row = (t: Todo) =>
712 {
713 const isFocused = has(rowKey(t)) || has(prioKey(t)) || has(whyKey(t)) || has(askKey(t)) || has(howKey(t))
714 const days = Math.round((Date.parse(now) - Date.parse(t.date)) / 86_400_000)
715 return (
716 <Box key={`row:${t.text}`} flexDirection="column">
717 <Box flexDirection="row">
718 <Box flexDirection="column" width={LEFT} flexShrink={0}>
719 <Box>
720 {marker(isFocused)}
721 <Button key={rowKey(t)} label="[Done]" plain dimColor={!has(rowKey(t))} onPress={() => markDone(t)} />
722 </Box>
723 <Box justifyContent="flex-end">
724 <Button key={prioKey(t)} label={`[${t.priority}]`} plain dimColor={!has(prioKey(t))} onPress={() => cycle(t)} />
725 </Box>
726 </Box>
727 <Box flexDirection="column" flexGrow={1} flexShrink={1} paddingLeft={1}>
728 <Text bold={isFocused} wrap="wrap">{t.text}</Text>
729 {t.at && (
730 <Box flexDirection="row">
731 <Text dimColor wrap="truncate-start">{t.at}</Text>
732 {missing.has(t.text) && <Text color="warning"> · file missing</Text>}
733 </Box>
734 )}
735 </Box>
736 <Box flexDirection="column" alignItems="flex-end" width={RIGHT} flexShrink={0}>
737 <Box>
738 <Button key={whyKey(t)} label="[?]" plain dimColor={!has(whyKey(t))} onPress={() => explain($, t)} />
739 <Text> </Text>
740 <Button key={askKey(t)} label="[Ask]" plain dimColor={!has(askKey(t))} onPress={() => ask(t)} />
741 </Box>
742 {days >= OLD_DAYS ? <Text color="warning">{age(t.date, now)}</Text> : <Text dimColor>{age(t.date, now)}</Text>}
743 </Box>
744 </Box>
745 {explaining === t.text && (
746 <Box key={`explanation:${t.text}`} paddingLeft={LEFT + 1}>
747 {explained.get(t.text) === undefined
748 ? <Text dimColor italic>Explaining...</Text>
749 : <Text dimColor italic wrap="wrap">{clampExplanation(explained.get(t.text)!, width)}</Text>}
750 </Box>
751 )}
752 {asking === t.text && (
753 <Box paddingLeft={LEFT + 1}>
754 <Input
755 key={howKey(t)}
756 placeholder="How should Claude do it? Empty: Claude decides"
757 submitLabel="send"
758 onSubmit={how => send(t, how)}
759 />
760 </Box>
761 )}
762 </Box>
763 )
764 }
765
766 const doneRow = (t: Todo) =>
767 {
768 const isFocused = has(undoKey(t))
769 return (
770 <Box key={`donerow:${t.text}`} flexDirection="row">
771 <Box flexShrink={0}>
772 {marker(isFocused)}
773 <Button key={undoKey(t)} label="[Undo]" plain dimColor={!isFocused} onPress={() => undo(t)} />
774 </Box>
775 <Box flexGrow={1} flexShrink={1} paddingLeft={1}>
776 <Text dimColor={!isFocused} strikethrough wrap="truncate-end">{t.text}</Text>
777 </Box>
778 <Box justifyContent="flex-end" width={AGE} flexShrink={0}>
779 <Text dimColor>{age(t.done!, now)}</Text>
780 </Box>
781 </Box>
782 )
783 }
784
785 // At least a row taller than the window, the last row blank, so that ↑↓ always come as a scroll.
786 return (
787 <Box flexDirection="column" width={e.props.bodyColumns} minHeight={e.props.scroll.bodyRows + 1}>
788 <Box key="title" flexDirection="row" justifyContent="space-between">
789 <Box flexShrink={0}>
790 {marker(has(ADD))}
791 <Text bold>Todos </Text>
792 <Button key={ADD} label="[Add]" plain dimColor={!has(ADD)} onPress={toggleAdd} />
793 </Box>
794 <Text dimColor>
795 {open.length ? `${open.length} open${blocking ? ` · ${blocking} blocking` : ''}` : path}
796 </Text>
797 </Box>
798 {adding && (
799 <Box paddingLeft={2}>
800 <Input
801 key={NOTE}
802 placeholder="What to remember? Claude words it and adds it"
803 submitLabel="send"
804 onSubmit={note => sendNote(note)}
805 />
806 </Box>
807 )}
808 {!open.length && (
809 <Box marginTop={1} paddingLeft={2}>
810 <Text dimColor>Nothing open. Run /todos --scan to collect the TODOs already in the code, or press Add.</Text>
811 </Box>
812 )}
813 {GROUPS.map(g =>
814 {
815 const items = open.filter(t => t.priority === g.priority)
816 if (!items.length) return null
817 return (
818 <Box key={`group:${g.priority}`} flexDirection="column" marginTop={1}>
819 {sectionHeader(g.priority, (
820 <Text color={g.color} bold>
821 {g.priority} {g.label} <Text dimColor>{items.length}</Text>
822 </Text>
823 ))}
824 {!fold.has(g.priority) && items.map(row)}
825 </Box>
826 )
827 })}
828 {recent.length > 0 && (
829 <Box key="group:done" flexDirection="column" marginTop={1}>
830 {sectionHeader('done', <Text dimColor bold>Recently done</Text>)}
831 {!fold.has('done') && recent.map(doneRow)}
832 </Box>
833 )}
834 <Box marginTop={1} paddingLeft={2}>
835 <Text dimColor>{e.props.isFocused ? '↑↓ item · Tab button · Enter press · Esc close' : 'ctrl+x tab to select'}</Text>
836 </Box>
837 </Box>
838 )
839 })
840}
841hooks/todos.ts 173 lines1// Docs/todos.md is a markdown checklist with an "## Open" and a "## Done" section.
2// Open line: `- [ ] P2 2026-10-07 Text (at src/x.ts:42)`, Done line: `- [x] ... (done 2026-10-08)`.
3// Lines that don't parse are kept as-is.
4
5export type Priority = 'P1' | 'P2' | 'P3'
6export type Todo = { priority: Priority; date: string; text: string; at?: string; line: number; done?: string }
7
8// Where the file goes when neither the `path` option nor a lowercase docs/ folder says otherwise.
9export const PATH = 'Docs/todos.md'
10export const DONE_KEEP = 20
11
12const OPEN = '## Open'
13const DONE = '## Done'
14const ITEM = /^- \[ \] (P[123]) (\d{4}-\d{2}-\d{2}) (.+?)(?: \(at ([^()]+)\))?$/
15const DONE_ITEM = /^- \[x\] (P[123]) (\d{4}-\d{2}-\d{2}) (.+?)(?: \(at ([^()]+)\))? \(done (\d{4}-\d{2}-\d{2})\)$/
16
17export const EMPTY = `# Todos
18
19Unfinished work: partial implementations, undecided design, deferred tasks.
20Claude edits this through the reminders mod. Hand edits are fine; keep the line format.
21
22${OPEN}
23
24${DONE}
25`
26
27const sectionEnd = (lines: string[], header: string) =>
28{
29 const start = lines.indexOf(header)
30 if (start < 0) return -1
31 let end = start + 1
32 while (end < lines.length && !lines[end]!.startsWith('## ')) end++
33 return end
34}
35
36const ensureSections = (lines: string[]) =>
37{
38 if (!lines.includes(OPEN)) lines.push('', OPEN)
39 if (!lines.includes(DONE)) lines.push('', DONE)
40}
41
42export const parseOpen = (md: string): Todo[] =>
43{
44 const lines = md.split(/\r?\n/)
45 const start = lines.indexOf(OPEN)
46 if (start < 0) return []
47 const todos: Todo[] = []
48 for (let i = start + 1; i < lines.length && !lines[i]!.startsWith('## '); i++)
49 {
50 const m = ITEM.exec(lines[i]!.trimEnd())
51 if (m) todos.push({ priority: m[1] as Priority, date: m[2]!, text: m[3]!, at: m[4], line: i })
52 }
53 return todos.sort((a, b) => a.priority.localeCompare(b.priority) || a.line - b.line)
54}
55
56// How long an item has been open, short enough for a column: today, 3d, 2w, 4mo.
57export const age = (date: string, today: string) =>
58{
59 const days = Math.round((Date.parse(today) - Date.parse(date)) / 86_400_000)
60 if (!(days > 0)) return 'today'
61 if (days < 14) return `${days}d`
62 if (days < 60) return `${Math.floor(days / 7)}w`
63 return `${Math.floor(days / 30)}mo`
64}
65
66// Done items, newest first, as the Done section keeps them.
67export const parseDone = (md: string): Todo[] =>
68{
69 const lines = md.split(/\r?\n/)
70 const start = lines.indexOf(DONE)
71 if (start < 0) return []
72 const todos: Todo[] = []
73 for (let i = start + 1; i < lines.length && !lines[i]!.startsWith('## '); i++)
74 {
75 const m = DONE_ITEM.exec(lines[i]!.trimEnd())
76 if (m) todos.push({ priority: m[1] as Priority, date: m[2]!, text: m[3]!, at: m[4], line: i, done: m[5] })
77 }
78 return todos
79}
80
81export const format = (t: Omit<Todo, 'line'>) =>
82 `- [ ] ${t.priority} ${t.date} ${t.text.replace(/\s+/g, ' ').trim()}${t.at ? ` (at ${t.at})` : ''}`
83
84export const add = (md: string, t: Omit<Todo, 'line'>): { md: string; error?: string } =>
85{
86 const key = t.text.replace(/\s+/g, ' ').trim().toLowerCase()
87 if (!key) return { md, error: 'Reminder text is empty.' }
88 if (parseOpen(md).some(o => o.text.toLowerCase() === key)) return { md, error: 'An open reminder already has this text.' }
89
90 const lines = md.split(/\r?\n/)
91 ensureSections(lines)
92 let end = sectionEnd(lines, OPEN)
93 while (end > 0 && lines[end - 1]!.trim() === '' && lines[end - 1] !== OPEN) end--
94 lines.splice(end, 0, format(t))
95 return { md: tidy(lines) }
96}
97
98// The one item whose text is `match`, else the one containing it (case-insensitive); an error string otherwise.
99const pick = (items: Todo[], match: string, kind: 'open' | 'done'): Todo | string =>
100{
101 const needle = match.trim().toLowerCase()
102 const exact = items.filter(t => t.text.toLowerCase() === needle)
103 const hits = !needle ? [] : exact.length ? exact : items.filter(t => t.text.toLowerCase().includes(needle))
104 if (hits.length === 1) return hits[0]!
105 return hits.length ? `"${match}" matches ${hits.length} ${kind} reminders; be more specific.` : `No ${kind} reminder matches "${match}".`
106}
107
108// `match` is the item text, or a case-insensitive substring of the item text that must hit exactly one open item.
109export const complete = (md: string, match: string, today: string): { md: string; done?: Todo; error?: string } =>
110{
111 const done = pick(parseOpen(md), match, 'open')
112 if (typeof done === 'string') return { md, error: done }
113
114 const lines = md.split(/\r?\n/)
115 const doneLine = lines[done.line]!.trimEnd().replace('- [ ]', '- [x]') + ` (done ${today})`
116 lines.splice(done.line, 1)
117 ensureSections(lines)
118
119 const doneAt = lines.indexOf(DONE)
120 lines.splice(doneAt + 1, 0, doneLine)
121 let kept = 0
122 for (let i = doneAt + 1; i < lines.length && !lines[i]!.startsWith('## '); i++)
123 {
124 if (!lines[i]!.startsWith('- [x]')) continue
125 if (++kept > DONE_KEEP) lines.splice(i--, 1)
126 }
127 return { md: tidy(lines), done }
128}
129
130// Moves a Done item back to Open with its original priority, date and pointer.
131export const reopen = (md: string, match: string): { md: string; reopened?: Todo; error?: string } =>
132{
133 const hit = pick(parseDone(md), match, 'done')
134 if (typeof hit === 'string') return { md, error: hit }
135
136 const lines = md.split(/\r?\n/)
137 lines.splice(hit.line, 1)
138 const { priority, date, text, at } = hit
139 const r = add(lines.join('\n'), { priority, date, text, at })
140 return r.error ? { md, error: r.error } : { md: r.md, reopened: hit }
141}
142
143// Changes an open item's priority in place; the pane re-sorts it into its new group.
144export const setPriority = (md: string, match: string, priority: Priority): { md: string; error?: string } =>
145{
146 const hit = pick(parseOpen(md), match, 'open')
147 if (typeof hit === 'string') return { md, error: hit }
148
149 const lines = md.split(/\r?\n/)
150 lines[hit.line] = lines[hit.line]!.replace(/^- \[ \] P[123]/, `- [ ] ${priority}`)
151 return { md: lines.join('\n') }
152}
153
154// The file an `at` pointer names: `src/x.ts:42` and `src/x.ts:42-50` are `src/x.ts`.
155export const pointerFile = (at: string) => at.trim().replace(/:\d+(?:[-:]\d+)?$/, '')
156
157// One blank line between a header and its first item, none between items, trailing newline.
158const tidy = (lines: string[]) =>
159{
160 const out: string[] = []
161 for (const line of lines)
162 {
163 const prev = out[out.length - 1]
164 if (line.trim() === '' && (prev === undefined || prev.trim() === '')) continue
165 if (line.startsWith('- ') && prev?.trim() === '' && out[out.length - 2]?.startsWith('- ')) out.pop()
166 if (line.startsWith('## ') && prev !== undefined && prev.trim() !== '') out.push('')
167 out.push(line)
168 if (line.startsWith('## ')) out.push('')
169 }
170 while (out.length && out[out.length - 1]!.trim() === '') out.pop()
171 return out.join('\n') + '\n'
172}
173