SLOPSHOPPER

todo-ledger

One global ledger of the todos, parked work, proposals and discussions you and Claude leave behind, across every project, in a ToDo's sidebar

newpanespinnerguardcommandtoast
v1.0.0MITupdated 2026-10-07SamAct/todo-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-ledger
│ ┃ ToDo's ✕ › fix the failing auth test and add an audit log call │ ┃ p: This project ▾ r: ↻ │ ┃ 1: Agent 0 2: Human 0 3: Done 0 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Nothing open. Claude adds what it parks or ⏺ Update(src/auth.ts) │ ┃ proposes; add your own with /todo add ⎿ Added 2 lines, removed 1 line │ ┃ <text>. ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ☰ ToDo's ⟨Claude Code's own drawing⟩

Draws

Pane · ToDo's
p: This project ▾ r: ↻ 1: Agent 0 2: Human 0 3: Done 0 Nothing open. Claude adds what it parks or proposes; add your own with /todo add <text>.
README

todo-ledger

A Claude Code mod that keeps one global ledger of the work you and Claude leave behind: todos, parked or deferred items, proposals and discussions, from every project and session. It lives in a ToDo's sidebar in the desktop app and the terminal.

  • Claude adds items itself when it defers, parks or proposes something, with a short plain-English note on what and why.
  • After long replies, a quick Haiku scan catches deferrals Claude didn't record, and closes items the conversation settled (approved, rejected, done, duplicate).
  • You add your own with /todo add.
  • Pick an item and Send it to Claude, Draft it into the prompt, or copy it for another session.

Works on Windows, macOS and Linux. Requires Claude Code v2.1.287 or later (mods support).

Install

In Claude Code:

/plugin marketplace add SamAct/todo-ledger
/plugin install todo-ledger@todo-ledger

Then start a new session (or run /reload-plugins). A ☰ ToDo's button appears in the prompt footer.

Use

  • Footer button ☰ ToDo's N: opens the sidebar. N is this project's open items.
  • Sidebar: This project ▾ switches to all projects; tabs Agent / Human / Done; items grouped by what they need (Needs decision, To do, Discussion, Parked). Click an item to open its card.
  • Card actions: Start (Send · Draft · Copy/Terminal), Done, Drop, Note. Proposals get Approve and Reject. Keys in an open card: s start, d done, x drop, n note, a approve.
  • Done tab: Reopen, or Delete forever (dropped items only, two steps).

Commands:

CommandDoes
/todosToggle the sidebar
/todos list [all]Print open items (this project, or all)
/todos refreshReload items written by other sessions
`/todo add [-d\-p\-n] <text>`Add a todo (-d deferred, -p proposal, -n discussion)
/todo done <id>Mark an item done
/todo delete <id>Delete a dropped item forever

Claude gets four tools: park_item, list_items, get_item and update_item. It keeps the ledger current by itself, and agents can only drop items, never delete them.

Data

One JSON file per item in ~/.claude/todo-ledger/items/ (%USERPROFILE%\.claude\todo-ledger\items\ on Windows). Every Claude Code session on the machine shares it; open panes pick up changes within about 10 seconds. Nothing leaves your machine except the end-of-turn scan, which sends the last reply to Claude Haiku through your own Claude Code session.

Develop

claude plugin validate . --strict
claude plugin test .
claude --plugin-dir .

License

MIT

