SLOPSHOPPER

reminders

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

newpanebandguardcommandtoast
★ 3v0.5.0MITupdated 2026-10-07noash-xrc/claude-tools/reminders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reminders
│ ┃ Todos ✕ › fix the failing auth test and add an audit log call │ ┃ Todos [Add] D │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing open. Run /todos --scan to collect ⎿ Read 6 lines │ ┃ already in the code, or press Add. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ctrl+x tab to select ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /todos │ ┃ ⎿ reminders: Todos pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ No reminders yet · /todos --scan collects the TODOs in the code ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
No reminders yet · /todos --scan collects the TODOs in the code
Pane · Todos
Todos [Add] Docs/todos.md Nothing open. Run /todos --scan to collect the TODOs already in the code, or press Add. ctrl+x tab to select
README

Reminders

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.

  • A line at the top right of the prompt counts the open and blocking reminders.
  • /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.
  • When Claude edits code a reminder points at, it is told, so the list stays accurate.

Contents: Install · Getting started · How it works · Fill the list · The line above the prompt · The /todos pane · Commands · Token use · Settings · Update

Install

/plugin install reminders --marketplace noash-xrc/claude-tools

Answer y to add the marketplace, then pick a scope.

Getting started

  1. Run /todos --scan once, so the list starts with the unfinished work already in the project. See Fill the list.
  2. Work as usual. Claude records new reminders as it goes, and the line above the prompt shows the count.
  3. Run /todos to see the list, mark items done, or hand one to Claude.

How it works

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:

  • P1 Blocking: something is broken or blocks other work.
  • P2 Incomplete: a feature is unfinished.
  • P3 Polish: cleanup or polish.

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.

Fill the list

A new install starts with no reminders. There are two ways to add them yourself.

Scan the code

/todos --scan has Claude collect the unfinished work already in the project:

  • TODO, FIXME, HACK and XXX in comments, in any case or form (todo:, @todo, TODO(name)), in every comment syntax the project uses
  • comments that say in words that work was left: "for now", "temporary", "workaround", "not yet", "later", or the same in the language of the comments
  • stubs, such as code that throws "not implemented" or returns a placeholder
  • skipped or disabled tests
  • known issues written in the README or other docs

Claude 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.

Add one by hand

/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.

The line above the prompt

                              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.

The /todos pane

  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.

KeyWhat 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+TabMove between the buttons of the focused reminder: [Done], [P#], [?], [Ask], wrapping.
EnterPresses the focused button.
EscCloses 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.

ButtonWhat it does
▾ / ▸Folds a section to its header, or unfolds it. The pane remembers folded sections across sessions.
DoneMarks the reminder finished and moves it to the Done section of the file.
P1 / P2 / P3Steps the reminder to the next priority and moves it to that section.
UndoPuts 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.
AskHands 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.
AddOpens 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.

Commands

CommandWhat it doesUses tokens
/todosOpens the pane.No
/todos add <note>Sends your note to Claude, which words it and records it.Yes, a little
/todos addWith 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 --updateUpdates the plugin. See Update.No

Each argument works with or without the leading --: /todos scan is the same as /todos --scan.

Token use

Free

These never call Claude, so they cost no tokens:

  • the line above the prompt
  • the /todos pane, and its Done, P1/P2/P3, Undo and fold buttons, which edit the file directly
  • the commands /todos, /todos add with no note, /todos --header and /todos --update

A 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.

What costs tokens

Rough figures (a token is about 4 characters of English):

WhatWhenTokens
The text that tells Claude the reminder tools exist and when to use themEvery sessionabout 650
The writing-reminders skillThe first time Claude writes a reminder in a sessionabout 1,100
Recording a reminderEach reminderabout 100
Listing remindersWhen you ask Claude about open workabout 25 per open reminder
The note after Claude edits a file a reminder points atOnce per reminder per sessionabout 50, plus 20 per reminder
The ? buttonThe first press on a reminder in a session, on Haiku, outside the conversationabout 700 in, at most 100 out
Ask, Add, /todos add <note>Each timeabout 100, plus the code Claude reads to do or record the work
/todos --scanOnly when you run itfrom 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.

Settings

Both are in /config, under the plugin.

SettingDefaultWhat it does
Reminders file (path)emptyWhere reminders are kept, relative to the project.
Reminders line above the prompt (showHeader)onShows 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.

Update

Installed before 0.5.0? The plugin moved from the horien-reminders marketplace to noash-tools, and /todos --update in older versions still looks for the old one. Move once by hand:

claude plugin uninstall reminders@horien-reminders
claude plugin marketplace remove horien-reminders

Then 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.

Credits

The writing-reminders skill adapts unslop by Lauren Tan and writing-for-agents by Matt Pocock, both MIT licensed. See CREDITS.md.

License

MIT

Source 2 files
hooks/register.tsx 841 lines
1import 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}
841
hooks/todos.ts 173 lines
1// 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