one quiet to-do pane for every conversation: what this one is for, questions waiting on you, PRs to merge, and what's left

a to-do pane beside the chat, shared by every conversation. when you come back to a conversation it tells you what this one was doing, what other conversations asked you, and what is left.
•ᴥ• 3 to-do · 1 asked you refresh
╭─────────────────────────────────────────────╮
│ notes app my-app │
│ last: the list works; offline sync is next. │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ asked you 1 waiting │
│ games chat · 3h │
│ make the office prototype first? │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ to-do 3 open │
│ [add a to-do…] │
│ │
│ this conversation │
│ ☐ test offline sync │
│ │
│ my-app │
│ ☐ write the retry docs │
│ ↗ merge the notes PR · waiting for merge │
╰─────────────────────────────────────────────╯
▸ done 8
▸ tidy up: 32 of 80 conversations have nothing open
looked at 14:05
it looks like the other puppy mods: one card per part (style).
it opens by itself when a session starts. /todo opens it again; /todo <text> adds a to-do for this conversation.
/todo <text>.done to open it again.that's all. everything else happens by itself.
done if it closed without merging.todo_add, todo_done and todo_list, and a line in your CLAUDE.md asks it to add what is left at the end of a conversation and tick what it finished.one small json file per item in ~/.claude/todo/items/, so conversations writing at the same time don't overwrite each other's items. the file name is the id.
{
"schemaVersion": 2,
"id": "20261025-1210-m8p2",
"text": "call the vet about the shots",
"project": "home",
"kind": "todo",
"status": "open",
"session": { "id": "…", "title": "notes app", "cwd": "…" },
"by": "claude",
"createdAt": "2026-10-25T04:10:00.000Z",
"completion": { "mode": "declared" }
}
status: "done", doneAt, and declaration (who said it: person for a tick in the pane; recordedBy: "claude" when you said it in a conversation)."source": { "type": "github-pr", "host": "github.com", "repo": "you/my-app", "number": 78 } and "completion": { "mode": "source", "condition": "merged" }. github decides; status in the file is ignored. a plain link decides nothing.deletedAt and deletedBy. the file stays; the item is never shown again.without the mod (codex, a script, you), write a file in this shape.
a line for your CLAUDE.md:
before ending a conversation, add what is still left with
todo_add(passprwhen a PR merging finishes it), or write a file to~/.claude/todo/items/. tick withtodo_doneonly when the person said it is done (by: person) or you did it and checked (by: claude).
~/.claude/todo/.gh cli, signed in./plugin install puppy-todo --marketplace nonbiri1004/puppy-mods
MIT
hooks/register.tsx 790 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Convo, Item, PrSource, PrState, Session, Talk, View, WorktreeState } from '../types'
5import { folderFiles, isWorktree, matchByTime, parseSessions, parseWorktree, pickByTitle, sortConvos, verdictOf } from './convos'
6import {
7 FACES,
8 ago,
9 byProject,
10 clip,
11 clockTime,
12 declareDone,
13 deleteItem,
14 isHere,
15 isOpenItem,
16 mood,
17 newId,
18 newestTalks,
19 outcomeOf,
20 parseItem,
21 parsePrState,
22 parsePrUrl,
23 parseScan,
24 prAnswer,
25 prKey,
26 prUrl,
27 projectOf,
28 recentAsks,
29 reopen,
30 saying,
31 shown,
32 sourceOf,
33} from './todo'
34
35const PANE = 'puppy-todo'
36// conversations older than this, or past this many, are left out of "asked you"
37const TALK_DAYS = 14
38const TALK_MAX = 40
39// how often the pane looks again (the shared list, transcripts); gh only every GH_EVERY
40const TICK = 60_000
41const GH_EVERY = 5 * 60_000
42
43const EMPTY: View = {
44 here: { id: '', title: '', project: '', cwd: '' },
45 talks: [],
46 items: [],
47 prStates: {},
48 convos: null,
49 isShowingDone: false,
50 isShowingConvos: false,
51 at: null,
52 notes: [],
53}
54const view = atom({ plugin: 'puppy-todo', key: 'view' } as const, EMPTY)
55
56// per transcript: what it said, read again only when the file changes (a reload starts over)
57const talkCache = new Map<string, { mtimeMs: number; talk: Talk }>()
58let ghAt = 0
59// the gh that ran last time
60let ghBin: string | null = null
61let ticking = false
62
63// reads the end of each transcript and prints only what parseScan needs (X for a file it can't read): titles, PR links,
64// wrap-up rows (recap + question), "U" for each typed prompt (not the notes the app slips in,
65// which start with "<"), and the last working folder
66const SCAN = `for f in "$@"; do
67printf '\\036%s\\n' "$f"
68if [ ! -r "$f" ]; then printf 'X\\n'; continue; fi
69tail -c 1500000 "$f" | LC_ALL=C grep -a -F -e '"type":"custom-title"' -e '"type":"pr-link"' -e '"name":"mcp__ccd_turn__wrap_up","input"' -e '"message":{"role":"user","content":"' | LC_ALL=C grep -a -v '"type":"api-request' | LC_ALL=C awk '/"name":"mcp__ccd_turn__wrap_up","input"/ {print; next} /"message":\\{"role":"user","content":"</ {next} /"message":\\{"role":"user","content":"/ {print "U"; next} {print}'
70c=$(tail -c 30000 "$f" | LC_ALL=C grep -a -o '"cwd":"[^"]*"' | tail -1)
71printf 'C:%s\\n' "$c"
72done`
73const LIST = `find "$1" -maxdepth 2 -name '*.jsonl' -mtime -${TALK_DAYS} -not -path '*scratch-workspaces*' -print0 | xargs -0 stat -f '%Fm %N'`
74
75async function home($: EngineInterface): Promise<string> {
76 return (await $.env.get('HOME')) ?? ''
77}
78
79async function todoDir($: EngineInterface, part: 'items'): Promise<string> {
80 return `${await home($)}/.claude/todo/${part}`
81}
82
83async function readJsonDir<T>($: EngineInterface, dir: string, parse: (text: string, name: string) => T | null): Promise<T[]> {
84 if (!(await $.fs.exists(dir))) {
85 return []
86 }
87 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
88 const read: (T | null)[] = await Promise.all(
89 files.map(f =>
90 $.fs
91 .read(`${dir}/${f.name}`)
92 .then(text => parse(text, f.name))
93 .catch(() => null),
94 ),
95 )
96
97 return read.filter((x): x is T => x !== null)
98}
99
100async function readItems($: EngineInterface): Promise<Item[]> {
101 return readJsonDir($, await todoDir($, 'items'), parseItem)
102}
103
104async function writeItem($: EngineInterface, item: Item): Promise<void> {
105 await $.fs.write(`${await todoDir($, 'items')}/${item.id}.json`, `${JSON.stringify(item, null, 2)}\n`)
106}
107
108async function projectHere($: EngineInterface): Promise<string> {
109 const repo = await $.session.repo()
110 const fromRemote = repo?.remote?.replace(/\.git$/, '').split(/[/:]/).pop()
111
112 return fromRemote || projectOf(repo?.root ?? (await $.session.cwd()))
113}
114
115type TranscriptFile = { path: string; mtimeMs: number }
116
117// every transcript changed in the last TALK_DAYS days, newest first
118async function listTranscripts($: EngineInterface): Promise<TranscriptFile[]> {
119 const listed = await $.process.run(['sh', '-c', LIST, 'sh', `${await home($)}/.claude/projects`])
120
121 return listed.stdout
122 .split('\n')
123 .map(line => {
124 const cut = line.indexOf(' ')
125
126 return { mtimeMs: Number(line.slice(0, cut)) * 1000, path: line.slice(cut + 1) }
127 })
128 .filter(f => f.path.endsWith('.jsonl') && Number.isFinite(f.mtimeMs))
129 .sort((a, b) => b.mtimeMs - a.mtimeMs)
130}
131
132// what each transcript says, read again only when the file changed
133async function scanTalks($: EngineInterface, files: readonly TranscriptFile[]): Promise<Map<string, Talk>> {
134 const stale = files.filter(f => talkCache.get(f.path)?.mtimeMs !== f.mtimeMs)
135 if (stale.length > 0) {
136 const mtimes = new Map(stale.map(f => [f.path, f.mtimeMs]))
137 const scanned = await $.process.run(['sh', '-c', SCAN, 'sh', ...stale.map(f => f.path)], { timeoutMs: 120_000 })
138 const talks = parseScan(scanned.stdout, mtimes)
139 stale.forEach((f, i) => {
140 const talk = talks[i]
141 // a file that couldn't be read is read again next time, and until then is no transcript
142 if (talk && !talk.isUnread) {
143 talkCache.set(f.path, { mtimeMs: f.mtimeMs, talk })
144 } else {
145 talkCache.delete(f.path)
146 }
147 })
148 }
149 const out = new Map<string, Talk>()
150 for (const f of files) {
151 const talk = talkCache.get(f.path)?.talk
152 if (talk) {
153 out.set(f.path, talk)
154 }
155 }
156
157 return out
158}
159
160// the app's open (not archived) conversations; null away from the desktop app. a big list comes back
161// as something other than JSON (the app caps what one call returns), so ask for fewer until it parses
162const SESSION_LIMITS = [150, 80, 40]
163
164async function readSessions($: EngineInterface): Promise<{ list: Session[]; limit: number } | null> {
165 for (const limit of SESSION_LIMITS) {
166 try {
167 const got = await $.mcp.call('ccd_session_mgmt', 'list_sessions', { limit })
168 const block = got.content[0] as { type?: string; text?: string } | undefined
169 if (got.isError || block?.type !== 'text' || !block.text) {
170 return null
171 }
172 const list = parseSessions(block.text)
173 if (list.length > 0 || block.text.trim() === '[]') {
174 return { list, limit }
175 }
176 } catch {
177 return null
178 }
179 }
180
181 return null
182}
183
184// "asked you" shows a question only from a conversation that is still open in the app (matched
185// by title: transcripts don't carry the app's id); away from the app, every conversation counts
186function isLiveTitle(sessions: readonly Session[] | null, title: string): boolean {
187 return sessions === null || sessions.some(s => s.title === title)
188}
189
190// a worktree's uncommitted changes and commits on no remote; null when it isn't a worktree or is gone
191async function readWorktree($: EngineInterface, cwd: string): Promise<WorktreeState | null> {
192 if (!isWorktree(cwd) || !(await $.fs.exists(cwd))) {
193 return null
194 }
195 try {
196 const [status, unpushed] = await Promise.all([
197 $.process.run(['git', '-C', cwd, 'status', '--porcelain']),
198 $.process.run(['git', '-C', cwd, 'rev-list', '--count', 'HEAD', '--not', '--remotes']),
199 ])
200
201 return parseWorktree(status, unpushed)
202 } catch {
203 return { state: 'unknown' }
204 }
205}
206
207// one row per conversation the app has open, with what is still open in it
208async function readConvos(
209 $: EngineInterface,
210 c: { sessions: readonly Session[]; files: readonly TranscriptFile[]; items: readonly Item[]; prStates: Record<string, PrState>; here: View['here'] },
211): Promise<Convo[]> {
212 const projects = `${await home($)}/.claude/projects`
213 const live = c.sessions.filter(s => !s.isArchived)
214 const paths = matchByTime(live, c.files, projects)
215 // no file at the moment of the last activity: match the rest by title, leaving out files another
216 // conversation already matched
217 let unmatched = live.filter(s => paths.get(s.id) === null)
218 const claimed = new Set([...paths.values()].filter((p): p is string => p !== null))
219 const wanted = new Set([...claimed, ...unmatched.flatMap(s => folderFiles(s, c.files, projects).map(f => f.path))])
220 const talks = await scanTalks(
221 $,
222 c.files.filter(f => wanted.has(f.path)),
223 )
224 // a file matched by time whose titles don't include the conversation's is someone else's
225 for (const s of live) {
226 const path = paths.get(s.id)
227 const titles = path ? talks.get(path)?.titles : undefined
228 if (path && titles && titles.length > 0 && !titles.includes(s.title)) {
229 paths.set(s.id, null)
230 claimed.delete(path)
231 unmatched = [...unmatched, s]
232 }
233 }
234 // the folders of conversations sent back here may not have been read yet
235 const more = c.files.filter(f => !talks.has(f.path) && unmatched.some(s => folderFiles(s, [f], projects).length > 0))
236 if (more.length > 0) {
237 for (const [path, talk] of await scanTalks($, more)) {
238 talks.set(path, talk)
239 }
240 }
241 for (const s of unmatched) {
242 const candidates = folderFiles(s, c.files, projects).map(f => ({ ...f, titles: talks.get(f.path)?.titles ?? [] }))
243 const path = pickByTitle(s, candidates, claimed)
244 paths.set(s.id, path)
245 if (path) {
246 claimed.add(path)
247 }
248 }
249
250 return Promise.all(
251 live.map(async session => {
252 const path = paths.get(session.id)
253 const talk = path ? (talks.get(path) ?? null) : null
254 const worktree = await readWorktree($, session.cwd)
255 const isThis = session.title === c.here.title && session.cwd === c.here.cwd && talk?.id === c.here.id
256
257 return {
258 session,
259 ...(talk?.recap ? { recap: talk.recap } : {}),
260 verdict: verdictOf({
261 session,
262 talk,
263 items: c.items,
264 isOpenItem: i => isOpenItem(i, c.prStates),
265 worktree,
266 isHere: isThis || (path !== null && path !== undefined && path.endsWith(`/${c.here.id}.jsonl`)),
267 }),
268 }
269 }),
270 )
271}
272
273// runs gh from where it is: the app may not have homebrew on its PATH
274async function gh($: EngineInterface, args: readonly string[]): Promise<{ ok: boolean; out: string } | null> {
275 for (const bin of ghBin ? [ghBin] : ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']) {
276 try {
277 const ran = await $.process.run([bin, ...args])
278 ghBin = bin
279
280 return { ok: ran.exitCode === 0, out: ran.exitCode === 0 ? ran.stdout : ran.stderr }
281 } catch {
282 // not at this path; try the next
283 }
284 }
285
286 return null
287}
288
289// asks github about each PR an item waits on, one by one: a PR missing from the open-PR search
290// proves nothing (past the limit, or someone else's), so every tracked PR is asked by its URL
291async function readPrStates($: EngineInterface, items: readonly Item[]): Promise<Record<string, PrState>> {
292 const sources = new Map<string, PrSource>()
293 for (const i of items) {
294 const s = sourceOf(i)
295 if (s) {
296 sources.set(prKey(s), s)
297 }
298 }
299 const got = await Promise.all(
300 [...sources.entries()].map(async ([key, s]) => {
301 const ran = await gh($, ['pr', 'view', prUrl(s), '--json', 'state,mergedAt,closedAt'])
302 const state = ran?.ok ? parsePrState(ran.out) : null
303
304 return state ? ([key, state] as const) : null
305 }),
306 )
307
308 return Object.fromEntries(got.filter(x => x !== null))
309}
310
311
312// reads every source again; github only when asked or every few minutes
313async function refresh($: EngineInterface, isFull = false): Promise<void> {
314 const now = await $.clock.now()
315 const ownId = await $.session.id()
316 const notes: string[] = []
317 const [items, files, got, project, cwd] = await Promise.all([
318 readItems($).catch(() => {
319 notes.push('could not read ~/.claude/todo/items')
320
321 return [] as Item[]
322 }),
323 listTranscripts($).catch(() => {
324 notes.push('could not read the transcripts')
325
326 return [] as TranscriptFile[]
327 }),
328 readSessions($),
329 projectHere($),
330 $.session.cwd(),
331 ])
332 const sessions = got?.list ?? null
333 if (got && got.list.length >= got.limit) {
334 notes.push(`showing the newest ${got.limit} conversations`)
335 }
336 const recent = files.filter((f, i) => i < TALK_MAX || f.path.endsWith(`/${ownId}.jsonl`))
337 const talks = [...(await scanTalks($, recent).catch(() => new Map<string, Talk>())).values()]
338 const was = await read($, view)
339 let { prStates } = was
340 const missing = items.filter(i => sourceOf(i) && !prStates[prKey(sourceOf(i) as PrSource)])
341 if (isFull || now - ghAt > GH_EVERY) {
342 ghAt = now
343 prStates = await readPrStates($, items)
344 } else if (missing.length > 0) {
345 // a new PR item: check it now rather than in five minutes
346 prStates = { ...prStates, ...(await readPrStates($, missing)) }
347 }
348 const own = talks.find(t => t.id === ownId)
349 const others = newestTalks(talks).filter(
350 t => t.id !== ownId && t.title !== '' && !(own && t.title === own.title && t.cwd === own.cwd) && isLiveTitle(sessions, t.title),
351 )
352 const here = { id: ownId, title: own?.title || (await firstPrompt($)) || '', project, cwd, recap: own?.recap }
353 const convos = sessions
354 ? await readConvos($, { sessions, files, items, prStates, here }).catch(() => {
355 notes.push('could not read the conversations')
356
357 return null
358 })
359 : null
360 await update($, view, v => ({ ...v, here, talks: others, convos, items, prStates, at: now, notes }))
361}
362
363// what the person first asked here, as a title when the app gave the conversation none
364async function firstPrompt($: EngineInterface): Promise<string | undefined> {
365 const messages = await $.session.messages()
366 const first = messages.find(m => m.role === 'user' && m.text.trim() !== '' && !m.text.startsWith('<'))
367
368 return first ? clip(first.text, 40) : undefined
369}
370
371function startTicking($: EngineInterface): void {
372 if (ticking) {
373 return
374 }
375 ticking = true
376 $.clock.every(TICK, () => {
377 void refresh($).catch(() => undefined)
378 })
379}
380
381async function add($: EngineInterface, text: string, extra: { project?: string; link?: string; pr?: PrSource; by?: string } = {}): Promise<Item> {
382 const now = await $.clock.now()
383 const v = await read($, view)
384 const item: Item = {
385 schemaVersion: 2,
386 id: newId(now),
387 text: text.trim(),
388 project: extra.project || v.here.project || projectOf(await $.session.cwd()),
389 kind: 'todo',
390 status: 'open',
391 session: { id: await $.session.id(), title: v.here.title || undefined, cwd: await $.session.cwd() },
392 ...(extra.pr ? { link: prUrl(extra.pr) } : extra.link ? { link: extra.link } : {}),
393 by: extra.by ?? 'claude',
394 createdAt: new Date(now).toISOString(),
395 ...(extra.pr
396 ? { source: extra.pr, completion: { mode: 'source', condition: 'merged' } as const }
397 : { completion: { mode: 'declared' } as const }),
398 }
399 await writeItem($, item)
400 const prStates = extra.pr ? { ...v.prStates, ...(await readPrStates($, [item])) } : v.prStates
401 await update($, view, was => ({ ...was, items: [...was.items, item], prStates }))
402
403 return item
404}
405
406
407// records that someone says an item is done (or open again). a PR item can't be declared:
408// github is asked instead, and the answer comes back
409async function declare(
410 $: EngineInterface,
411 id: string,
412 how: { isDone: boolean; by: string; recordedBy?: string; note?: string },
413): Promise<{ item: Item; said: string } | null> {
414 const item = (await readItems($)).find(i => i.id === id)
415 if (!item) {
416 return null
417 }
418 if (sourceOf(item) || item.completion?.mode === 'source') {
419 const fresh = await readPrStates($, [item])
420 const answer = prAnswer(item, fresh, (await read($, view)).prStates)
421 await update($, view, was => ({ ...was, prStates: { ...was.prStates, ...answer.states } }))
422
423 return { item, said: answer.said }
424 }
425 const at = new Date(await $.clock.now()).toISOString()
426 const next = how.isDone ? declareDone(item, { by: how.by, recordedBy: how.recordedBy, note: how.note, at }) : reopen(item, at)
427 await writeItem($, next)
428 await update($, view, was => ({ ...was, items: was.items.map(i => (i.id === id ? next : i)) }))
429
430 return { item: next, said: next.status === 'done' ? `done (${how.by} said so)` : 'open again' }
431}
432
433
434// a tick or ✕ in the pane: the person did it, so the person is who said so
435async function press($: EngineInterface, id: string, what: 'done' | 'open' | 'delete'): Promise<void> {
436 const item = (await readItems($)).find(i => i.id === id)
437 if (!item || sourceOf(item)) {
438 return
439 }
440 const at = new Date(await $.clock.now()).toISOString()
441 const next = what === 'done' ? declareDone(item, { by: 'person', at }) : what === 'open' ? reopen(item, at) : deleteItem(item, { by: 'person', at })
442 await writeItem($, next)
443 await update($, view, was => ({ ...was, items: was.items.map(i => (i.id === id ? next : i)) }))
444}
445
446function line(i: Item, prStates: Readonly<Record<string, PrState>>): string {
447 const o = outcomeOf(i, prStates)
448 const from = i.session?.title ? ` · from ${i.session.title}` : ''
449 const state = o.state === 'open' ? '' : ` {${o.state}${o.why ? `: ${o.why}` : ''}}`
450
451 return `${i.id} ${i.project}: ${i.text}${i.link ? ` (${i.link})` : ''}${state}${from}`
452}
453
454const field = (e: unknown, name: string): unknown => (e as Record<string, unknown>)[name]
455const text = (e: unknown, name: string): string | undefined => {
456 const v = field(e, name)
457
458 return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
459}
460
461export const register: Register = on => {
462 on('session.start', async ($, e, next) => {
463 await $.command.register({
464 name: 'todo',
465 description: 'open the to-do pane; /todo <text> adds one for this conversation',
466 argumentHint: '[text]',
467 })
468 await $.tool.register({
469 name: 'todo_add',
470 description:
471 'Add a to-do to the shared list every conversation sees (~/.claude/todo/items): something the person asked for ' +
472 'that is still left after this turn, one short sentence. Check todo_list first and do not add the same thing twice. ' +
473 'Not every work step, not optional ideas, not "shall I go on". When it is done exactly when a GitHub PR merges, ' +
474 'pass the PR as `pr`: GitHub then ticks it. This conversation and project are filled in.',
475 inputSchema: {
476 type: 'object',
477 properties: {
478 text: { type: 'string', description: 'what is left, one short sentence' },
479 project: { type: 'string', description: 'repo name; defaults to this conversation’s repo' },
480 pr: { type: 'string', description: 'https://github.com/<owner>/<repo>/pull/<n>; the item is done when it merges' },
481 link: { type: 'string', description: 'a PR, issue or doc URL to show; it decides nothing' },
482 },
483 required: ['text'],
484 },
485 isDeferred: false,
486 })
487 await $.tool.register({
488 name: 'todo_done',
489 description:
490 'Tick a shared to-do as done, or open it again with reopen: true. Only when the person said it is done ' +
491 '(by: "person", their words in note) or you did it yourself and checked the result (by: "claude"). Never because ' +
492 'it looks likely. Items tied to a PR are ticked by GitHub; this only returns what GitHub says.',
493 inputSchema: {
494 type: 'object',
495 properties: {
496 id: { type: 'string' },
497 by: { type: 'string', enum: ['person', 'claude'] },
498 note: { type: 'string', description: 'why it is done, in a few words' },
499 reopen: { type: 'boolean' },
500 },
501 required: ['id', 'by'],
502 },
503 isDeferred: false,
504 })
505 await $.tool.register({
506 name: 'todo_list',
507 description: 'List the open shared to-dos with their ids, optionally for one project.',
508 inputSchema: { type: 'object', properties: { project: { type: 'string' } } },
509 isDeferred: false,
510 })
511 const started = await next(e)
512 void $.ui.open({ id: PANE, title: 'todo' })
513 void refresh($, true).catch(() => undefined)
514 startTicking($)
515
516 return started
517 })
518
519 on('turn.complete', async ($, e, next) => {
520 const done = await next(e)
521 void refresh($).catch(() => undefined)
522
523 return done
524 })
525
526 on('tool.call', { tool: 'mcp__puppy-todo__todo_add' }, async ($, e) => {
527 try {
528 const said = text(e, 'text')
529 if (!said) {
530 return { deny: 'text is required' }
531 }
532 const prText = text(e, 'pr')
533 const pr = prText ? parsePrUrl(prText) : null
534 if (prText && !pr) {
535 return { deny: 'pr must look like https://github.com/<owner>/<repo>/pull/<n>' }
536 }
537 const item = await add($, said, { project: text(e, 'project'), link: text(e, 'link'), pr: pr ?? undefined })
538
539 return { result: `added ${line(item, (await read($, view)).prStates)}` }
540 } catch (err) {
541 return { deny: `could not add: ${String(err)}` }
542 }
543 })
544
545 on('tool.call', { tool: 'mcp__puppy-todo__todo_done' }, async ($, e) => {
546 try {
547 const id = text(e, 'id')
548 const by = text(e, 'by')
549 if (!id || (by !== 'person' && by !== 'claude')) {
550 return { deny: 'id and by (person or claude) are required' }
551 }
552 const got = await declare($, id, {
553 isDone: field(e, 'reopen') !== true,
554 by,
555 // the person said it in a conversation; Claude wrote it down
556 recordedBy: by === 'person' ? 'claude' : undefined,
557 note: text(e, 'note'),
558 })
559
560 return got ? { result: `${got.said}: ${line(got.item, (await read($, view)).prStates)}` } : { deny: `no item with id ${id}` }
561 } catch (err) {
562 return { deny: `could not record it: ${String(err)}` }
563 }
564 })
565
566 on('tool.call', { tool: 'mcp__puppy-todo__todo_list' }, async ($, e) => {
567 try {
568 const project = text(e, 'project')
569 const items = (await readItems($)).filter(i => !project || i.project === project)
570 const prStates = await readPrStates($, items)
571 const open = shown(items, prStates, 0).filter(i => isOpenItem(i, prStates))
572
573 return { result: open.length === 0 ? 'nothing open' : open.map(i => line(i, prStates)).join('\n') }
574 } catch (err) {
575 return { deny: `could not read the list: ${String(err)}` }
576 }
577 })
578
579 on('command.run', { command: 'todo' }, async ($, e) => {
580 await $.ui.open({ id: PANE, title: 'todo' })
581 if (e.args.trim() === '') {
582 void refresh($, true).catch(() => undefined)
583
584 return { text: 'the to-do pane is open.' }
585 }
586 const item = await add($, e.args, { by: 'person' })
587
588 return { text: `added: ${item.text}` }
589 })
590
591 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
592 const table = $.ui.resolve(e)
593 const { Box, Text, Button, Link } = table
594 // the phone app draws no field yet
595 const Input = 'Input' in table ? table.Input : undefined
596 const v = await read($, view)
597 if (v.at === null) {
598 return <Text dimColor>{FACES.calm} sniffing around…</Text>
599 }
600 const now = v.at
601 const width = Math.max(30, (e.viewport?.columns ?? 60) - 4)
602
603 const all = shown(v.items, v.prStates, now)
604 const open = all.filter(i => isOpenItem(i, v.prStates))
605 const done = all.filter(i => !isOpenItem(i, v.prStates))
606 const mine = open.filter(i => isHere(i, v.here))
607 const others = open.filter(i => !isHere(i, v.here))
608 const asks = recentAsks(v.talks, now)
609 const clear = (v.convos ?? []).filter(c => c.verdict.state === 'clear')
610
611 // inside a card: the border and padding take 4 columns (STYLE.md)
612 const inner = width - 4
613 // the terminal cuts by columns; a graphical surface measures the words and cuts at the edge itself
614 const isGraphical = e.surface !== 'terminal'
615 const fit = (text: string, max: number) => clip(text, isGraphical ? 300 : max)
616 // a card: a rounded dim border, the name on the left, quiet facts on the right
617 const card = (key: string, name: string, right: RenderChildren, body: RenderChildren) => (
618 <Box key={`card-${key}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap="xs">
619 <Box flexDirection="row" justifyContent="space-between" gap={1}>
620 <Box flexShrink={1} minWidth={0}>
621 <Text weight="semibold" wrap="truncate-end">{name}</Text>
622 </Box>
623 <Text dimColor>{right}</Text>
624 </Box>
625 {body}
626 </Box>
627 )
628 // the ✕ shows when the pointer is on the row (the desktop); with no pointer it always shows
629 const hasPointer = e.surface === 'desktop'
630 // an open to-do: a tick, its words, and a ✕ at the far right. a PR item has no tick or ✕: github ticks it
631 const row = (i: Item) => {
632 const isPr = sourceOf(i) !== null
633 const o = outcomeOf(i, v.prStates)
634 const scope = `row-${i.id}`
635
636 return (
637 <Box key={`i-${i.id}`} flexDirection="row" gap={1} alignItems="center" hover={{ scope }}>
638 {/* one width for the tick and the arrow, so the words line up */}
639 <Box width={3} justifyContent="center">
640 {isPr ? <Text dimColor>↗</Text> : <Button key={`tick-${i.id}`} label="☐" plain onPress={() => press($, i.id, 'done')} />}
641 </Box>
642 <Box flexGrow={1} flexShrink={1} minWidth={0}>
643 <Text wrap="truncate-end">{fit(i.text, inner - 10)}</Text>
644 </Box>
645 {isPr ? <Text dimColor>{o.state === 'unknown' ? 'not checked yet' : 'waiting for merge'}</Text> : null}
646 {i.link?.startsWith('https://') ? <Link href={i.link} label="link" /> : null}
647 {isPr ? null : (
648 <Box {...(hasPointer ? { display: 'none', hover: { scope, display: 'flex' } } : {})}>
649 <Button key={`del-${i.id}`} label="✕" plain dimColor onPress={() => press($, i.id, 'delete')} />
650 </Box>
651 )}
652 </Box>
653 )
654 }
655 const group = (key: string, label: string, list: readonly Item[]) => (
656 <Box key={key} flexDirection="column">
657 <Text dimColor>{label}</Text>
658 {list.map(i => row(i))}
659 </Box>
660 )
661 const ASKS_SHOWN = 3
662
663 return (
664 <Box flexDirection="column" gap={1}>
665 <Box flexDirection="row" justifyContent="space-between" gap={1}>
666 <Box flexShrink={1} minWidth={0}>
667 <Text>
668 {FACES[mood(asks.length, open.length)]}
669 <Text dimColor> {saying(asks.length, open.length)}</Text>
670 </Text>
671 </Box>
672 <Button key="refresh" label="refresh" dimColor onPress={() => refresh($, true)} />
673 </Box>
674
675 {card(
676 'here',
677 fit(v.here.title || 'this conversation', inner - v.here.project.length - 4),
678 v.here.project,
679 v.here.recap ? <Text dimColor wrap="truncate-end">last: {fit(v.here.recap, inner - 6)}</Text> : null,
680 )}
681
682 {asks.length > 0 &&
683 card(
684 'asks',
685 'asked you',
686 <Text color="warning">{asks.length} waiting</Text>,
687 <Box flexDirection="column">
688 {asks.slice(0, ASKS_SHOWN).map(t => (
689 <Box key={`ask-${t.id}`} flexDirection="column">
690 <Text wrap="truncate-end">
691 {fit(t.title, inner - 16)}
692 <Text dimColor> · {ago(now, t.mtimeMs)}</Text>
693 </Text>
694 <Text dimColor wrap="truncate-end"> {fit(t.ask ?? '', inner - 2)}</Text>
695 </Box>
696 ))}
697 {asks.length > ASKS_SHOWN ? <Text dimColor>and {asks.length - ASKS_SHOWN} more</Text> : null}
698 </Box>,
699 )}
700
701 {card(
702 'todo',
703 'to-do',
704 `${open.length} open`,
705 <Box flexDirection="column" gap={1}>
706 {Input ? (
707 <Input
708 key="add"
709 placeholder="add a to-do…"
710 submitLabel="add"
711 onSubmit={said => {
712 if (said.trim() !== '') {
713 void add($, said, { by: 'person' })
714 }
715 }}
716 />
717 ) : null}
718 {mine.length > 0 ? group('here', 'this conversation', mine) : null}
719 {byProject(others).map(([project, list]) => group(`p-${project}`, project, list))}
720 {open.length === 0 ? <Text dimColor>nothing to do. the puppy naps.</Text> : null}
721 </Box>,
722 )}
723
724 {done.length > 0 && (
725 <Box flexDirection="column">
726 <Button
727 key="show-done"
728 label={`${v.isShowingDone ? '▾' : '▸'} done ${done.length}`}
729 dimColor
730 onPress={() => update($, view, was => ({ ...was, isShowingDone: !was.isShowingDone }))}
731 />
732 {v.isShowingDone
733 ? done.map(i => (
734 <Box key={`d-${i.id}`} flexDirection="row" gap={1}>
735 {sourceOf(i) ? (
736 <Text dimColor>{outcomeOf(i, v.prStates).state === 'canceled' ? '✕' : '✓'}</Text>
737 ) : (
738 <Button key={`untick-${i.id}`} label="☑" dimColor onPress={() => press($, i.id, 'open')} />
739 )}
740 <Text dimColor strikethrough>
741 {fit(i.text, width - 8)}
742 </Text>
743 </Box>
744 ))
745 : null}
746 </Box>
747 )}
748
749 {v.convos !== null && (
750 <Box flexDirection="column">
751 <Button
752 key="show-convos"
753 label={`${v.isShowingConvos ? '▾' : '▸'} tidy up: ${clear.length} of ${v.convos.length} conversations have nothing open`}
754 dimColor
755 onPress={() => update($, view, was => ({ ...was, isShowingConvos: !was.isShowingConvos }))}
756 />
757 {v.isShowingConvos ? (
758 <Box flexDirection="column">
759 <Text dimColor>nothing open found is a hint, not a promise. archive from the sidebar.</Text>
760 {sortConvos(v.convos).map(c => (
761 <Box key={`c-${c.session.id}`} flexDirection="column">
762 <Text dimColor={c.verdict.state !== 'clear'}>
763 {{ clear: '✓', open: '●', unknown: '?' }[c.verdict.state]} {fit(c.session.title, width - 24)}
764 <Text dimColor>
765 {' '}
766 · {ago(now, c.session.lastActivityMs)}
767 {c.session.isPinned ? ' · pinned' : ''}
768 </Text>
769 </Text>
770 <Text dimColor>
771 {' '}
772 {fit(c.verdict.reasons.length > 0 ? c.verdict.reasons.join(' · ') : 'nothing open found', width * 2)}
773 </Text>
774 {c.recap ? <Text dimColor> last: {fit(c.recap, width * 2)}</Text> : null}
775 </Box>
776 ))}
777 </Box>
778 ) : null}
779 </Box>
780 )}
781
782 <Text dimColor>
783 looked at {clockTime(now)}
784 {v.notes.length > 0 ? ` · ${v.notes.join(' · ')}` : ''}
785 </Text>
786 </Box>
787 )
788 })
789}
790hooks/convos.ts 176 lines1// the conversation list: which of the app's conversations can be archived. no $ here; tests import it
2import type { Convo, Item, Session, Talk, Verdict, WorktreeState } from '../types'
3
4import { isHere } from './todo'
5
6// a transcript's folder under ~/.claude/projects: the working folder with every other character as -
7export function projectDir(cwd: string): string {
8 return cwd.replace(/[^A-Za-z0-9]/g, '-')
9}
10
11// `list_sessions` as the app returns it; rows missing an id, a title or a folder are left out
12export function parseSessions(text: string): Session[] {
13 try {
14 const rows = JSON.parse(text) as Record<string, unknown>[]
15
16 return rows.flatMap(r => {
17 if (typeof r.sessionId !== 'string' || typeof r.title !== 'string' || typeof r.cwd !== 'string') {
18 return []
19 }
20 const at = typeof r.lastActivityAt === 'string' ? Date.parse(r.lastActivityAt) : NaN
21
22 return [
23 {
24 id: r.sessionId,
25 title: r.title,
26 cwd: r.cwd,
27 isArchived: r.isArchived === true,
28 isPinned: r.pinned === true,
29 lastActivityMs: Number.isFinite(at) ? at : 0,
30 ...(typeof r.prNumber === 'number' ? { prNumber: r.prNumber } : {}),
31 ...(typeof r.prState === 'string' ? { prState: r.prState } : {}),
32 },
33 ]
34 })
35 } catch {
36 return []
37 }
38}
39
40// which transcript is this conversation's latest: the app writes the transcript as it works, so the
41// file in the conversation's folder changed at its last activity. a resumed conversation has several
42// transcripts and forks share a title, so the time is what tells them apart
43export const SAME_MOMENT_MS = 5_000
44
45export function pickTranscript(s: Session, files: readonly { path: string; mtimeMs: number }[], projects: string): string | null {
46 const mine = folderFiles(s, files, projects)
47 let best: { path: string; gap: number } | null = null
48 for (const f of mine) {
49 const gap = Math.abs(f.mtimeMs - s.lastActivityMs)
50 if (!best || gap < best.gap) {
51 best = { path: f.path, gap }
52 }
53 }
54 // the file named after the app's id is only the first transcript: a reopened conversation goes on
55 // in a newer one, so without a match in time the title match (newest first) decides
56 return best && best.gap <= SAME_MOMENT_MS ? best.path : null
57}
58
59// when no transcript changed at the last activity (the app moves that time when a conversation is
60// pinned or renamed, and writes to a transcript when it opens it again), the newest transcript in its
61// folder that ever had its title, among those no other conversation already matched by time.
62// a resumed conversation has several such files; the newest is its latest
63export function pickByTitle(
64 s: Session,
65 candidates: readonly { path: string; mtimeMs: number; titles: readonly string[] }[],
66 claimed: ReadonlySet<string>,
67): string | null {
68 const same = candidates.filter(c => !claimed.has(c.path) && c.titles.includes(s.title)).sort((a, b) => b.mtimeMs - a.mtimeMs)
69
70 return same[0]?.path ?? null
71}
72
73// the transcripts in a conversation's folder
74export function folderFiles<T extends { path: string }>(s: Session, files: readonly T[], projects: string): T[] {
75 const dir = `${projects}/${projectDir(s.cwd)}/`
76
77 return files.filter(f => f.path.startsWith(dir) && !f.path.slice(dir.length).includes('/'))
78}
79
80// every conversation's transcript by time, each file to one conversation at most: the closest
81// pairs first, so two conversations active a moment apart in one folder never share a transcript
82export function matchByTime(sessions: readonly Session[], files: readonly { path: string; mtimeMs: number }[], projects: string): Map<string, string | null> {
83 const pairs = sessions.flatMap(s =>
84 folderFiles(s, files, projects)
85 .map(f => ({ id: s.id, path: f.path, gap: Math.abs(f.mtimeMs - s.lastActivityMs) }))
86 .filter(p => p.gap <= SAME_MOMENT_MS),
87 )
88 pairs.sort((a, b) => a.gap - b.gap)
89 const out = new Map<string, string | null>(sessions.map(s => [s.id, null]))
90 const taken = new Set<string>()
91 for (const p of pairs) {
92 if (out.get(p.id) === null && !taken.has(p.path)) {
93 out.set(p.id, p.path)
94 taken.add(p.path)
95 }
96 }
97
98 return out
99}
100
101// `git status --porcelain` lines, and the number of commits on no remote
102export function parseWorktree(status: { exitCode: number; stdout: string }, unpushed: { exitCode: number; stdout: string }): WorktreeState {
103 if (status.exitCode !== 0 || unpushed.exitCode !== 0) {
104 return { state: 'unknown' }
105 }
106 const n = Number(unpushed.stdout.trim())
107
108 return {
109 state: 'read',
110 changed: status.stdout.split('\n').filter(l => l.trim() !== '').length,
111 unpushed: Number.isFinite(n) ? n : 0,
112 }
113}
114
115export function isWorktree(cwd: string): boolean {
116 return /\/\.claude\/worktrees\/[^/]+\/?$/.test(cwd)
117}
118
119// what is still open in a conversation, as facts. no open thing found is "clear", which only says
120// nothing was found, not that it is finished; anything that can't be read makes it unknown
121export function verdictOf(c: {
122 session: Session
123 talk: Talk | null
124 items: readonly Item[]
125 isOpenItem: (i: Item) => boolean
126 worktree: WorktreeState | null
127 isHere: boolean
128}): Verdict {
129 const reasons: string[] = []
130 if (c.isHere) {
131 return { state: 'open', reasons: ['this conversation'] }
132 }
133 if (c.talk?.ask) {
134 reasons.push(`asked you: ${c.talk.ask}`)
135 }
136 if (c.talk) {
137 // any title the conversation had: a to-do written before a rename is still its own
138 const titles = new Set([c.session.title, ...c.talk.titles])
139 const open = c.items.filter(
140 i => c.isOpenItem(i) && [...titles].some(title => isHere(i, { id: c.talk!.id, title, cwd: c.session.cwd })),
141 )
142 if (open.length > 0) {
143 reasons.push(`${open.length} open to-do${open.length > 1 ? 's' : ''}`)
144 }
145 }
146 if (c.session.prState === 'OPEN') {
147 reasons.push(`PR #${c.session.prNumber ?? '?'} not merged`)
148 }
149 if (c.worktree?.state === 'read') {
150 if (c.worktree.changed > 0) {
151 reasons.push(`${c.worktree.changed} uncommitted change${c.worktree.changed > 1 ? 's' : ''}`)
152 }
153 if (c.worktree.unpushed > 0) {
154 reasons.push(`${c.worktree.unpushed} commit${c.worktree.unpushed > 1 ? 's' : ''} not pushed`)
155 }
156 }
157 if (reasons.length > 0) {
158 return { state: 'open', reasons }
159 }
160 if (!c.talk) {
161 return { state: 'unknown', reasons: ['could not find its last turn'] }
162 }
163 if (c.worktree?.state === 'unknown') {
164 return { state: 'unknown', reasons: ['could not check its folder'] }
165 }
166
167 return { state: 'clear', reasons: [] }
168}
169
170// clear ones first, oldest first, so tidying starts with the stalest; then open; then unknown
171export function sortConvos(list: readonly Convo[]): Convo[] {
172 const rank = { clear: 0, open: 1, unknown: 2 }
173
174 return [...list].sort((a, b) => rank[a.verdict.state] - rank[b.verdict.state] || a.session.lastActivityMs - b.session.lastActivityMs)
175}
176hooks/todo.ts 394 lines1// the parts with no $: ids, parsing transcripts and items, sorting. tests import these.
2import type { Item, Kind, Outcome, PrSource, PrState, Talk } from '../types'
3
4export const KINDS: readonly Kind[] = ['todo', 'ask', 'later']
5// the puppy at the top of the pane, no headphones (a to-do isn't music, so no orange either):
6// hello while another conversation asked you something, happy when nothing is open, calm otherwise
7export type Mood = 'calm' | 'hello' | 'happy'
8export const FACES: Record<Mood, string> = { calm: '•ᴥ•', hello: '°ᴥ°', happy: '^ᴥ^' }
9
10export function mood(asks: number, open: number): Mood {
11 if (asks > 0) {
12 return 'hello'
13 }
14
15 return open === 0 ? 'happy' : 'calm'
16}
17
18// the words beside the face
19export function saying(asks: number, open: number): string {
20 if (asks === 0 && open === 0) {
21 return 'all clear'
22 }
23 const parts = [`${open} to-do`]
24 if (asks > 0) {
25 parts.push(`${asks} asked you`)
26 }
27
28 return parts.join(' · ')
29}
30
31// a question from another conversation shows this long after it was asked, then fades by itself
32export const ASK_DAYS = 2
33
34export function recentAsks(talks: readonly Talk[], now: number): Talk[] {
35 return talks.filter(t => t.ask && now - t.mtimeMs < ASK_DAYS * DAY).sort((a, b) => b.mtimeMs - a.mtimeMs)
36}
37
38// done items stay visible this long, then only the file remains
39export const DONE_DAYS = 3
40const DAY = 86_400_000
41
42// 20261024-1432-k3f9: sorts by time, short enough to type
43export function newId(now: number, rand: () => number = Math.random): string {
44 const d = new Date(now)
45 const two = (n: number) => String(n).padStart(2, '0')
46 const stamp = `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
47 const tail = Math.floor(rand() * 36 ** 4).toString(36).padStart(4, '0')
48
49 return `${stamp}-${tail}`
50}
51
52// a repo's name from its working folder: worktrees count as their repo
53export function projectOf(cwd: string): string {
54 const repo = cwd.replace(/\/\.claude\/worktrees\/[^/]+\/?$/, '').replace(/\/+$/, '')
55
56 return repo.slice(repo.lastIndexOf('/') + 1) || repo
57}
58
59// a file someone (or another AI) wrote by hand: keep what is usable, drop the rest
60export function parseItem(text: string): Item | null {
61 let raw: unknown
62 try {
63 raw = JSON.parse(text)
64 } catch {
65 return null
66 }
67 if (typeof raw !== 'object' || raw === null) {
68 return null
69 }
70 const o = raw as Record<string, unknown>
71 if (typeof o.id !== 'string' || typeof o.text !== 'string' || o.text.trim() === '') {
72 return null
73 }
74 const kind = KINDS.includes(o.kind as Kind) ? (o.kind as Kind) : 'todo'
75 // a null or a non-object in these is read as not written; an object with a strange mode
76 // stays, and shows as "not understood"
77 const isObject = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v)
78 const { completion, source, declaration, ...known } = o
79
80 return {
81 ...(known as Partial<Item>),
82 ...(isObject(completion) ? { completion: completion as Item['completion'] } : {}),
83 ...(isObject(source) ? { source: source as Item['source'] } : {}),
84 ...(isObject(declaration) ? { declaration: declaration as Item['declaration'] } : {}),
85 id: o.id,
86 text: o.text.trim(),
87 project: typeof o.project === 'string' && o.project ? o.project : 'misc',
88 kind,
89 status: o.status === 'done' ? 'done' : 'open',
90 createdAt: typeof o.createdAt === 'string' ? o.createdAt : '',
91 }
92}
93
94// https://github.com/<owner>/<repo>/pull/<n>, nothing else
95export function parsePrUrl(url: string): PrSource | null {
96 const m = /^https:\/\/(github\.com)\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)\/?$/.exec(url.trim())
97
98 return m && m[1] && m[2] && m[3] ? { type: 'github-pr', host: m[1], repo: m[2], number: Number(m[3]) } : null
99}
100
101export function prKey(s: PrSource): string {
102 return `${s.host}/${s.repo}#${s.number}`
103}
104
105export function prUrl(s: PrSource): string {
106 return `https://${s.host}/${s.repo}/pull/${s.number}`
107}
108
109// the PR an item waits on, when its completion says so
110export function sourceOf(item: Item): PrSource | null {
111 const s = item.source
112 if (item.completion?.mode !== 'source' || s?.type !== 'github-pr') {
113 return null
114 }
115
116 return typeof s.repo === 'string' && /^[\w.-]+\/[\w.-]+$/.test(s.repo) && Number.isInteger(s.number) && s.number > 0
117 ? { type: 'github-pr', host: 'github.com', repo: s.repo, number: s.number }
118 : null
119}
120
121// is it done? a source item asks its source and ignores what the file says;
122// a declared item believes the file, and says who said it
123export function outcomeOf(item: Item, prStates: Readonly<Record<string, PrState>>): Outcome {
124 if (item.completion?.mode === 'source') {
125 const source = sourceOf(item)
126 if (!source || (item.completion.condition ?? 'merged') !== 'merged') {
127 return { state: 'unknown', basis: 'source', why: 'the done rule in the file is not understood' }
128 }
129 const pr = prStates[prKey(source)]
130 if (!pr) {
131 return { state: 'unknown', basis: 'source', why: 'not checked on github yet' }
132 }
133 if (pr.state === 'MERGED') {
134 return { state: 'done', basis: 'source', why: 'merged', endedAt: pr.endedAt }
135 }
136 if (pr.state === 'CLOSED') {
137 return { state: 'canceled', basis: 'source', why: 'closed without merge', endedAt: pr.endedAt }
138 }
139
140 return { state: 'open', basis: 'source', why: 'done when the PR merges' }
141 }
142 if (item.completion !== undefined && item.completion.mode !== 'declared') {
143 return { state: 'unknown', basis: 'declared', why: 'the done rule in the file is not understood' }
144 }
145 if (item.status !== 'done') {
146 return { state: 'open', basis: item.completion ? 'declared' : 'legacy' }
147 }
148 const d = item.declaration
149 if (d && typeof d.by === 'string') {
150 const said = d.recordedBy && d.recordedBy !== d.by ? `${d.by} said done (noted by ${d.recordedBy})` : `${d.by} said done`
151
152 return { state: 'done', basis: 'declared', why: said, endedAt: d.at || item.doneAt }
153 }
154
155 return { state: 'done', basis: 'legacy', why: 'marked done, nobody recorded who', endedAt: item.doneAt }
156}
157
158// the item after someone says it is done: who said it, who wrote it down (when someone else did), when
159export function declareDone(item: Item, how: { by: string; recordedBy?: string; note?: string; at?: string } = { by: 'person' }): Item {
160 const at = how.at ?? new Date().toISOString()
161 const { declaration: _gone, doneAt: _alsoGone, ...rest } = item
162
163 return {
164 ...rest,
165 schemaVersion: 2,
166 completion: item.completion ?? { mode: 'declared' },
167 status: 'done',
168 doneAt: at,
169 declaration: {
170 by: how.by,
171 ...(how.recordedBy && how.recordedBy !== how.by ? { recordedBy: how.recordedBy } : {}),
172 at,
173 ...(how.note ? { note: how.note } : {}),
174 },
175 }
176}
177
178// deleted: never shown again, the file stays (nothing is erased)
179export function deleteItem(item: Item, how: { by: string; at?: string }): Item {
180 return { ...item, schemaVersion: 2, deletedAt: how.at ?? new Date().toISOString(), deletedBy: how.by }
181}
182
183// the item open again: the old declaration no longer holds; reopenedAt keeps when
184export function reopen(item: Item, at: string = new Date().toISOString()): Item {
185 const { declaration: _gone, doneAt: _alsoGone, ...rest } = item
186
187 return { ...rest, schemaVersion: 2, completion: item.completion ?? { mode: 'declared' }, status: 'open', reopenedAt: at }
188}
189
190// what to say when someone tries to mark a PR item done: github is asked instead.
191// fresh is what github just said (empty when it could not be asked); known is what the pane knew
192export function prAnswer(item: Item, fresh: Readonly<Record<string, PrState>>, known: Readonly<Record<string, PrState>>): { states: Record<string, PrState>; said: string } {
193 // one set of states for the words and for the line beside them
194 const states = { ...known, ...fresh }
195 const o = outcomeOf(item, states)
196 const source = sourceOf(item)
197 const isFresh = source !== null && prKey(source) in fresh
198 const what = `${o.state}${o.why ? ` (${o.why})` : ''}`
199
200 return {
201 states,
202 said: isFresh
203 ? `not changed: this item is done when github says so. github: ${what}`
204 : `not changed: this item is done when github says so. could not ask github now; last known: ${what}`,
205 }
206}
207
208const isOpen = (o: Outcome) => o.state === 'open' || o.state === 'unknown'
209
210// open first (oldest first, so nothing sinks), then what ended in the last few days
211export function shown(items: readonly Item[], prStates: Readonly<Record<string, PrState>>, now: number): Item[] {
212 const withOutcome = items.filter(i => !i.deletedAt).map(i => ({ i, o: outcomeOf(i, prStates) }))
213 const open = withOutcome.filter(x => isOpen(x.o)).sort((a, b) => a.i.id.localeCompare(b.i.id))
214 const ended = withOutcome
215 .filter(x => !isOpen(x.o) && x.o.endedAt && now - Date.parse(x.o.endedAt) < DONE_DAYS * DAY)
216 .sort((a, b) => (b.o.endedAt ?? '').localeCompare(a.o.endedAt ?? ''))
217
218 return [...open, ...ended].map(x => x.i)
219}
220
221export function isOpenItem(item: Item, prStates: Readonly<Record<string, PrState>>): boolean {
222 return !item.deletedAt && isOpen(outcomeOf(item, prStates))
223}
224
225// a conversation as a person knows it: its title and folder. the engine's session id changes
226// each time the app opens it again, so it can't tie rows to one conversation on its own
227export function talkKey(title: string, cwd: string): string {
228 return `${title}\u0000${cwd}`
229}
230
231// the newest transcript of each conversation; older ones of the same conversation are past turns
232export function newestTalks(talks: readonly Talk[]): Talk[] {
233 const best = new Map<string, Talk>()
234 for (const t of talks) {
235 const key = talkKey(t.title, t.cwd)
236 const was = best.get(key)
237 if (!was || t.mtimeMs > was.mtimeMs) {
238 best.set(key, t)
239 }
240 }
241
242 return talks.filter(t => best.get(talkKey(t.title, t.cwd)) === t)
243}
244
245// an item belongs to this conversation when this session wrote it, or a past session of the same
246// conversation did (same title and folder)
247export function isHere(item: Item, here: { id: string; title: string; cwd: string }): boolean {
248 const s = item.session
249 if (!s) {
250 return false
251 }
252
253 return s.id === here.id || (here.title !== '' && s.title === here.title && s.cwd === here.cwd)
254}
255
256export function byProject<T extends { project: string }>(list: readonly T[]): [string, T[]][] {
257 const groups = new Map<string, T[]>()
258 for (const one of list) {
259 groups.set(one.project, [...(groups.get(one.project) ?? []), one])
260 }
261
262 return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
263}
264
265// the lines SCAN prints: \x1e<path> starts a file, X says it couldn't be read, U stands for something the person typed,
266// C:"<cwd>" is the last working folder, every other line is a whole transcript row
267export function parseScan(out: string, mtimes: ReadonlyMap<string, number>): Talk[] {
268 const talks: Talk[] = []
269 let talk: Talk | null = null
270 // whether the last wrap-up still waits: nothing typed after it
271 let isOpen = false
272 let ask: string | undefined
273 let askId: string | undefined
274 const finish = () => {
275 if (talk) {
276 talks.push({ ...talk, ask: isOpen ? ask : undefined, askId: isOpen ? askId : undefined })
277 }
278 }
279 for (const line of out.split('\n')) {
280 if (line.startsWith('\x1e')) {
281 finish()
282 const path = line.slice(1)
283 const id = path.slice(path.lastIndexOf('/') + 1).replace(/\.jsonl$/, '')
284 talk = { id, title: '', titles: [], project: '', cwd: '', prs: [], mtimeMs: mtimes.get(path) ?? 0 }
285 isOpen = false
286 ask = undefined
287 askId = undefined
288 continue
289 }
290 if (!talk || line === '') {
291 continue
292 }
293 if (line === 'X') {
294 talk.isUnread = true
295 continue
296 }
297 if (line === 'U') {
298 isOpen = false
299 continue
300 }
301 if (line.startsWith('C:')) {
302 const cwd = line.slice(2).replace(/^"cwd":"|"$/g, '')
303 talk.cwd = cwd
304 talk.project = projectOf(cwd)
305 continue
306 }
307 let row: Record<string, unknown>
308 try {
309 row = JSON.parse(line)
310 } catch {
311 continue
312 }
313 if (row.type === 'custom-title' && typeof row.customTitle === 'string') {
314 talk.title = row.customTitle
315 if (!talk.titles.includes(row.customTitle)) {
316 talk.titles.push(row.customTitle)
317 }
318 } else if (row.type === 'pr-link' && typeof row.prUrl === 'string') {
319 if (!talk.prs.includes(row.prUrl)) {
320 talk.prs.push(row.prUrl)
321 }
322 } else if (row.type === 'assistant') {
323 const wrap = wrapUpOf(row)
324 if (wrap) {
325 talk.recap = wrap.recap
326 ask = wrap.ask
327 askId = wrap.id
328 isOpen = wrap.ask !== undefined
329 }
330 }
331 }
332 finish()
333
334 return talks
335}
336
337function wrapUpOf(row: Record<string, unknown>): { recap?: string; ask?: string; id?: string } | null {
338 const content = (row.message as { content?: unknown } | undefined)?.content
339 if (!Array.isArray(content)) {
340 return null
341 }
342 for (const block of content) {
343 const b = block as { type?: string; id?: unknown; name?: string; input?: { recap?: unknown; ask?: unknown } }
344 if (b.type === 'tool_use' && b.name === 'mcp__ccd_turn__wrap_up' && b.input) {
345 return {
346 id: typeof b.id === 'string' ? b.id : undefined,
347 recap: typeof b.input.recap === 'string' ? b.input.recap : undefined,
348 ask: typeof b.input.ask === 'string' && b.input.ask ? b.input.ask : undefined,
349 }
350 }
351 }
352
353 return null
354}
355
356// `gh pr view --json state,mergedAt,closedAt`
357export function parsePrState(out: string): PrState | null {
358 try {
359 const o = JSON.parse(out) as { state?: string; mergedAt?: string | null; closedAt?: string | null }
360 if (o.state === 'MERGED') {
361 return { state: 'MERGED', endedAt: o.mergedAt ?? o.closedAt ?? undefined }
362 }
363 if (o.state === 'CLOSED') {
364 return { state: 'CLOSED', endedAt: o.closedAt ?? undefined }
365 }
366
367 return o.state === 'OPEN' ? { state: 'OPEN' } : null
368 } catch {
369 return null
370 }
371}
372
373export function clip(text: string, max: number): string {
374 const one = text.replace(/\s+/g, ' ').trim()
375
376 return one.length > max ? `${one.slice(0, max - 1)}…` : one
377}
378
379export function clockTime(ms: number): string {
380 const d = new Date(ms)
381
382 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
383}
384
385export function ago(now: number, ms: number): string {
386 const m = Math.round((now - ms) / 60_000)
387 if (m < 60) {
388 return `${Math.max(m, 0)}m`
389 }
390 const hours = Math.round(m / 60)
391
392 return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
393}
394types/index.d.ts 127 lines1// one to-do in the shared list: ~/.claude/todo/items/<id>.json
2export type Item = {
3 // 2 for items with source/completion/declaration; missing means the first format
4 schemaVersion?: number
5 id: string
6 text: string
7 // the repo or project it belongs to, e.g. baibai-music
8 project: string
9 // todo: the person does it; ask: a question waiting for the person; later: a conversation said it would do it later
10 kind: Kind
11 // for declared items: what someone said. for source items: ignored, the source decides
12 status: 'open' | 'done'
13 // the conversation that wrote it, when known
14 session?: { id?: string; title?: string; cwd?: string }
15 // a PR, issue or doc it points at (a link alone decides nothing)
16 link?: string
17 // who wrote the item: claude, codex or person
18 by?: string
19 createdAt: string
20 doneAt?: string
21 // where the answer to "is it done?" lives; only github PRs so far
22 source?: PrSource
23 // declared: someone says it is done. source: the source says it is done
24 completion?: { mode: 'declared' | 'source'; condition?: 'merged' }
25 // who said it is done, for declared items; cleared when it opens again
26 declaration?: Declaration
27 // when it was last opened again after being done
28 reopenedAt?: string
29 // deleted: never shown, the file stays
30 deletedAt?: string
31 deletedBy?: string
32}
33
34export type Kind = 'todo' | 'ask' | 'later'
35
36export type PrSource = { type: 'github-pr'; host: string; repo: string; number: number }
37
38export type Declaration = {
39 // who says it is done: person, claude or codex
40 by: string
41 // who wrote it down, when someone else said it
42 recordedBy?: string
43 at: string
44 note?: string
45}
46
47// what a PR is right now, by `gh pr view`; endedAt is when it was merged or closed
48export type PrState = { state: 'OPEN' | 'MERGED' | 'CLOSED'; endedAt?: string }
49
50// an item as the pane shows it: worked out from the file and its source, never stored
51export type Outcome = {
52 state: 'open' | 'done' | 'canceled' | 'unknown'
53 // source: checked at the source. declared: someone said so. legacy: an old file, nobody recorded who
54 basis: 'source' | 'declared' | 'legacy'
55 // a few words on why, shown dim
56 why?: string
57 // when it was done or canceled, for how long it stays shown
58 endedAt?: string
59}
60
61// what one conversation's transcript says right now
62export type Talk = {
63 // the transcript's file name without .jsonl (the engine's session id; a reopened conversation gets a new one)
64 id: string
65 title: string
66 // every title it had (a conversation can be renamed)
67 titles: string[]
68 project: string
69 cwd: string
70 // the last wrap-up recap
71 recap?: string
72 // the last wrap-up question, while nothing was typed after it
73 ask?: string
74 // the tool use that asked it
75 askId?: string
76 prs: string[]
77 mtimeMs: number
78 // the file could not be read: nothing in it is known
79 isUnread?: boolean
80}
81
82// one of the app's conversations, from its session list
83export type Session = {
84 // local_<uuid>, the app's id
85 id: string
86 title: string
87 cwd: string
88 isArchived: boolean
89 isPinned: boolean
90 lastActivityMs: number
91 prNumber?: number
92 prState?: string
93}
94
95// a worktree's git state: changed files and commits on no remote
96export type WorktreeState = { state: 'read'; changed: number; unpushed: number } | { state: 'unknown' }
97
98// clear: no open thing was found (not a promise it is finished). open: something is (reasons say what).
99// unknown: its last turn couldn't be read
100export type Verdict = { state: 'clear' | 'open' | 'unknown'; reasons: string[] }
101
102export type Convo = { session: Session; recap?: string; verdict: Verdict }
103
104export type View = {
105 // this conversation
106 here: { id: string; title: string; project: string; cwd: string; recap?: string }
107 talks: Talk[]
108 items: Item[]
109 // PR states for items whose source is a PR, by prKey
110 prStates: Record<string, PrState>
111 // every conversation the app has open, with what is still open in it; null when the app's list can't be read
112 convos: Convo[] | null
113 // the two folded rows at the bottom
114 isShowingDone: boolean
115 isShowingConvos: boolean
116 // ms since the epoch of the last refresh; null before the first
117 at: number | null
118 // why a source could not be read (gh missing, ...)
119 notes: string[]
120}
121
122declare module 'claude-code' {
123 interface PluginState {
124 'puppy-todo': { view: View }
125 }
126}
127