Source 6 files
hooks/register.ts 978 lines
1import { formatItem, formatList, parseTodoCommand, parseTodosArgs } from '../src/commands.ts'
2import { SCAN_SYSTEM, buildScanPrompt, parseScanReply, parseScanUpdates, shouldScan } from '../src/scan.ts'
3import type { ScanUpdate } from '../src/scan.ts'
4import { KINDS, cleanTitle, clip, dedupeKey, wrapLines, isOpen, lastSessionTitle, makeItem, parseItem, randomHex4, sessionLabel, similarTitle } from '../src/item.ts'
5import type { Item, Kind, Origin, Source, Status } from '../src/item.ts'
6import { fallbackArgv, startOptions, startPrompt, terminalArgv, terminalTask } from '../src/start.ts'
7import type { StartChoice } from '../src/start.ts'
8import { TABS, age, bandLabel, displayOrder, firstView, groupItems, itemsForTab, tabOf } from '../src/view.ts'
9import type { Tab } from '../src/view.ts'
10
11// Every function below that takes $ must stay top-level in this file: the validator
12// rejects passing $ into functions imported from other files.
13
14let cache: Item[] = []
15const reportedBad = new Set<string>()
16let parkedThisTurn = false
17// True while the main loop is answering; a prompt sent then waits for the reply to end.
18let replying = false
19let lastUserText = ''
20let deletingId: string | null = null
21const STATUS_VALUES = ['open', 'in_progress', 'done', 'dropped']
22// What the card shows under an item's title, asked of every agent that adds one.
23const DETAIL_HINT = 'Optional but recommended: 2 to 4 short sentences in plain English, no jargon: what this task is ' +
24  'and why it is proposed or parked. Shown to the user in the item\'s card.'
25
26const PANE = 'todo-ledger'
27// The dock width asked for on open; a width the person dragged wins (the engine has no minimum).
28const PANE_COLUMNS = 72
29let paneOpen = false
30let tab: Tab = 'agent'
31let allProjects = false
32let expandedId: string | null = null
33let notingId: string | null = null
34let startingId: string | null = null
35let allNotesId: string | null = null
36// The list draws its first LIST_LIMIT rows (in display order) until the person asks for all:
37// fewer elements per draw keeps presses responsive on a long ledger.
38const LIST_LIMIT = 40
39let showAllRows = false
40let cwd = ''
41// One accent colour for what needs attention (the open card, the decision mark,
42// started items); everything else is hierarchy by weight, dimming and spacing.
43// A theme key, so light and dark themes both fit.
44const ACCENT = 'suggestion'
45// Faint tints for a row's project, session and age.
46const META = { project: '#5b8fd9', session: '#a57fd6', age: '#4fa88a' }
47// The same hues at low alpha, as pill backgrounds on the desktop. A coloured area
48// behind a plain Button keeps it pressable (a Button's own text takes no colour).
49const META_BG = { project: '#5b8fd92e', session: '#a57fd62e', age: '#4fa88a2e' }
50const CLOSED_MEMORY_MS = 30 * 86_400_000
51let lastLoadAt = 0
52const REFRESH_MS = 5000
53const SYNC_MS = 10_000
54let folderSig = ''
55// TodoWrite entries (by dedupe key) already linked to a ledger item in this session.
56const linked = new Map<string, string>()
57
58// The machine the session runs on: Windows uses USERPROFILE, '\' and cmd; macOS and
59// Linux use HOME, '/' and POSIX tools. Read once, from the environment.
60let host: { win: boolean; sep: string; home: string } | null = null
61
62async function hostOf($: any): Promise<{ win: boolean; sep: string; home: string }> {
63  if (host) return host
64  const win = (await $.env.get('OS')) === 'Windows_NT'
65  const home = (win ? await $.env.get('USERPROFILE') : undefined) || (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
66  if (!home) throw new Error('Neither HOME nor USERPROFILE is set, so the ledger folder cannot be found')
67  host = { win, sep: win ? '\\' : '/', home }
68  return host
69}
70
71// Moves a file over another, replacing it (how a save lands in one step).
72async function moveFile($: any, from: string, to: string): Promise<{ exitCode: number; stderr: string }> {
73  const h = await hostOf($)
74  return $.process.run(h.win ? ['cmd', '/c', 'move', '/y', from, to] : ['mv', '-f', from, to])
75}
76
77async function removeFile($: any, path: string): Promise<{ exitCode: number; stderr: string }> {
78  const h = await hostOf($)
79  return $.process.run(h.win ? ['cmd', '/c', 'del', '/q', path] : ['rm', '-f', path])
80}
81
82async function ledgerDir($: any): Promise<string> {
83  const h = await hostOf($)
84  return [h.home, '.claude', 'todo-ledger'].join(h.sep)
85}
86
87async function sep($: any): Promise<string> {
88  return (await hostOf($)).sep
89}
90
91async function itemsDir($: any): Promise<string> {
92  return (await ledgerDir($)) + (await sep($)) + 'items'
93}
94
95async function loadAll($: any): Promise<Item[]> {
96  const dir = await itemsDir($)
97  lastLoadAt = await $.clock.now()
98  if (!(await $.fs.exists(dir))) { cache = []; return cache }
99  const entries = await $.fs.list(dir)
100  const items: Item[] = []
101  for (const entry of entries) {
102    if (!entry.name.endsWith('.json')) continue
103    // A read can reject (over 4 MiB, permission, removed since the list); treat it like bad JSON.
104    let text: string | null
105    try { text = await $.fs.read(dir + (await sep($)) + entry.name) } catch { text = null }
106    const item = text === null ? null : parseItem(text)
107    if (item) items.push(item)
108    else if (!reportedBad.has(entry.name)) {
109      reportedBad.add(entry.name)
110      $.ui.log('skipped unreadable item file ' + entry.name)
111    }
112  }
113  cache = items
114  folderSig = signatureOf(entries)
115  return items
116}
117
118// What the items folder looks like on disk: changes when any session adds,
119// edits (size or mtime) or removes an item file.
120function signatureOf(entries: { name: string; size?: number; mtimeMs?: number }[]): string {
121  return entries
122    .filter((x) => x.name.endsWith('.json'))
123    .map((x) => x.name + ':' + (x.size ?? 0) + ':' + (x.mtimeMs ?? 0))
124    .sort()
125    .join('|')
126}
127
128// Background check: one directory listing; reload and redraw only when another
129// session (CLI or desktop) changed the folder since our last load.
130async function syncIfChanged($: any): Promise<void> {
131  try {
132    const dir = await itemsDir($)
133    const entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
134    if (signatureOf(entries) === folderSig) return
135    await loadAll($)
136    $.ui.invalidate('ui.render')
137  } catch { /* a failed check waits for the next tick */ }
138}
139
140// The pane's Refresh button and `/todos refresh`.
141async function forceRefresh($: any): Promise<number> {
142  await loadAll($)
143  $.ui.invalidate('ui.render')
144  return cache.length
145}
146
147async function saveItem($: any, item: Item): Promise<void> {
148  // $.fs.write creates missing parent directories, so no separate mkdir step.
149  const dir = await itemsDir($)
150  const path = dir + (await sep($)) + item.id + '.json'
151  await $.fs.write(path + '.tmp', JSON.stringify(item, null, 2))
152  const r = await moveFile($, path + '.tmp', path)
153  if (r.exitCode !== 0) throw new Error('could not save ' + item.id + ': ' + r.stderr.trim())
154  cache = [...cache.filter((i) => i.id !== item.id), item]
155  $.ui.invalidate('ui.render')
156}
157
158// The session's name (its tab title). The mods API has no call for it, so it is
159// read from the transcript's `custom-title` lines; null for an unnamed session.
160let sessionName: string | null = null
161let transcriptPath: string | null = null
162
163async function lookupSessionName($: any): Promise<string | null> {
164  try {
165    if (!transcriptPath) {
166      const id = await $.session.id()
167      const h = await hostOf($)
168      const projects = [h.home, '.claude', 'projects'].join(h.sep)
169      const found = await $.process.run(h.win
170        ? ['cmd', '/c', 'dir', '/s', '/b', projects + h.sep + id + '.jsonl']
171        : ['find', projects, '-name', id + '.jsonl'])
172      const first = found.exitCode === 0 ? found.stdout.split(/\r?\n/).find((l: string) => l.trim()) : undefined
173      if (!first) return sessionName
174      transcriptPath = first.trim()
175    }
176    // findstr / grep print only lines that begin with a title record, so a large
177    // transcript is never read whole (`.` matches the quotes in the regex).
178    const r = await $.process.run((await hostOf($)).win
179      ? ['findstr', '/r', '/b', '/c:{.type.:.custom-title.', transcriptPath]
180      : ['grep', '^{.type.:.custom-title.', transcriptPath])
181    sessionName = lastSessionTitle(r.stdout) ?? sessionName
182  } catch { /* keep the last known name */ }
183  return sessionName
184}
185
186async function capture(
187  $: any,
188  f: { title: string; kind: Kind; origin: Origin; source: Source; body?: string },
189): Promise<{ item: Item; isNew: boolean }> {
190  const project = await $.session.cwd()
191  const items = await loadAll($)
192  const key = dedupeKey(f.title, project)
193  const now = await $.clock.now()
194  const at = new Date(now).toISOString()
195  // Same item when the title matches exactly, or is a rewording of an open item in this project.
196  const existing = items.find((i) => isOpen(i) && dedupeKey(i.title, i.project) === key) ??
197    items.find((i) => isOpen(i) && i.project.toLowerCase() === project.toLowerCase() && similarTitle(i.title, f.title))
198  if (existing) {
199    // The scan re-reading a known item says nothing new; a note per turn was only noise.
200    if (f.source === 'scan' || f.source === 'todowrite') return { item: existing, isNew: false }
201    const reworded = dedupeKey(existing.title, existing.project) !== key ? ' as "' + cleanTitle(f.title) + '"' : ''
202    const text = 'Captured again (' + f.source + ')' + reworded + (f.body ? ': ' + f.body : '')
203    if (existing.notes.at(-1)?.text === text) return { item: existing, isNew: false }
204    const updated = { ...existing, updatedAt: at, notes: [...existing.notes, { by: f.origin, text, at }] }
205    await saveItem($, updated)
206    return { item: updated, isNew: false }
207  }
208  // The scan must not bring back what was just finished or turned down when a
209  // later reply mentions it again; a person or an agent asking on purpose still can.
210  if (f.source === 'scan') {
211    const closed = items.find((i) => !isOpen(i) && i.project.toLowerCase() === project.toLowerCase() &&
212      now - Date.parse(i.updatedAt) < CLOSED_MEMORY_MS && (dedupeKey(i.title, i.project) === key || similarTitle(i.title, f.title)))
213    if (closed) return { item: closed, isNew: false }
214  }
215  const sessionId = await $.session.id()
216  const name = sessionName ?? (await lookupSessionName($))
217  const dir = await itemsDir($)
218  // Ids are second-resolution plus 4 hex chars and `move /y` overwrites, so check before the first write.
219  let item = makeItem({ ...f, title: cleanTitle(f.title), project, sessionId, sessionName: name ?? undefined, nowMs: now, hex4: randomHex4() })
220  for (let tries = 0; tries < 5 && (await $.fs.exists(dir + (await sep($)) + item.id + '.json')); tries++) {
221    item = makeItem({ ...f, title: cleanTitle(f.title), project, sessionId, sessionName: name ?? undefined, nowMs: now, hex4: randomHex4() })
222  }
223  await saveItem($, item)
224  return { item, isNew: true }
225}
226
227// Called while drawing: one folder listing at most every few seconds, and a full
228// reload only when the folder changed (reading every item file per draw was slow).
229async function refreshIfStale($: any): Promise<void> {
230  try {
231    const now = await $.clock.now()
232    if (now - lastLoadAt < REFRESH_MS) return
233    lastLoadAt = now
234    await syncIfChanged($)
235  } catch { /* keep the old cache; the next command or tool reports the error */ }
236}
237
238async function setStatus($: any, id: string, status: Status, note?: string): Promise<Item | null> {
239  const items = await loadAll($)
240  const found = items.filter((i) => i.id.startsWith(id))
241  if (found.length !== 1) return null
242  const at = new Date(await $.clock.now()).toISOString()
243  const notes = note ? [...found[0].notes, { by: 'agent' as Origin, text: note, at }] : found[0].notes
244  const updated = { ...found[0], status, updatedAt: at, notes }
245  await saveItem($, updated)
246  return updated
247}
248
249async function mirrorTodos($: any, todos: { content: string; status: string }[]): Promise<void> {
250  const project = await $.session.cwd()
251  const sessionId = await $.session.id()
252  for (const todo of todos) {
253    if (typeof todo?.content !== 'string' || !todo.content.trim()) continue
254    const items = await loadAll($)
255    const key = dedupeKey(todo.content, project)
256    const sameKey = (i: Item) => dedupeKey(i.title, i.project) === key
257    // Link to this session's earlier mirror, else to any open item with the same key and project
258    // (parked, scanned or manual), so repeated TodoWrite calls never add "Captured again" notes.
259    const linkedId = linked.get(key)
260    const mine =
261      (linkedId ? items.find((i) => i.id === linkedId) : undefined) ??
262      items.find((i) => i.source === 'todowrite' && i.sessionId === sessionId && sameKey(i)) ??
263      items.find((i) => isOpen(i) && sameKey(i))
264    const status: Status = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'in_progress' : 'open'
265    if (mine) {
266      linked.set(key, mine.id)
267      if (mine.status !== status && isOpen(mine)) {
268        await saveItem($, { ...mine, status, updatedAt: new Date(await $.clock.now()).toISOString() })
269      }
270      continue
271    }
272    if (status === 'done') continue
273    const r = await capture($, { title: todo.content, kind: 'todo', origin: 'agent', source: 'todowrite' })
274    linked.set(key, r.item.id)
275    if (r.isNew && status !== 'open') await saveItem($, { ...r.item, status })
276  }
277  // TodoWrite sends the whole list each time: an entry it no longer carries was
278  // removed from the session's plan, so its mirror is dropped rather than left open forever.
279  const keep = new Set(todos.filter((t) => typeof t?.content === 'string').map((t) => dedupeKey(t.content, project)))
280  const at = new Date(await $.clock.now()).toISOString()
281  for (const i of await loadAll($)) {
282    if (i.source !== 'todowrite' || i.sessionId !== sessionId || !isOpen(i) || keep.has(dedupeKey(i.title, i.project))) continue
283    await saveItem($, { ...i, status: 'dropped', updatedAt: at, notes: [...i.notes, { by: 'agent' as Origin, text: 'removed from the session\'s task list', at }] })
284  }
285}
286
287// What the scan concluded about an open item, applied with a note saying why.
288async function applyUpdate($: any, item: Item, u: ScanUpdate): Promise<void> {
289  const at = new Date(await $.clock.now()).toISOString()
290  const note = (text: string) => [...item.notes, { by: 'agent' as Origin, text, at }]
291  if (u.action === 'approve') {
292    await saveItem($, { ...item, kind: item.kind === 'proposal' ? 'todo' : item.kind, updatedAt: at, notes: note('approved by the user (seen by the end-of-turn scan)') })
293  } else if (u.action === 'reject') {
294    await saveItem($, { ...item, status: 'dropped', updatedAt: at, notes: note('rejected by the user (seen by the end-of-turn scan)') })
295  } else if (u.action === 'done') {
296    await saveItem($, { ...item, status: 'done', updatedAt: at, notes: note('done (seen by the end-of-turn scan)') })
297  } else {
298    await saveItem($, { ...item, status: 'dropped', updatedAt: at, notes: note('duplicate of ' + u.of + ' (seen by the end-of-turn scan)') })
299  }
300}
301
302async function runScan($: any, answer: string, userText: string): Promise<void> {
303  try {
304    const project = (await $.session.cwd()).toLowerCase()
305    const open = (await loadAll($)).filter((i) => isOpen(i) && i.project.toLowerCase() === project)
306    const r = await $.model.complete({
307      model: 'haiku',
308      system: SCAN_SYSTEM,
309      prompt: buildScanPrompt(answer, open.map((i) => ({ id: i.id, title: i.title, kind: i.kind })), userText),
310      maxTokens: 1200,
311      timeoutMs: 20000,
312    })
313    if (!r.isAnswered) { $.ui.log('scan skipped: ' + r.reason); return }
314    const found = parseScanReply(r.text)
315    if (!found) { $.ui.log('scan skipped: reply was not JSON'); return }
316    // Updates first, against the items the prompt listed, so new captures cannot be touched.
317    for (const u of parseScanUpdates(r.text, open.map((i) => i.id))) {
318      const item = open.find((i) => i.id === u.id)
319      if (item) await applyUpdate($, item, u)
320    }
321    for (const f of found) await capture($, { title: f.title, kind: f.kind, origin: 'agent', source: 'scan', body: f.detail })
322  } catch (err: any) {
323    $.ui.log('scan skipped: ' + err.message)
324  }
325}
326
327async function togglePane($: any): Promise<void> {
328  if (paneOpen) {
329    paneOpen = false
330    await $.ui.close({ id: PANE })
331    return
332  }
333  try { await loadAll($) } catch (err: any) { $.ui.toast('Ledger not loaded: ' + err.message) }
334  // Open on something to look at: a tab with items, and every project when this one has none.
335  let here = cwd
336  try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
337  const view = firstView(cache, here, tab, allProjects)
338  tab = view.tab
339  allProjects = view.allProjects
340  const r = await $.ui.open({ id: PANE, title: "ToDo's", focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
341  // Only a placed pane counts as open; otherwise the next press must try to open it again.
342  paneOpen = r?.isPlaced === true
343  if (!paneOpen) $.ui.toast("Could not open the ToDo's pane: " + (r?.reason ?? 'unknown reason'))
344  $.ui.invalidate('ui.render')
345}
346
347async function safeToggle($: any): Promise<void> {
348  try { await togglePane($) } catch (err: any) { $.ui.toast("ToDo's pane failed: " + err.message) }
349}
350
351async function markStatus($: any, id: string, status: Status): Promise<void> {
352  try { await setStatus($, id, status) } catch (err: any) { $.ui.toast('ToDo not saved: ' + err.message) }
353}
354
355async function addNote($: any, id: string, text: string): Promise<void> {
356  const item = cache.find((i) => i.id === id)
357  if (!item || !text.trim()) return
358  const at = new Date(await $.clock.now()).toISOString()
359  try {
360    await saveItem($, { ...item, updatedAt: at, notes: [...item.notes, { by: 'human' as Origin, text: text.trim(), at }] })
361  } catch (err: any) {
362    $.ui.toast('Note not saved: ' + err.message)
363  }
364}
365
366async function copyText($: any, text: string, okMessage: string, failPrefix: string): Promise<void> {
367  const r = await $.ui.copy({ text })
368  $.ui.toast(r?.isCopied ? okMessage : failPrefix + ' (' + (r?.reason ?? 'unknown') + ')')
369}
370
371async function doStart($: any, item: Item, choice: StartChoice): Promise<void> {
372  const text = startPrompt(item, cwd || (await $.session.cwd()))
373  try {
374    if (choice === 'send') {
375      await saveItem($, { ...item, status: 'in_progress', updatedAt: new Date(await $.clock.now()).toISOString() })
376      // A plugin's prompt waits until Claude is idle, so say so at once instead of
377      // looking like nothing happened while a reply is still running.
378      $.ui.toast(replying ? 'Queued: starts when the current reply finishes' : 'Sending: ' + clip(item.title, 40))
379      let dropped: string | null = null
380      try {
381        const r = await $.prompt.submit({ text, asUser: true })
382        if (r && typeof r.drop === 'string') dropped = r.drop
383      } catch (err: any) { dropped = err.message }
384      if (dropped !== null) {
385        await saveItem($, { ...item, updatedAt: new Date(await $.clock.now()).toISOString() })
386        $.ui.toast('Prompt not sent: ' + dropped)
387      }
388    } else if (choice === 'draft') {
389      const r = await $.prompt.fill({ text })
390      if (!r.isFilled) await copyText($, text, 'Could not fill the prompt; copied instead', 'Could not fill or copy the prompt')
391    } else if (choice === 'copy') {
392      await copyText($, text, 'Prompt copied', 'Prompt not copied')
393    } else {
394      // The full prompt goes in a file; the command line carries a one-line pointer to it.
395      const s = await sep($)
396      const file = (await ledgerDir($)) + s + 'start' + s + item.id + '.md'
397      await $.fs.write(file, text)
398      const task = terminalTask(item.id, file)
399      let ok = false
400      // Opening a new terminal window is wired for Windows (wt, else cmd start); elsewhere the prompt is copied.
401      if (!(await hostOf($)).win) {
402        await copyText($, text, 'Prompt copied: start claude in a new terminal and paste it', 'Prompt not copied')
403        return
404      }
405      try { ok = (await $.process.run(terminalArgv(item.project, task))).exitCode === 0 } catch { ok = false }
406      if (!ok) {
407        try { ok = (await $.process.run(fallbackArgv(item.project, task))).exitCode === 0 } catch { ok = false }
408      }
409      if (!ok) await copyText($, text, 'Could not open a terminal; prompt copied instead', 'Could not open a terminal or copy the prompt')
410    }
411  } catch (err: any) {
412    $.ui.toast('Start failed: ' + err.message)
413  }
414}
415
416// The Start step: one button per way to start, in place of the card's actions, so
417// a choice is a single press (a picker needed focus, arrows and Enter). The first
418// is the main one; Cancel is plain and dim so it never competes.
419function startChooser($: any, e: any, item: Item) {
420  const { Button } = $.ui.resolve(e)
421  const redraw = () => $.ui.invalidate('ui.render')
422  return [
423    ...startOptions(e.surface === 'desktop' ? 'desktop' : 'terminal').map((o, k) =>
424      Button({ key: 'start-' + o.value + '-' + item.id, label: o.label, hotkey: o.hotkey,
425        ...(k === 0 ? { variant: 'primary' as const, autoFocus: true as const } : { plain: true as const }),
426        onPress: async () => { startingId = null; await doStart($, item, o.value as StartChoice); redraw() } })),
427    Button({ key: 'start-cancel-' + item.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { startingId = null; redraw() } }),
428  ]
429}
430
431// Permanent deletion: only for items already dropped, only by the person (pane or
432// /todo delete). Agents' tools can drop but never delete.
433async function deleteItem($: any, idPrefix: string): Promise<{ ok: true; title: string } | { ok: false; message: string }> {
434  const items = await loadAll($)
435  const found = items.filter((i) => i.id.startsWith(idPrefix))
436  if (found.length !== 1) return { ok: false, message: 'No single item matches id ' + idPrefix }
437  const item = found[0]
438  if (item.status !== 'dropped') return { ok: false, message: 'Only dropped items can be deleted. Drop it first: ' + item.title }
439  if (!/^[A-Za-z0-9_-]+$/.test(item.id)) return { ok: false, message: 'Refused: unexpected characters in id ' + item.id }
440  const path = (await itemsDir($)) + (await sep($)) + item.id + '.json'
441  const r = await removeFile($, path)
442  if (r.exitCode !== 0 || (await $.fs.exists(path))) return { ok: false, message: 'Could not delete ' + item.title + ': ' + (r.stderr.trim() || 'file still there') }
443  cache = cache.filter((i) => i.id !== item.id)
444  $.ui.invalidate('ui.render')
445  return { ok: true, title: item.title }
446}
447
448async function runTodo($: any, args: string): Promise<{ text: string }> {
449  const cmd = parseTodoCommand(args)
450  if (cmd.action === 'error') return { text: cmd.message }
451  try {
452    if (cmd.action === 'done') {
453      const item = await setStatus($, cmd.idPrefix, 'done')
454      return { text: item ? 'Done: ' + item.title : 'No single item matches id ' + cmd.idPrefix }
455    }
456    if (cmd.action === 'delete') {
457      const r = await deleteItem($, cmd.idPrefix)
458      return { text: r.ok ? 'Deleted forever: ' + r.title : r.message }
459    }
460    const r = await capture($, { title: cmd.text, kind: cmd.kind, origin: 'human', source: 'manual' })
461    return { text: (r.isNew ? 'Added ' : 'Already listed, noted: ') + r.item.title }
462  } catch (err: any) {
463    $.ui.toast('ToDo not saved: ' + err.message)
464    return { text: 'Not saved: ' + err.message }
465  }
466}
467
468async function runTodos($: any, args: string): Promise<{ text?: string }> {
469  const cmd = parseTodosArgs(args)
470  if (cmd.action === 'list') {
471    try { return { text: formatList(await loadAll($), await $.session.cwd(), cmd.all) } }
472    catch (err: any) { return { text: 'Not loaded: ' + err.message } }
473  }
474  if (cmd.action === 'refresh') {
475    try {
476      await forceRefresh($)
477      return { text: 'Ledger refreshed: ' + cache.filter(isOpen).length + ' open.' }
478    } catch (err: any) { return { text: 'Not loaded: ' + err.message } }
479  }
480  await safeToggle($)
481  return {}
482}
483
484// Registers a command under its preferred name, or under the fallback name if that one is refused.
485async function registerCommand($: any, spec: any, fallback: string): Promise<void> {
486  try {
487    await $.command.register(spec)
488  } catch (err: any) {
489    $.ui.log('/' + spec.name + ' not registered (' + err.message + '); using /' + fallback)
490    try { await $.command.register({ ...spec, name: fallback }) }
491    catch (err2: any) { $.ui.log('/' + fallback + ' not registered either: ' + err2.message) }
492  }
493}
494
495async function registerTool($: any, spec: any): Promise<void> {
496  try { await $.tool.register(spec) } catch (err: any) { $.ui.log('tool ' + spec.name + ' not registered: ' + err.message) }
497}
498
499export function register(on) {
500  on('session.start', async ($, e, next) => {
501    try { cwd = await $.session.cwd() } catch { /* keep '' until a render reads it */ }
502    // Picks up items other sessions write; the timer stops when the module reloads.
503    $.clock.every(SYNC_MS, () => syncIfChanged($))
504    $.clock.after(0, () => lookupSessionName($))
505    await registerTool($, {
506      name: 'park_item',
507      description:
508        'Record an item in the user\'s global todo ledger. Call this, without asking first, whenever you ' +
509        'defer, park, postpone or put something out of scope, or propose work the user has not asked for. ' +
510        'If it may already be recorded, call list_items first and update that item instead of adding a second one. ' +
511        'kind: todo | deferred | proposal | discussion. Keep title to one line.',
512      inputSchema: {
513        type: 'object',
514        properties: {
515          title: { type: 'string' },
516          kind: { type: 'string', enum: ['todo', 'deferred', 'proposal', 'discussion'] },
517          body: { type: 'string', description: DETAIL_HINT },
518        },
519        required: ['title', 'kind'],
520      },
521    })
522    await registerTool($, {
523      name: 'list_items',
524      description:
525        'List open items in the user\'s todo ledger (their todos, parked work, proposals and discussions across ' +
526        'all projects and sessions). Use it whenever the user mentions todos, the ledger, parked or deferred work, ' +
527        'or asks what is pending. scope "project" (default) or "all". Each line gives the id to use with get_item and update_item.',
528      inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'all'] } } },
529    })
530    await registerTool($, {
531      name: 'get_item',
532      description: 'Read one ledger item in full: title, details, project, session, status and its notes thread. id may be a unique prefix.',
533      inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
534    })
535    await registerTool($, {
536      name: 'update_item',
537      description:
538        'Update a ledger item: status (open, in_progress, done, dropped), title, kind, details, and/or add a note. ' +
539        'Keep the ledger current without being asked: as soon as the user approves a proposal set kind "todo"; when ' +
540        'they reject or drop it set status "dropped"; when an item\'s work is finished set status "done"; when two ' +
541        'items are the same, drop one with a note naming the other. ' +
542        'To remove an item, set status "dropped" (it moves to Done and can be reopened). id may be a unique prefix.',
543      inputSchema: {
544        type: 'object',
545        properties: {
546          id: { type: 'string' },
547          status: { type: 'string', enum: STATUS_VALUES },
548          title: { type: 'string' },
549          kind: { type: 'string', enum: ['todo', 'deferred', 'proposal', 'discussion'] },
550          body: { type: 'string', description: 'Replaces the item\'s details. ' + DETAIL_HINT },
551          note: { type: 'string', description: 'Appended to the notes thread' },
552        },
553        required: ['id'],
554      },
555    })
556    await registerCommand($, { name: 'todos', description: "Toggle the ToDo's pane", argumentHint: '[list [all] | refresh]', immediate: true }, 'ledger')
557    await registerCommand($, { name: 'todo', description: 'Add or close a ledger item', argumentHint: 'add [-d|-p|-n] <text> | done <id>' }, 'ledger-add')
558    // Warm the cache; a failure here must not block the session.
559    // The next loadAll (any command, tool or render) retries.
560    try { await loadAll($) } catch { cache = [] }
561    return next(e)
562  })
563
564  // After a press redraws the pane, the desktop loses the pane's focus ring, and the
565  // next click is spent taking it back (traced 2026-10-07: one press logged per two
566  // clicks). Putting the ring on the element the next press will want keeps every
567  // click live: the open card's Close, or the row title once the card closes.
568  on('ui.press', async ($, e, next) => {
569    const r = await next(e)
570    if (e.plugin !== 'todo-ledger' || e.requestId !== PANE) return r
571    const target = expandedId ? 'close-' + expandedId : e.element.replace(/^close-/, 'title-')
572    try { await $.ui.focus({ requestId: PANE, key: target }) } catch { /* a denied move leaves focus as it was */ }
573    return r
574  })
575
576  on('ui.close', async ($, e, next) => {
577    if (e.id === PANE) paneOpen = false
578    return next(e)
579  })
580
581  // The button lives in the prompt footer (the row with the permission-mode label),
582  // on the same line as the engine's own mode labels.
583  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
584    const theirs = await next(e)
585    await refreshIfStale($)
586    let here = cwd
587    try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
588    const label = bandLabel(cache, here)
589    const { Box, Button } = $.ui.resolve(e)
590    const mine = Button({ key: 'ledger-toggle', label, plain: true, onPress: () => safeToggle($) })
591    return Box({ key: 'ledger-footer', flexDirection: 'row', columnGap: 2, alignItems: 'center',
592      children: theirs ? [mine, theirs] : [mine] })
593  })
594
595  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
596    if (e.requestId !== PANE) return next(e)
597    try {
598    const { Box, Text, Button, Input } = $.ui.resolve(e)
599    // Mobile's element table has no Input, so the note composer is skipped there.
600    const canInput = e.surface !== 'mobile'
601    const redraw = () => $.ui.invalidate('ui.render')
602    const now = await $.clock.now()
603    await refreshIfStale($)
604    // Read the cwd per render so the filter follows /cd and matches what capture stores.
605    let here = cwd
606    try { here = await $.session.cwd() } catch { /* keep the session.start value */ }
607    const rows = itemsForTab(cache, tab, allProjects ? null : here)
608    const ordered = displayOrder(rows, tab)
609    const head = ordered.slice(0, LIST_LIMIT)
610    const picked = ordered.find((x) => x.id === expandedId)
611    const shown = showAllRows ? ordered : picked && !head.includes(picked) ? [...head, picked] : head
612
613    const scoped = cache.filter((i) => allProjects || i.project.toLowerCase() === here.toLowerCase())
614    const count = (t: Tab) => scoped.filter((i) => tabOf(i) === t).length
615    const select = (id: string | null) => { expandedId = id; startingId = null; notingId = null; deletingId = null; redraw() }
616
617    // Closing an item from its card opens the next one, so a run of items is
618    // triaged with one press each instead of select, act, select again.
619    const closeItem = async (i: Item, status: Status, kindChange?: Kind) => {
620      const at = ordered.findIndex((x) => x.id === i.id)
621      const following = ordered.slice(at + 1).concat(ordered.slice(0, Math.max(0, at))).find((x) => x.id !== i.id)
622      try {
623        if (kindChange) {
624          const stamp = new Date(await $.clock.now()).toISOString()
625          await saveItem($, { ...i, kind: kindChange, updatedAt: stamp, notes: [...i.notes, { by: 'human' as Origin, text: 'approved', at: stamp }] })
626        } else await setStatus($, i.id, status)
627      } catch (err: any) { $.ui.toast('ToDo not saved: ' + err.message); return }
628      if (kindChange) { redraw(); return }
629      // Closed items are kept: say where they went.
630      $.ui.toast((status === 'dropped' ? 'Dropped, moved to Done: ' : 'Done: ') + clip(i.title, 40))
631      select(expandedId === i.id ? (following?.id ?? null) : expandedId)
632    }
633
634    // Row 1: where the list comes from, with a dim refresh at the far end. The open
635    // count is not repeated here: the tabs carry it.
636    const scopeRow = Box({
637      flexDirection: 'row',
638      justifyContent: 'space-between',
639      children: [
640        Button({ key: 'scope', plain: true, hotkey: 'p',
641          label: (allProjects ? 'All projects' : 'This project') + ' ▾',
642          onPress: () => { allProjects = !allProjects; showAllRows = false; select(null) } }),
643        Button({ key: 'refresh', plain: true, dimColor: true, hotkey: 'r', label: '↻',
644          onPress: async () => {
645            try { await forceRefresh($) } catch (err: any) { $.ui.toast('Refresh failed: ' + err.message) }
646          } }),
647      ],
648    })
649
650    // Row 2: who started it; the chosen tab at full strength, the others dim.
651    const tabRow = Box({
652      flexDirection: 'row',
653      columnGap: 3,
654      marginBottom: 1,
655      children: TABS.map((t) =>
656        Button({ key: 'tab-' + t.id, plain: true, hotkey: t.hotkey, dimColor: tab !== t.id,
657          label: t.label + ' ' + count(t.id),
658          onPress: () => { tab = t.id; showAllRows = false; select(null) } })),
659    })
660
661    // The card's actions in one fixed order, the first the primary. Proposals are
662    // decided (Approve / Reject) rather than worked, so they lead with that. While a
663    // note is being typed the Note button turns into Cancel and letter keys rest.
664    const actionsFor = (i: Item) => {
665      if (startingId === i.id) return startChooser($, e, i)
666      const noting = notingId === i.id
667      const hk = (k: string) => (notingId === null ? { hotkey: k } : {})
668      if (tab === 'done') {
669        const reopen = Button({ key: 'reopen-' + i.id, label: 'Reopen', variant: 'primary', ...hk('o'), onPress: () => markStatus($, i.id, 'open') })
670        if (i.status !== 'dropped') return [reopen]
671        // Two-step: the first press arms it; only Confirm deletes the file. Neither is
672        // primary: nothing should invite an irreversible press.
673        if (deletingId === i.id) {
674          return [
675            Button({ key: 'delete-confirm-' + i.id, label: 'Confirm delete', onPress: async () => {
676              deletingId = null
677              const r = await deleteItem($, i.id)
678              if (!r.ok) $.ui.toast(r.message)
679              else { expandedId = null; $.ui.toast('Deleted forever: ' + r.title) }
680              redraw()
681            } }),
682            Button({ key: 'delete-cancel-' + i.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { deletingId = null; redraw() } }),
683          ]
684        }
685        return [reopen, Button({ key: 'delete-' + i.id, label: 'Delete forever', dimColor: true, onPress: () => { deletingId = i.id; redraw() } })]
686      }
687      const start = Button({ key: 'start-' + i.id, label: 'Start', ...hk('s'),
688        ...(i.kind === 'proposal' ? {} : { variant: 'primary' as const }),
689        onPress: () => { startingId = i.id; notingId = null; redraw() } })
690      const note = !canInput ? []
691        : noting
692          ? [Button({ key: 'note-cancel-' + i.id, label: 'Cancel', plain: true, dimColor: true, onPress: () => { notingId = null; redraw() } })]
693          : [Button({ key: 'note-' + i.id, label: 'Note', ...hk('n'), onPress: () => { notingId = i.id; startingId = null; redraw() } })]
694      if (i.kind === 'proposal') {
695        return [
696          Button({ key: 'approve-' + i.id, label: 'Approve', variant: 'primary', ...hk('a'), onPress: () => closeItem(i, i.status, 'todo') }),
697          Button({ key: 'drop-' + i.id, label: 'Reject', ...hk('x'), onPress: () => closeItem(i, 'dropped') }),
698          start,
699          ...note,
700        ]
701      }
702      return [
703        start,
704        Button({ key: 'done-' + i.id, label: 'Done', ...hk('d'), onPress: () => closeItem(i, 'done') }),
705        Button({ key: 'drop-' + i.id, label: 'Drop', ...hk('x'), onPress: () => closeItem(i, 'dropped') }),
706        ...note,
707      ]
708    }
709
710    // Row width in title characters. The desktop draws a proportional font, about
711    // 1.4 characters per cell, so text wraps later there to use the pane's real width.
712    const cols = Math.max(12, (e.props.bodyColumns ?? 40) - 1)
713    const wide = e.surface === 'desktop' ? 1.4 : 1
714    // Row chrome: mark and gap (2), a spare column.
715    const titleCols = Math.max(16, Math.floor((cols - 3) * wide))
716
717    const projectOf = (i: Item) => i.project.split(/[\\/]/).filter(Boolean).pop() ?? i.project
718    // Where and when, each part in its own faint tint (a mid-tone drawn dim, readable
719    // on light and dark). The project is named only when the list spans projects:
720    // inside one project it would be the same word on every row. Closed items show
721    // how long ago they closed, which is also the Done tab's order.
722    // On the desktop each part is a pill tinted at low alpha; given `open`, the
723    // pills are buttons, so the whole row opens the card. The terminal cannot draw
724    // alpha, so there the parts stay faintly tinted text.
725    const pills = e.surface === 'desktop'
726    const metaLine = (i: Item, open?: () => void) => {
727      const room = Math.max(12, titleCols - 8)
728      const part = (k: 'project' | 'session' | 'age', text: string) => {
729        if (!pills) return Text({ color: META[k], dimColor: true, wrap: 'truncate-end', children: [text] })
730        const inner = open
731          ? Button({ key: 'meta-' + k + '-' + i.id, plain: true, label: text, onPress: open })
732          : Text({ children: [text] })
733        return Box({ backgroundColor: META_BG[k], paddingX: 1, children: [inner] })
734      }
735      const dot = Text({ dimColor: true, children: ['·'] })
736      const when = part('age', age(now, tab === 'done' ? i.updatedAt : i.createdAt))
737      const session = part('session', clip(sessionLabel(i), allProjects ? Math.floor(room / 2) : room))
738      const parts = allProjects ? [part('project', clip(projectOf(i), Math.ceil(room / 2))), session, when] : [session, when]
739      // Pills sit apart on their own; text parts need the dot between them.
740      const children = pills ? parts : parts.flatMap((x, k) => (k ? [dot, x] : [x]))
741      return Box({ key: 'meta-' + i.id, flexDirection: 'row', columnGap: 1, children })
742    }
743    // A started item shows ▶ in place of its group mark.
744    const markOf = (i: Item, mark: string, color?: string) =>
745      i.status === 'in_progress' ? Text({ color: ACCENT, children: ['▶'] }) : Text({ color, dimColor: !color, children: [mark] })
746
747    // Scanning: a mark, the title on up to two lines (both open the card), the meta
748    // line. Rows are set apart by one blank line, none after a group's last; no
749    // borders, no rules.
750    const compactRow = (i: Item, mark: string, color: string | undefined, last: boolean) => {
751      const open = () => select(i.id)
752      const lines = wrapLines(i.title, titleCols, 2)
753      return Box({
754        key: 'row-' + i.id,
755        flexDirection: 'row',
756        columnGap: 1,
757        alignItems: 'flex-start',
758        ...(last ? {} : { marginBottom: 1 }),
759        children: [
760          markOf(i, mark, color),
761          Box({
762            flexDirection: 'column',
763            flexGrow: 1,
764            flexShrink: 1,
765            children: [
766              Button({ key: 'title-' + i.id, plain: true, label: lines[0] ?? '', onPress: open }),
767              ...(lines[1] ? [Button({ key: 'title2-' + i.id, plain: true, label: lines[1], onPress: open })] : []),
768              metaLine(i, open),
769            ],
770          }),
771        ],
772      })
773    }
774
775    // One note: who and when on a dim line, the text under it; no blank between notes.
776    const noteView = (n: { by: string; text: string; at: string }, k: number, id: string) =>
777      Box({
778        key: 'note-' + k + '-' + id,
779        flexDirection: 'column',
780        children: [
781          Text({ dimColor: true, children: [(n.by === 'human' ? 'You' : 'Claude') + ' · ' + age(now, n.at)] }),
782          Text({ wrap: 'wrap', children: [n.text] }),
783        ],
784      })
785
786    // Acting: the picked item opens into a card outlined in the accent. Inside, by
787    // weight: the title bold, the same meta line as a row, details, the latest notes
788    // (older ones one press away), the composer when open, then the actions. The kind
789    // is not repeated: the card sits under its group heading.
790    const selectedCard = (i: Item, mark: string, color?: string) => {
791      const noting = notingId === i.id && canInput
792      const showAll = allNotesId === i.id
793      const hidden = showAll ? 0 : Math.max(0, i.notes.length - 3)
794      return Box({
795        key: 'row-' + i.id,
796        flexDirection: 'column',
797        borderStyle: 'round',
798        borderColor: ACCENT,
799        paddingX: 1,
800        marginBottom: 1,
801        children: [
802          Box({
803            flexDirection: 'row',
804            columnGap: 1,
805            alignItems: 'flex-start',
806            children: [
807              markOf(i, mark, color),
808              Box({
809                flexDirection: 'column',
810                flexGrow: 1,
811                flexShrink: 1,
812                children: [
813                  Text({ bold: true, wrap: 'wrap', children: [i.title] }),
814                  metaLine(i),
815                ],
816              }),
817              Button({ key: 'close-' + i.id, plain: true, dimColor: true, label: 'Close', onPress: () => select(null) }),
818            ],
819          }),
820          ...(i.body ? [Box({ marginTop: 1, children: [Text({ wrap: 'wrap', children: [i.body] })] })] : []),
821          ...(i.notes.length
822            ? [Box({
823                flexDirection: 'column',
824                marginTop: 1,
825                children: [
826                  hidden
827                    ? Button({ key: 'notes-more-' + i.id, plain: true, dimColor: true, label: 'Show ' + hidden + ' earlier',
828                        onPress: () => { allNotesId = i.id; redraw() } })
829                    : Text({ dimColor: true, children: ['Notes'] }),
830                  ...i.notes.slice(hidden).map((n, k) => noteView(n, k + hidden, i.id)),
831                ],
832              })]
833            : []),
834          ...(noting
835            ? [Box({ marginTop: 1, width: '100%', minHeight: 3, borderStyle: 'round', borderDimColor: true, paddingX: 1, children: [
836                Input({ key: 'note-input-' + i.id, value: '', placeholder: 'Write a note, then press Enter', submitLabel: 'add', autoFocus: true,
837                  onSubmit: async (v: string) => { notingId = null; await addNote($, i.id, v); redraw() } }),
838              ] })]
839            : []),
840          Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', marginTop: 1, children: actionsFor(i) }),
841        ],
842      })
843    }
844
845    const itemView = (i: Item, mark: string, color: string | undefined, last: boolean) =>
846      expandedId === i.id ? selectedCard(i, mark, color) : compactRow(i, mark, color, last)
847
848    // Group headings are secondary: dim, no count (the rows under them are the count).
849    const body = rows.length === 0
850      ? [Text({ dimColor: true, wrap: 'wrap', children: [
851          tab === 'done'
852            ? 'Nothing closed yet.'
853            : 'Nothing open. Claude adds what it parks or proposes; add your own with /todo add <text>.',
854        ] })]
855      : tab === 'done'
856        ? shown.map((i, k) => itemView(i, i.status === 'dropped' ? '✕' : '✓', undefined, k === shown.length - 1))
857        : groupItems(shown).flatMap((g, gi) => [
858            Box({ ...(gi === 0 ? {} : { marginTop: 1 }), children: [Text({ dimColor: true, children: [g.label] })] }),
859            ...g.items.map((i, k) => itemView(i, g.mark, g.color, k === g.items.length - 1)),
860          ])
861    const more = ordered.length - shown.length
862    if (more > 0) {
863      body.push(Box({ marginTop: 1, children: [Button({ key: 'show-all-rows', plain: true, dimColor: true,
864        label: 'Show ' + more + ' more', onPress: () => { showAllRows = true; redraw() } })] }))
865    }
866
867    return Box({
868      flexDirection: 'column',
869      children: [scopeRow, tabRow, ...body],
870    })
871    } catch (err: any) {
872      // A failed draw shows its reason instead of a blank pane.
873      $.ui.log('pane draw failed: ' + (err?.stack ?? err?.message ?? String(err)))
874      const { Text } = $.ui.resolve(e)
875      return Text({ wrap: 'wrap', children: ["ToDo's could not draw: " + (err?.message ?? String(err))] })
876    }
877  })
878
879  on('turn.start', async ($, e, next) => {
880    parkedThisTurn = false
881    if (e.agentId === undefined) replying = true
882    // The scan reads what the user said this turn to see which items they decided.
883    if (e.agentId === undefined && typeof e.text === 'string') lastUserText = e.text
884    // Picks up a /rename since the last turn, without holding the turn.
885    $.clock.after(0, () => lookupSessionName($))
886    // Other sessions may have written items; the next render reloads.
887    lastLoadAt = 0
888    return next(e)
889  })
890
891  on('turn.complete', async ($, e, next) => {
892    const result = await next(e)
893    if (e.agentId === undefined) replying = false
894    // Subagent turns and non-answer turns are not scanned.
895    if (e.agentId === undefined && e.reason === 'answer' && shouldScan(e.answer, parkedThisTurn)) {
896      const answer = e.answer
897      const userText = lastUserText
898      $.clock.after(0, () => runScan($, answer, userText))
899    }
900    return result
901  })
902
903  on('tool.call', { tool: 'mcp__todo-ledger__park_item' }, async ($, e) => {
904    if (!KINDS.includes(e.kind)) return { result: 'Not saved: kind must be one of ' + KINDS.join(', ') }
905    if (typeof e.title !== 'string' || !e.title.trim()) return { result: 'Not saved: title is required' }
906    try {
907      const r = await capture($, { title: e.title, kind: e.kind, origin: 'agent', source: 'tool', body: e.body })
908      parkedThisTurn = true
909      return { result: (r.isNew ? 'Parked ' : 'Already in ledger, added note to ') + r.item.id + ': ' + r.item.title }
910    } catch (err: any) {
911      $.ui.toast('ToDo not saved: ' + err.message)
912      return { result: 'Not saved: ' + err.message }
913    }
914  })
915
916  on('tool.call', { tool: 'mcp__todo-ledger__list_items' }, async ($, e) => {
917    try { return { result: formatList(await loadAll($), await $.session.cwd(), e.scope === 'all') } }
918    catch (err: any) { return { result: 'Not loaded: ' + err.message } }
919  })
920
921  on('tool.call', { tool: 'mcp__todo-ledger__get_item' }, async ($, e) => {
922    try {
923      const match = (await loadAll($)).filter((i) => typeof e.id === 'string' && e.id && i.id.startsWith(e.id))
924      return { result: match.length === 1 ? formatItem(match[0]) : 'No single item matches id ' + e.id }
925    } catch (err: any) { return { result: 'Not loaded: ' + err.message } }
926  })
927
928  on('tool.call', { tool: 'mcp__todo-ledger__update_item' }, async ($, e) => {
929    try {
930      const items = await loadAll($)
931      const match = items.filter((i) => typeof e.id === 'string' && i.id.startsWith(e.id))
932      if (match.length !== 1) return { result: 'No single item matches id ' + e.id }
933      if (e.kind !== undefined && !KINDS.includes(e.kind)) return { result: 'Not saved: kind must be one of ' + KINDS.join(', ') }
934      if (e.title !== undefined && (typeof e.title !== 'string' || !e.title.trim())) return { result: 'Not saved: title cannot be empty' }
935      // A wrong status used to be ignored while the reply still said "updated".
936      if (e.status !== undefined && !STATUS_VALUES.includes(e.status)) return { result: 'Not saved: status must be one of ' + STATUS_VALUES.join(', ') }
937      const item = match[0]
938      const at = new Date(await $.clock.now()).toISOString()
939      const edits = {
940        ...(e.title !== undefined ? { title: cleanTitle(e.title) } : {}),
941        ...(e.kind !== undefined ? { kind: e.kind as Kind } : {}),
942        ...(typeof e.body === 'string' ? { body: e.body } : {}),
943      }
944      const note = typeof e.note === 'string' && e.note.trim() ? e.note.trim() : ''
945      // One write for every change, so another session never sees half an update.
946      const final: Item = {
947        ...item, ...edits, status: (e.status as Status | undefined) ?? item.status, updatedAt: at,
948        notes: note ? [...item.notes, { by: 'agent' as Origin, text: note, at }] : item.notes,
949      }
950      await saveItem($, final)
951      const changed = ['status ' + final.status, ...Object.keys(edits), ...(note ? ['note added'] : [])]
952      return { result: 'Item ' + final.id + ' updated: ' + changed.join(', ') }
953    } catch (err: any) {
954      $.ui.toast('ToDo not saved: ' + err.message)
955      return { result: 'Not saved: ' + err.message }
956    }
957  })
958
959  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
960    const result = await next(e)
961    // Subagents' and workflow agents' working lists are theirs, not the person's ledger.
962    if (e.agentId !== undefined) return result
963    try {
964      await mirrorTodos($, Array.isArray(e.todos) ? e.todos : [])
965    } catch (err: any) {
966      $.ui.log('TodoWrite mirror failed: ' + err.message)
967    }
968    return result
969  })
970
971  on('command.run', { command: 'todo' }, async ($, e) => runTodo($, e.args))
972  on('command.run', { command: 'todos' }, async ($, e) => runTodos($, e.args))
973  // Fallback names, used only when /todo or /todos could not be registered.
974  on('command.run', { command: 'ledger-add' }, async ($, e) => runTodo($, e.args))
975  on('command.run', { command: 'ledger' }, async ($, e) => runTodos($, e.args))
976}
977
978
src/commands.ts 56 lines
1import { isOpen, sessionLabel } from './item.ts'
2import type { Item, Kind } from './item.ts'
3
4export type TodoCommand =
5  | { action: 'add'; kind: Kind; text: string }
6  | { action: 'done'; idPrefix: string }
7  | { action: 'delete'; idPrefix: string }
8  | { action: 'error'; message: string }
9
10const FLAGS: Record<string, Kind> = { '-d': 'deferred', '-p': 'proposal', '-n': 'discussion' }
11const USAGE = 'Usage: /todo add [-d|-p|-n] <text>  ·  /todo done <id>  ·  /todo delete <id> (dropped items only)'
12
13export function parseTodoCommand(args: string): TodoCommand {
14  const parts = args.trim().split(/\s+/).filter(Boolean)
15  if (parts[0] === 'done' && parts[1]) return { action: 'done', idPrefix: parts[1] }
16  if (parts[0] === 'delete' && parts[1]) return { action: 'delete', idPrefix: parts[1] }
17  if (parts[0] !== 'add') return { action: 'error', message: USAGE }
18  let kind: Kind = 'todo'
19  let rest = parts.slice(1)
20  if (rest[0] && FLAGS[rest[0]]) { kind = FLAGS[rest[0]]; rest = rest.slice(1) }
21  if (rest.length === 0) return { action: 'error', message: USAGE }
22  return { action: 'add', kind, text: rest.join(' ') }
23}
24
25export function parseTodosArgs(args: string): { action: 'toggle' } | { action: 'refresh' } | { action: 'list'; all: boolean } {
26  const parts = args.trim().split(/\s+/).filter(Boolean)
27  if (parts[0] === 'list') return { action: 'list', all: parts[1] === 'all' }
28  if (parts[0] === 'refresh') return { action: 'refresh' }
29  return { action: 'toggle' }
30}
31
32export function formatList(items: Item[], project: string, all: boolean): string {
33  const shown = items
34    .filter(isOpen)
35    .filter((i) => all || i.project.toLowerCase() === project.toLowerCase())
36    .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
37  if (shown.length === 0) return all ? 'No open ledger items.' : 'No open ledger items for this project.'
38  return shown
39    .map((i) => `- [${i.id}] (${i.kind}, ${i.origin}, ${i.status}) ${i.title}  · session ${sessionLabel(i)}` + (all ? `  · ${i.project}` : ''))
40    .join('\n')
41}
42
43// One item in full, for the get_item tool: everything an agent needs to act on it.
44export function formatItem(i: Item): string {
45  const lines = [
46    `[${i.id}] ${i.title}`,
47    `kind: ${i.kind} · status: ${i.status} · started by: ${i.origin} (${i.source})`,
48    `project: ${i.project}`,
49    `session: ${sessionLabel(i)} (${i.sessionId})`,
50    `created: ${i.createdAt} · updated: ${i.updatedAt}`,
51  ]
52  if (i.body) lines.push('', i.body)
53  if (i.notes.length) lines.push('', 'Notes:', ...i.notes.map((n) => `- ${n.by === 'human' ? 'user' : 'agent'} (${n.at}): ${n.text}`))
54  return lines.join('\n')
55}
56
src/scan.ts 64 lines
1export type ScanKind = 'todo' | 'deferred' | 'proposal'
2const SCAN_KINDS: ScanKind[] = ['todo', 'deferred', 'proposal']
3
4export const SCAN_SYSTEM =
5  'You keep a todo ledger current from one turn of a conversation between a USER and an AI coding assistant (the REPLY).\n' +
6  '1. items: things the assistant explicitly deferred, parked, left for later, put out of scope, or proposed without being ' +
7  'asked. Not work completed in the reply. Not anything already in KNOWN, even reworded.\n' +
8  '2. updates: KNOWN items this turn changed. "approve": the USER agreed to a proposal. "reject": the USER declined it. ' +
9  '"done": the REPLY shows its work was completed. "duplicate": it is the same thing as another KNOWN item (give "of": that id). ' +
10  'Use only ids from KNOWN. Only when the USER message or the REPLY says so clearly; when unsure, leave it out.\n' +
11  'Give each item a "detail": 2 to 4 short sentences in plain English, no jargon, saying what the task is and why it ' +
12  'was deferred or proposed.\n' +
13  'Reply with JSON only: {"items":[{"title":"one line","kind":"todo|deferred|proposal","detail":"..."}],' +
14  '"updates":[{"id":"…","action":"approve|reject|done|duplicate","of":"…"}]}. Use empty arrays when there is nothing.'
15
16export function shouldScan(answer: unknown, parked: boolean): boolean {
17  return typeof answer === 'string' && answer.length >= 400 && !parked
18}
19
20export type KnownItem = { id: string; title: string; kind: string }
21
22export function buildScanPrompt(answer: string, known: (string | KnownItem)[], userText?: string): string {
23  const lines = known.length
24    ? known.map((k) => (typeof k === 'string' ? '- ' + k : `- [${k.id}] (${k.kind}) ${k.title}`)).join('\n')
25    : '(none)'
26  const user = userText && userText.trim() ? 'USER:\n' + userText.trim().slice(-2000) + '\n\n' : ''
27  return 'KNOWN:\n' + lines + '\n\n' + user + 'REPLY:\n' + answer.slice(-6000)
28}
29
30export type ScanUpdate = { id: string; action: 'approve' | 'reject' | 'done' } | { id: string; action: 'duplicate'; of: string }
31const ACTIONS = ['approve', 'reject', 'done', 'duplicate']
32
33// Updates the model proposed, kept only when they name ids the prompt listed.
34export function parseScanUpdates(text: string, knownIds: string[]): ScanUpdate[] {
35  const start = text.indexOf('{')
36  const end = text.lastIndexOf('}')
37  if (start < 0 || end <= start) return []
38  let v: any
39  try { v = JSON.parse(text.slice(start, end + 1)) } catch { return [] }
40  if (!v || !Array.isArray(v.updates)) return []
41  const known = new Set(knownIds)
42  const out: ScanUpdate[] = []
43  for (const u of v.updates) {
44    if (!u || !known.has(u.id) || !ACTIONS.includes(u.action)) continue
45    if (u.action === 'duplicate') {
46      if (typeof u.of === 'string' && known.has(u.of) && u.of !== u.id) out.push({ id: u.id, action: 'duplicate', of: u.of })
47    } else out.push({ id: u.id, action: u.action })
48  }
49  return out
50}
51
52export function parseScanReply(text: string): { title: string; kind: ScanKind; detail?: string }[] | null {
53  const start = text.indexOf('{')
54  const end = text.lastIndexOf('}')
55  if (start < 0 || end <= start) return null
56  let v: any
57  try { v = JSON.parse(text.slice(start, end + 1)) } catch { return null }
58  if (!v || !Array.isArray(v.items)) return null
59  return v.items
60    .filter((i: any) => i && typeof i.title === 'string' && i.title.trim() && SCAN_KINDS.includes(i.kind))
61    .map((i: any) => ({ title: i.title.trim(), kind: i.kind,
62      ...(typeof i.detail === 'string' && i.detail.trim() ? { detail: i.detail.trim().slice(0, 600) } : {}) }))
63}
64
src/item.ts 163 lines
1export type Kind = 'todo' | 'deferred' | 'proposal' | 'discussion'
2export type Origin = 'agent' | 'human'
3export type Status = 'open' | 'in_progress' | 'done' | 'dropped'
4export type Source = 'tool' | 'todowrite' | 'scan' | 'manual'
5
6export interface Note { by: Origin; text: string; at: string }
7
8export interface Item {
9  id: string
10  title: string
11  body: string
12  kind: Kind
13  origin: Origin
14  status: Status
15  project: string
16  sessionId: string
17  // The session's name as its tab shows it (/rename), when it had one at capture.
18  sessionName?: string
19  source: Source
20  createdAt: string
21  updatedAt: string
22  notes: Note[]
23}
24
25export interface NewItemFields {
26  title: string
27  kind: Kind
28  origin: Origin
29  source: Source
30  project: string
31  sessionId: string
32  sessionName?: string
33  body?: string
34  nowMs: number
35  hex4: string
36}
37
38export const KINDS: Kind[] = ['todo', 'deferred', 'proposal', 'discussion']
39const STATUSES: Status[] = ['open', 'in_progress', 'done', 'dropped']
40
41const pad = (n: number) => String(n).padStart(2, '0')
42
43export function newId(nowMs: number, hex4: string): string {
44  const d = new Date(nowMs)
45  const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`
46  const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
47  return `${date}-${time}-${hex4}`
48}
49
50export function randomHex4(): string {
51  const b = new Uint8Array(2)
52  crypto.getRandomValues(b)
53  return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
54}
55
56export function cleanTitle(s: string): string {
57  const one = s.replace(/\s+/g, ' ').trim()
58  return one.length > 200 ? one.slice(0, 197) + '...' : one
59}
60
61export function dedupeKey(title: string, project: string): string {
62  return cleanTitle(title).toLowerCase() + '|' + project.toLowerCase()
63}
64
65export function isOpen(item: Item): boolean {
66  return item.status === 'open' || item.status === 'in_progress'
67}
68
69export function makeItem(f: NewItemFields): Item {
70  const at = new Date(f.nowMs).toISOString()
71  return {
72    id: newId(f.nowMs, f.hex4),
73    title: cleanTitle(f.title),
74    body: f.body ?? '',
75    kind: f.kind,
76    origin: f.origin,
77    status: 'open',
78    project: f.project,
79    sessionId: f.sessionId,
80    ...(f.sessionName ? { sessionName: f.sessionName } : {}),
81    source: f.source,
82    createdAt: at,
83    updatedAt: at,
84    notes: [],
85  }
86}
87
88export function parseItem(text: string): Item | null {
89  let v: any
90  try { v = JSON.parse(text) } catch { return null }
91  if (!v || typeof v !== 'object') return null
92  const strings = ['id', 'title', 'project', 'sessionId', 'createdAt', 'updatedAt']
93  if (!strings.every((k) => typeof v[k] === 'string')) return null
94  if (!KINDS.includes(v.kind) || !STATUSES.includes(v.status)) return null
95  if (v.origin !== 'agent' && v.origin !== 'human') return null
96  return { ...v, body: typeof v.body === 'string' ? v.body : '', notes: Array.isArray(v.notes) ? v.notes : [] }
97}
98
99// Cut text to at most `max` characters, ending in an ellipsis when cut.
100export function clip(s: string, max: number): string {
101  return s.length <= max ? s : s.slice(0, Math.max(1, max - 1)) + '…'
102}
103
104// Wrap text at word boundaries into at most `lines` lines of `width`; the last
105// line ends in an ellipsis when text is left over.
106export function wrapLines(s: string, width: number, lines: number): string[] {
107  const w = Math.max(4, width)
108  const out: string[] = []
109  let rest = s.replace(/\s+/g, ' ').trim()
110  while (rest && out.length < lines) {
111    if (rest.length <= w) { out.push(rest); rest = ''; break }
112    if (out.length === lines - 1) { out.push(clip(rest, w)); rest = ''; break }
113    const cut = rest.lastIndexOf(' ', w)
114    const at = cut > w / 3 ? cut : w
115    out.push(rest.slice(0, at).trimEnd())
116    rest = rest.slice(at).trimStart()
117  }
118  return out
119}
120
121// How the pane names the session an item came from: its name, else the id's first block.
122export function sessionLabel(item: Item): string {
123  return item.sessionName?.trim() || item.sessionId.split('-')[0]
124}
125
126// The newest session name in transcript lines (`{"type":"custom-title","customTitle":...}`).
127export function lastSessionTitle(lines: string): string | null {
128  let found: string | null = null
129  for (const line of lines.split(/\r?\n/)) {
130    if (!line.includes('"custom-title"')) continue
131    try {
132      const v = JSON.parse(line)
133      if (v?.type === 'custom-title' && typeof v.customTitle === 'string' && v.customTitle.trim()) found = v.customTitle.trim()
134    } catch { /* a partial line; skip it */ }
135  }
136  return found
137}
138// Words that carry no meaning for "is this the same item": articles, joiners and
139// the verbs titles start with ("add", "implement", "build"...).
140const STOP = new Set(['the', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'from', 'with', 'by', 'at', 'as', 'is', 'are',
141  'be', 'it', 'its', 'this', 'that', 'into', 'per', 'via', 'then', 'add', 'implement', 'build', 'make', 'create', 'support',
142  'handle', 'extract', 'display', 'show', 'use'])
143
144function titleWords(s: string): Set<string> {
145  const words = s.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
146    .filter((w) => w.length >= 2 && !STOP.has(w))
147    .map((w) => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w))
148  return new Set(words)
149}
150
151// Two titles name the same item when at least 70% of the shorter one's meaningful
152// words appear in the other. Titles under 3 meaningful words never match this way
153// ("Fix login" / "Fix logout"); exact duplicates are caught by dedupeKey instead.
154export function similarTitle(a: string, b: string): boolean {
155  const A = titleWords(a)
156  const B = titleWords(b)
157  const small = Math.min(A.size, B.size)
158  if (small < 3) return false
159  let shared = 0
160  for (const w of A) if (B.has(w)) shared++
161  return shared / small >= 0.7
162}
163
src/start.ts 58 lines
1import type { Item } from './item.ts'
2
3export type StartChoice = 'send' | 'draft' | 'terminal' | 'copy'
4
5export interface StartOption { value: StartChoice; label: string; hotkey: string }
6
7// The ways to start an item, drawn as one button each (one press, no picker).
8// The first is the main one. Terminals can open a new window; the desktop copies instead.
9export function startOptions(surface: 'terminal' | 'desktop'): StartOption[] {
10  const common: StartOption[] = [
11    { value: 'send', label: 'Send', hotkey: 's' },
12    { value: 'draft', label: 'Draft', hotkey: 'd' },
13  ]
14  return surface === 'terminal'
15    ? [...common, { value: 'terminal', label: 'Terminal', hotkey: 't' }]
16    : [...common, { value: 'copy', label: 'Copy', hotkey: 'c' }]
17}
18
19export function startPrompt(item: Item, cwd: string): string {
20  const lines = [
21    `Work on this ledger item (id ${item.id}, ${item.kind}, from project ${item.project}):`,
22    item.title,
23  ]
24  if (item.body) lines.push(item.body)
25  if (item.notes.length) lines.push('Notes:', ...item.notes.map((n) => `- ${n.by}: ${n.text}`))
26  if (item.project.toLowerCase() !== cwd.toLowerCase()) {
27    lines.push(`Note: this item came from a different project (${item.project}); this session is in ${cwd}.`)
28  }
29  lines.push(`When finished, call update_item with id ${item.id} and status done.`)
30  return lines.join('\n')
31}
32
33// The full prompt never goes on a command line: Windows Terminal splits its own command line on ';'
34// (even inside one argument) and a newline ends a cmd command line. The new session gets a short
35// one-line task that points at a file holding the full prompt.
36export function terminalTask(id: string, file: string): string {
37  return `Work on todo-ledger item ${id}. Read the file ${file} and follow the instructions in it.`
38}
39
40function oneLine(s: string): string {
41  return s.replace(/[\r\n]+/g, ' ')
42}
43
44// wt documents '\;' as the escape for a literal ';' in its arguments.
45function wtEscape(s: string): string {
46  return oneLine(s).replace(/;/g, '\\;')
47}
48
49export function terminalArgv(project: string, task: string): string[] {
50  return ['wt', '-d', wtEscape(project), 'claude', wtEscape(task)]
51}
52
53// Fallback without Windows Terminal: `start` opens a new console in the project directory, where
54// `cmd /k` runs claude. No '&&' element: the outer `cmd /c` would run what follows it itself.
55export function fallbackArgv(project: string, task: string): string[] {
56  return ['cmd', '/c', 'start', '', '/d', oneLine(project), 'cmd', '/k', 'claude', oneLine(task)]
57}
58
src/view.ts 74 lines
1import { isOpen } from './item.ts'
2import type { Item, Kind } from './item.ts'
3
4export type Tab = 'agent' | 'human' | 'done'
5
6export const TABS: { id: Tab; label: string; hotkey: string }[] = [
7  { id: 'agent', label: 'Agent', hotkey: '1' },
8  { id: 'human', label: 'Human', hotkey: '2' },
9  { id: 'done', label: 'Done', hotkey: '3' },
10]
11
12// Tabs split by who started the item; the kind shows as a tag on the row instead.
13export function tabOf(item: Item): Tab {
14  if (!isOpen(item)) return 'done'
15  return item.origin === 'agent' ? 'agent' : 'human'
16}
17
18// Groups inside an open tab, ordered by what the item needs from the person:
19// a decision first, then work, then talk, then what was put off.
20export const GROUPS: { kind: Kind; label: string; mark: string; color?: string }[] = [
21  { kind: 'proposal', label: 'Needs decision', mark: '◆', color: 'suggestion' },
22  { kind: 'todo', label: 'To do', mark: '•' },
23  { kind: 'discussion', label: 'Discussion', mark: '◇' },
24  { kind: 'deferred', label: 'Parked', mark: '◇' },
25]
26
27export function groupItems(items: Item[]): { kind: Kind; label: string; mark: string; color?: string; items: Item[] }[] {
28  return GROUPS.map((g) => ({ ...g, items: items.filter((i) => i.kind === g.kind) })).filter((g) => g.items.length > 0)
29}
30
31export function itemsForTab(items: Item[], tab: Tab, project: string | null): Item[] {
32  const p = project?.toLowerCase() ?? null
33  const sortKey = (i: Item) => (tab === 'done' ? i.updatedAt : i.createdAt)
34  return items
35    .filter((i) => tabOf(i) === tab)
36    .filter((i) => p === null || i.project.toLowerCase() === p)
37    .sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
38    .slice(0, tab === 'done' ? 50 : 100)
39}
40
41const inProject = (i: Item, project: string | null) => project === null || i.project.toLowerCase() === project.toLowerCase()
42
43// The footer button. It counts what the pane first shows (this project's open
44// items) and is always drawn, so the pane (Done tab included) stays reachable.
45// Compact: it shares the footer row with the mode labels.
46export function bandLabel(items: Item[], project: string | null = null): string {
47  const open = items.filter((i) => isOpen(i) && inProject(i, project))
48  return "☰ ToDo's" + (open.length ? ' ' + open.length : '')
49}
50
51// Where the pane opens: this project unless it has nothing open while others do,
52// and the open tab with items (Agent first) rather than an empty one.
53export function firstView(items: Item[], project: string, tab: Tab, allProjects: boolean): { tab: Tab; allProjects: boolean } {
54  const open = items.filter(isOpen)
55  const all = allProjects || (!open.some((i) => inProject(i, project)) && open.length > 0)
56  const scoped = open.filter((i) => all || inProject(i, project))
57  const has = (t: Tab) => scoped.some((i) => tabOf(i) === t)
58  if (tab !== 'done' && has(tab)) return { tab, allProjects: all }
59  return { tab: has('agent') ? 'agent' : has('human') ? 'human' : tab, allProjects: all }
60}
61
62// The list in the order the pane draws it: groups in GROUPS order inside an open tab.
63export function displayOrder(rows: Item[], tab: Tab): Item[] {
64  return tab === 'done' ? rows : groupItems(rows).flatMap((g) => g.items)
65}
66
67export function age(nowMs: number, iso: string): string {
68  const s = Math.max(0, (nowMs - Date.parse(iso)) / 1000)
69  if (s < 60) return 'now'
70  if (s < 3600) return Math.floor(s / 60) + 'm'
71  if (s < 86400) return Math.floor(s / 3600) + 'h'
72  return Math.floor(s / 86400) + 'd'
73}
74