A live todo pane for the session: the agent keeps it current through a tool, you read and tick it without asking.

A Claude Code mod that puts the session's todo list in a side pane. The agent keeps the list current through a tool it is told to use, so you can see what is done, what is in progress and what is left without asking.
about under the title saying what the list is for (cut to 80 characters with an ellipsis, so it stays a line). Each row shows a status glyph, the item text and an optional note; items are numbered straight through the pane. A small mark after the text says who does the item: an orange ✱ for the agent, a yellow ♟ for you (items you add with /todo add, and steps the agent marks as yours because only you can take them: approve, decide, run something it may not). Click the glyph to cycle an item pending → in progress → done. Every in-progress item is repeated in a now: block, and a legend closes the pane.[>] TODO: <current item> (+1), or [ ] TODO: <first pending item> when nothing is in progress, the done/total count and an "All items" button; the item text and the button both open the pane. When the item in progress finishes, the band shows it ticked for a moment before moving on to the next. The "Band" setting has three modes: off, closed (the default: only while the pane is closed) and always (also while the pane is open). Pick one in the ⚙ settings row, with /todo band off|closed|always (/todo band alone cycles through them), or in the /config row.done/total count: done, in progress and blocked take their share in colour, the rest is dim, and any status with at least one item keeps at least one cell./todo command for your own edits: /todo (show), /todo add [@list] <text>, /todo start <n>, /todo done <n>, /todo remove <n>, /todo about <list> [text], /todo clear [list], /todo drop <list>, /todo focus <list>, /todo theme <name>, /todo compact [on|off], /todo band [off|closed|always]./todo compact on|off, or the /config row drops it.classic (the terminal's green, yellow, red), cyberpunk (neon green and purple), and the standard palettes dracula, nord, solarized, gruvbox, monokai, catppuccin, tokyo-night, one-dark. Pick one with the ⚙ button at the top of the pane, /todo theme <name>, or in /config under the plugin's "Theme" row; the choice is stored in your user settings.todo tool the agent calls (mcp__session-todo__todo): write a named list, add, update, remove, read (every list), clear, drop, focus, describe (the about line). A system-prompt section tells the agent to write the plan before multi-step work, keep the step it works on in progress (one at a time preferred, several allowed), mark items done as it goes, and keep follow-ups such as "open the PR" or "report to Jira" in a second list so the main plan stays focused.Several lists, two flat levels (list, item), item ids unique across lists. The board lives in the session's plugin state (survives hot reloads and context compaction) and is mirrored to the plugin store under the session id, so claude --resume brings it back. /clear empties it.
From a terminal session of Claude Code:
/plugin install session-todo --marketplace jepperip/claude-session-todo
Answer y to add the marketplace, then pick the user scope. A mod installed at the user scope also loads in the sessions the Claude desktop app starts.
For a terminal session: claude --plugin-dir <path-to-this-folder>.
For the desktop app, which takes no flags, name the folder in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\code\\claude-session-todo" } }
An interactive session watches the folder and reloads the mod when a file changes.
.claude-plugin/plugin.json manifest
.claude-plugin/marketplace.json makes this repository installable as a marketplace
hooks/hooks.json names the hooks module
hooks/register.tsx the mod: tool, command, pane, status line, prompt section
types/index.d.ts the state contract (what the pane draws from)
claude plugin validate . checks the manifest and module. The engine writes this build's API declarations to .claude-plugin/types/ on every load (gitignored), so tsc -p . type-checks the module after the mod has loaded once.
Early access API: Claude Code's function-hook plugin API moves between releases, so a newer engine may need small changes here. Written against Claude Code 2.1.293.
Works in a terminal and in the Claude desktop app. In Claude Code for VS Code (extension 2.1.292 at the time of writing) the mod loads and the todo tool, the prompt section and /todo work, but the extension's webview does not draw plugin panes or bands yet, so there is no pane, band or settings row there; /todo prints the board as text instead. Nothing here has to change for that: the engine already treats VS Code as a drawing surface, and the pane appears once an extension release draws it.
hooks/register.tsx 904 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TodoBoard, TodoItem, TodoList, TodoStatus } from '../types'
5
6const PANE = 'session-todo'
7const TOOL = 'mcp__session-todo__todo'
8const COMMAND = 'todo'
9const DEFAULT_LIST = 'main'
10const EMPTY: TodoBoard = { lists: [], nextId: 1 }
11
12const board = atom({ plugin: 'session-todo', key: 'board' } as const, EMPTY)
13const settingsOpen = atom({ plugin: 'session-todo', key: 'settingsOpen' } as const, false)
14const justDone = atom({ plugin: 'session-todo', key: 'justDone' } as const, null)
15const paneAutoOpened = atom({ plugin: 'session-todo', key: 'paneAutoOpened' } as const, false)
16const THEME_FIELD = 'theme'
17
18const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'done', 'blocked']
19
20// The empty box holds a figure space (U+2007), as wide as a digit, so it lines up with [x] where a
21// proportional font would draw a plain space narrower.
22const GLYPH: Record<TodoStatus, string> = {
23 pending: '[ ]',
24 in_progress: '[>]',
25 done: '[x]',
26 blocked: '[!]',
27}
28
29/** Follows the text of an item the agent does itself, so the person's own steps stand out by its absence. */
30const AGENT_MARK = '✱'
31/** Follows the text of an item the person does. */
32const USER_MARK = '♟'
33/** The marks' own colours, muted so they read as badges rather than status: orange for the agent, yellow for the person. */
34const AGENT_MARK_COLOR = '#c97b4a'
35const USER_MARK_COLOR = '#d4b24c'
36
37const LEGEND = `${GLYPH.pending} pending ${GLYPH.in_progress} in progress ${GLYPH.done} done ${GLYPH.blocked} blocked ${AGENT_MARK} Claude's ${USER_MARK} yours · click a glyph to cycle`
38
39/** The speech bubble and the figure under it, drawn at the bottom of the pane on Fridays. */
40const FRIDAY_BUBBLE = ['╭──────────────────────╮', '│ have a nice weekend! │', '╰─┬────────────────────╯']
41const FRIDAY_GUY = [' ╯', ' \\o/🍺', ' |', ' / \\']
42
43const FRIDAY = 5
44
45/** Whether the given moment falls on a Friday where this module runs. */
46function isFriday(epochMs: number): boolean {
47 return new Date(epochMs).getDay() === FRIDAY
48}
49
50/** What a theme paints: the colour of each status, the accents, and the bar's two glyphs. */
51type Theme = {
52 /** Per status: the bar segment and, for in progress and blocked, the item text. Pending and done text keep the surface's colours. */
53 status: Record<TodoStatus, string | undefined>
54 /** The active list's title; undefined keeps the surface's text colour. */
55 title: string | undefined
56 /** The percentage and the `now:` lines. */
57 accent: string | undefined
58 barFill: string
59 barRest: string
60 /** How many glyphs fit where one plain cell would: under 1 for glyphs the desktop draws wider than a cell. */
61 barScale?: number
62}
63
64const THEMES: Record<string, Theme> = {
65 classic: {
66 status: { done: 'green', in_progress: 'yellow', blocked: 'red', pending: undefined },
67 title: undefined,
68 accent: undefined,
69 barFill: '█',
70 barRest: '░',
71 },
72 cyberpunk: {
73 status: { done: '#39ff14', in_progress: '#c77dff', blocked: '#ff2975', pending: '#5a3d7a' },
74 title: '#c77dff',
75 accent: '#39ff14',
76 barFill: '▰',
77 barRest: '▱',
78 barScale: 0.55,
79 },
80 dracula: {
81 status: { done: '#50fa7b', in_progress: '#f1fa8c', blocked: '#ff5555', pending: '#6272a4' },
82 title: '#bd93f9',
83 accent: '#ff79c6',
84 barFill: '█',
85 barRest: '░',
86 },
87 nord: {
88 status: { done: '#a3be8c', in_progress: '#ebcb8b', blocked: '#bf616a', pending: '#4c566a' },
89 title: '#88c0d0',
90 accent: '#81a1c1',
91 barFill: '█',
92 barRest: '░',
93 },
94 solarized: {
95 status: { done: '#859900', in_progress: '#b58900', blocked: '#dc322f', pending: '#586e75' },
96 title: '#268bd2',
97 accent: '#2aa198',
98 barFill: '█',
99 barRest: '░',
100 },
101 gruvbox: {
102 status: { done: '#b8bb26', in_progress: '#fabd2f', blocked: '#fb4934', pending: '#665c54' },
103 title: '#fe8019',
104 accent: '#83a598',
105 barFill: '█',
106 barRest: '░',
107 },
108 monokai: {
109 status: { done: '#a6e22e', in_progress: '#e6db74', blocked: '#f92672', pending: '#75715e' },
110 title: '#ae81ff',
111 accent: '#66d9ef',
112 barFill: '█',
113 barRest: '░',
114 },
115 catppuccin: {
116 status: { done: '#a6e3a1', in_progress: '#f9e2af', blocked: '#f38ba8', pending: '#585b70' },
117 title: '#cba6f7',
118 accent: '#89b4fa',
119 barFill: '█',
120 barRest: '░',
121 },
122 'tokyo-night': {
123 status: { done: '#9ece6a', in_progress: '#e0af68', blocked: '#f7768e', pending: '#565f89' },
124 title: '#bb9af7',
125 accent: '#7aa2f7',
126 barFill: '█',
127 barRest: '░',
128 },
129 'one-dark': {
130 status: { done: '#98c379', in_progress: '#e5c07b', blocked: '#e06c75', pending: '#5c6370' },
131 title: '#c678dd',
132 accent: '#61afef',
133 barFill: '█',
134 barRest: '░',
135 },
136}
137const DEFAULT_THEME = 'classic'
138const THEME_NAMES = Object.keys(THEMES)
139
140function themeName(name: unknown): string {
141 return typeof name === 'string' && THEMES[name] ? name : DEFAULT_THEME
142}
143
144/** The bar's segments left to right: what is finished, what is moving, what is stuck, what is left. */
145const BAR_ORDER: readonly TodoStatus[] = ['done', 'in_progress', 'blocked', 'pending']
146
147type BarSegment = { status: TodoStatus; cells: number }
148
149/** Splits `width` cells between the statuses in proportion, rounding on the running total so the cells always add up. */
150function barSegments(items: readonly TodoItem[], width: number): BarSegment[] {
151 const total = items.length
152 if (total === 0) return [{ status: 'pending', cells: width }]
153 const counts = BAR_ORDER.map(status => ({ status, count: items.filter(item => item.status === status).length }))
154 const present = counts.filter(one => one.count > 0)
155 // Every status that has an item gets at least one cell, so a lone blocked item still shows on a
156 // short bar; the rest of the width is shared in proportion, the largest remainders rounding up.
157 // With more statuses than cells (a bar under four cells) the floor cannot hold and the shares
158 // fall back to plain proportion.
159 const floor = present.length <= width ? 1 : 0
160 const spare = width - floor * present.length
161 const shares = present.map(one => {
162 const exact = (one.count / total) * spare
163 return { status: one.status, cells: floor + Math.floor(exact), remainder: exact - Math.floor(exact) }
164 })
165 let left = width - shares.reduce((sum, one) => sum + one.cells, 0)
166 for (const share of [...shares].sort((a, b) => b.remainder - a.remainder)) {
167 if (left === 0) break
168 share.cells += 1
169 left -= 1
170 }
171 return shares.map(({ status, cells }) => ({ status, cells }))
172}
173
174function percentDone(items: readonly TodoItem[]): number {
175 if (items.length === 0) return 0
176 return Math.round((items.filter(item => item.status === 'done').length / items.length) * 100)
177}
178
179/** The longest `about` line a list may carry: one short sentence, never a paragraph. */
180const ABOUT_MAX = 80
181
182type TodoToolInput = {
183 action: 'write' | 'add' | 'update' | 'remove' | 'read' | 'clear' | 'drop' | 'focus' | 'describe'
184 list?: string
185 title?: string
186 about?: string
187 items?: Array<{ id?: string; text: string; status?: TodoStatus; note?: string; owner?: TodoOwner }>
188 id?: string
189 text?: string
190 status?: TodoStatus
191 note?: string
192 owner?: TodoOwner
193}
194
195type TodoOwner = NonNullable<TodoItem['owner']>
196
197const TOOL_DESCRIPTION = [
198 'The todo lists the user watches in the Todo side pane. They are their view of the plan, so keep them current without being asked.',
199 'Lists are named (default "main"); items are addressed by id, unique across lists.',
200 '- write: replace one list with its steps (list, title, items, optional about). Do this before work with three or more steps, or that spans more than one turn, starts. The first list written becomes the active one, drawn first; focus switches it.',
201 `- about (optional, one line of at most ${ABOUT_MAX} characters; a longer one is cut with an ellipsis): one short sentence under the title saying what the list is for or what done looks like, so the user still knows a day later. Not a summary of the items. Set it with write and leave it; describe changes it later.`,
202 '- add / update / remove: one item. Mark the step you begin in_progress (prefer one at a time, several when work really runs in parallel), done the moment it finishes, blocked with a note when it waits on the user.',
203 '- owner: who does the item, "agent" (you; the default) or "user". Mark a step "user" when only the person can take it: approve, decide, click, run something you may not. The pane marks your own items so theirs stand out.',
204 '- read: every list as the pane shows it, with ids. Call it after a context compaction.',
205 '- clear: empty one list (list) or all. drop: remove a list. focus: make a list the active one. describe: set or clear a list\'s about (list, about).',
206 'Use a second list, e.g. "followup", for things to do after the main work (open the PR, report a finding to Jira), so the main list stays focused.',
207 'Keep item text short and imperative, one line each. Before reporting a task finished, leave its list true: every item done, items that fell away removed.',
208].join('\n')
209
210const TOOL_SCHEMA = {
211 type: 'object',
212 properties: {
213 action: { type: 'string', enum: ['write', 'add', 'update', 'remove', 'read', 'clear', 'drop', 'focus', 'describe'] },
214 list: {
215 type: 'string',
216 description: 'The list name (write, add, clear, drop, focus, describe). Defaults to the active list, or "main".',
217 },
218 title: { type: 'string', description: 'A heading for the list (write only); defaults to the name.' },
219 about: {
220 type: 'string',
221 description: `One short line under the title saying what the list is for (write, describe). Optional; at most ${ABOUT_MAX} characters, a longer one is cut. Empty clears it.`,
222 },
223 items: {
224 type: 'array',
225 description: 'The whole list (write only). Reuse an existing id to keep an item in place.',
226 items: {
227 type: 'object',
228 properties: {
229 id: { type: 'string' },
230 text: { type: 'string' },
231 status: { type: 'string', enum: STATUSES },
232 note: { type: 'string' },
233 owner: { type: 'string', enum: ['agent', 'user'] },
234 },
235 required: ['text'],
236 },
237 },
238 id: { type: 'string', description: 'The item (update, remove).' },
239 text: { type: 'string', description: 'The item text (add, update).' },
240 status: { type: 'string', enum: STATUSES, description: 'The new status (add, update).' },
241 note: { type: 'string', description: 'A short note on the item, e.g. why it is blocked (add, update). Empty removes it.' },
242 owner: { type: 'string', enum: ['agent', 'user'], description: 'Who does the item (add, update): agent (default) or user.' },
243 },
244 required: ['action'],
245}
246
247const PROMPT_SECTION = {
248 id: 'session-todo:instructions',
249 scope: 'session',
250 text: [
251 '# Session todo pane',
252 `The user sees live todo lists driven by the \`${TOOL}\` tool. Use it instead of narrating the plan: write the steps before any work with three or more steps or that will span more than one turn (a single edit needs no list), mark the step you work on in_progress (prefer one at a time), mark it done the moment it finishes, and add or remove items as the work changes. Keep follow-ups (open the PR, report to Jira, update docs) in a separate list so the main plan stays focused. A list may carry an optional \`about\`: one short sentence on what it is for, set when the list is written and then left alone. Before reporting a task finished, leave its list true: every item done, and items that fell away removed. The pane is the user's way of seeing what is left without asking, so a stale list is worse than none. After a context compaction, call read to recover the lists.`,
253 ].join('\n'),
254} as const
255
256function isValidName(name: string): boolean {
257 return /^[a-z0-9][a-z0-9_-]{0,31}$/i.test(name)
258}
259
260function listNamed(todo: TodoBoard, name: string): TodoList | undefined {
261 return todo.lists.find(list => list.name.toLowerCase() === name.toLowerCase())
262}
263
264function targetList(todo: TodoBoard, name: string | undefined): TodoList | undefined {
265 if (name) return listNamed(todo, name)
266 if (todo.active) return listNamed(todo, todo.active)
267 return todo.lists[0]
268}
269
270function allItems(todo: TodoBoard): TodoItem[] {
271 return todo.lists.flatMap(list => list.items)
272}
273
274function itemById(todo: TodoBoard, id: string | undefined): TodoItem | undefined {
275 return id ? allItems(todo).find(item => item.id === id) : undefined
276}
277
278/** Lists in the order the pane draws them: the active one first, then creation order. */
279function drawn(todo: TodoBoard): TodoList[] {
280 const active = todo.active ? listNamed(todo, todo.active) : undefined
281 if (!active) return todo.lists
282 return [active, ...todo.lists.filter(list => list !== active)]
283}
284
285function nextStatus(status: TodoStatus): TodoStatus {
286 if (status === 'pending') return 'in_progress'
287 if (status === 'in_progress') return 'done'
288 return 'pending'
289}
290
291function mapItem(todo: TodoBoard, id: string, change: (item: TodoItem) => TodoItem): TodoBoard {
292 return {
293 ...todo,
294 lists: todo.lists.map(list => ({
295 ...list,
296 items: list.items.map(item => (item.id === id ? change(item) : item)),
297 })),
298 }
299}
300
301function render(todo: TodoBoard): string {
302 if (todo.lists.length === 0) return 'Todo: no lists'
303 const lines: string[] = []
304 let n = 0
305 for (const list of drawn(todo)) {
306 const done = list.items.filter(item => item.status === 'done').length
307 const marker = list.name === todo.active ? ' (active)' : ''
308 lines.push(`${list.title} [${list.name}]${marker} ${done}/${list.items.length} done`)
309 if (list.about) lines.push(` ${list.about}`)
310 if (list.items.length === 0) lines.push(' (empty)')
311 for (const item of list.items) {
312 n++
313 const glyph = GLYPH[item.status].replace(' ', ' ')
314 const owner = item.owner === 'user' ? ' [yours]' : ''
315 lines.push(` ${glyph} ${n}. ${item.text} (${item.id})${owner}${item.note ? ` — ${item.note}` : ''}`)
316 }
317 }
318 return lines.join('\n')
319}
320
321function storeKey(sessionId: string): string {
322 return `board:${sessionId}`
323}
324
325async function isPaneShown($: EngineInterface): Promise<boolean> {
326 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
327}
328
329/** The band above the prompt depends on whether the pane is on screen, which no state read tracks. */
330function redraw($: EngineInterface): void {
331 $.ui.invalidate('ui.render')
332}
333
334/** How long the band shows an item that just finished before moving on to the next one. */
335const JUST_DONE_MS = 2000
336
337async function commit($: EngineInterface, change: (todo: TodoBoard) => TodoBoard): Promise<TodoBoard> {
338 const before = allItems(await read($, board))
339 const todo = await update($, board, change)
340 await $.store.set(storeKey(await $.session.id()), todo)
341
342 const finished = allItems(todo).find(item => {
343 const was = before.find(one => one.id === item.id)
344 return was?.status === 'in_progress' && item.status === 'done'
345 })
346 if (finished) {
347 await update($, justDone, () => ({ id: finished.id, text: finished.text }))
348 $.clock.after(JUST_DONE_MS, () => void update($, justDone, held => (held?.id === finished.id ? null : held)))
349 }
350 return todo
351}
352
353/**
354 * Writes one of the plugin's options through its own /config row. The row's key carries the
355 * plugin's loaded name, which differs by how it was loaded (`session-todo`, `session-todo@inline`,
356 * `session-todo@<marketplace>`), so the row is found by its field rather than spelled out.
357 * Resolves to the reason when the write did not happen.
358 */
359async function setOption($: EngineInterface, field: string, value: string | boolean): Promise<string | undefined> {
360 const rows = await $.config.list()
361 const row = rows.find(one => one.key === `session-todo.${field}` || /^session-todo@[^.]+\.(.+)$/.exec(one.key)?.[1] === field)
362 if (!row) return `no "${field}" row in /config for this plugin (rows: ${rows.length})`
363 try {
364 const set = await $.config.set({ key: row.key, value })
365 return set.deny
366 } catch (error) {
367 return error instanceof Error ? error.message : String(error)
368 }
369}
370
371function setTheme($: EngineInterface, name: string): Promise<string | undefined> {
372 return setOption($, THEME_FIELD, name)
373}
374
375/** Opens the pane because the person asked: a command, a button on the band. */
376async function openPane($: EngineInterface): Promise<void> {
377 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
378 if (!isUp) await $.ui.open({ id: PANE, title: 'Todo' })
379 await update($, paneAutoOpened, () => true)
380 redraw($)
381}
382
383/**
384 * Opens the pane on the agent's behalf, once per session at most: the first write brings it up,
385 * and after that a pane the person closed stays closed, with the band standing in for it.
386 */
387async function autoOpenPane($: EngineInterface): Promise<void> {
388 if (await read($, paneAutoOpened)) return
389 await openPane($)
390}
391
392function applyTool(todo: TodoBoard, input: TodoToolInput): TodoBoard | string {
393 switch (input.action) {
394 case 'read':
395 return todo
396 case 'write': {
397 const name = input.list ?? todo.active ?? DEFAULT_LIST
398 if (!isValidName(name)) return `list names are letters, digits, _ and -: ${name}`
399 const existing = listNamed(todo, name)
400 const others = todo.lists.filter(list => list !== existing)
401 const taken = new Set(others.flatMap(list => list.items.map(item => item.id)))
402 let nextId = todo.nextId
403 const items: TodoItem[] = []
404 for (const given of input.items ?? []) {
405 if (!given.text) return 'every item needs a text'
406 let id = given.id
407 if (!id || taken.has(id)) {
408 do id = `t${nextId++}`
409 while (taken.has(id))
410 }
411 taken.add(id)
412 const item: TodoItem = { id, text: given.text, status: given.status ?? 'pending', owner: given.owner ?? 'agent' }
413 if (given.note) item.note = given.note
414 items.push(item)
415 }
416 const about = aboutOf(input.about, existing?.about)
417 const list: TodoList = { name: existing?.name ?? name, title: input.title ?? existing?.title ?? name, items }
418 if (about) list.about = about
419 const lists = existing ? todo.lists.map(one => (one === existing ? list : one)) : [...todo.lists, list]
420 return { lists, active: todo.active ?? list.name, nextId }
421 }
422 case 'add': {
423 if (!input.text) return 'add needs a text'
424 let target = targetList(todo, input.list)
425 let lists = todo.lists
426 if (!target) {
427 const name = input.list ?? DEFAULT_LIST
428 if (!isValidName(name)) return `list names are letters, digits, _ and -: ${name}`
429 target = { name, title: name, items: [] }
430 lists = [...lists, target]
431 }
432 const item: TodoItem = { id: `t${todo.nextId}`, text: input.text, status: input.status ?? 'pending', owner: input.owner ?? 'agent' }
433 if (input.note) item.note = input.note
434 const added = target
435 return {
436 ...todo,
437 lists: lists.map(list => (list === added ? { ...list, items: [...list.items, item] } : list)),
438 active: todo.active ?? added.name,
439 nextId: todo.nextId + 1,
440 }
441 }
442 case 'update': {
443 const target = itemById(todo, input.id)
444 if (!target) return `no item with id ${input.id ?? '(none)'}`
445 return mapItem(todo, target.id, item => {
446 const changed: TodoItem = { ...item }
447 if (input.text) changed.text = input.text
448 if (input.status) changed.status = input.status
449 if (input.owner) changed.owner = input.owner
450 if (input.note !== undefined) {
451 if (input.note) changed.note = input.note
452 else delete changed.note
453 }
454 return changed
455 })
456 }
457 case 'remove': {
458 const target = itemById(todo, input.id)
459 if (!target) return `no item with id ${input.id ?? '(none)'}`
460 return {
461 ...todo,
462 lists: todo.lists.map(list => ({ ...list, items: list.items.filter(item => item !== target) })),
463 }
464 }
465 case 'clear': {
466 if (!input.list) return { ...todo, lists: todo.lists.map(list => ({ ...list, items: [] })) }
467 const target = listNamed(todo, input.list)
468 if (!target) return `no list named ${input.list}`
469 return { ...todo, lists: todo.lists.map(list => (list === target ? { ...list, items: [] } : list)) }
470 }
471 case 'drop': {
472 const target = input.list ? listNamed(todo, input.list) : undefined
473 if (!target) return `no list named ${input.list ?? '(none)'}`
474 const lists = todo.lists.filter(list => list !== target)
475 return { ...todo, lists, active: todo.active === target.name ? lists[0]?.name : todo.active }
476 }
477 case 'focus': {
478 const target = input.list ? listNamed(todo, input.list) : undefined
479 if (!target) return `no list named ${input.list ?? '(none)'}`
480 return { ...todo, active: target.name }
481 }
482 case 'describe': {
483 const target = targetList(todo, input.list)
484 if (!target) return `no list named ${input.list ?? '(none)'}`
485 const about = aboutOf(input.about, undefined)
486 return {
487 ...todo,
488 lists: todo.lists.map(list => {
489 if (list !== target) return list
490 const changed: TodoList = { ...list }
491 if (about) changed.about = about
492 else delete changed.about
493 return changed
494 }),
495 }
496 }
497 default:
498 return `unknown action ${String((input as { action?: unknown }).action)}`
499 }
500}
501
502/** The `about` a call leaves on a list: the given one trimmed, or the current one when none is given. */
503function aboutOf(given: string | undefined, current: string | undefined): string | undefined {
504 if (given === undefined) return current
505 return given.trim().replace(/\s+/g, ' ') || undefined
506}
507
508/**
509 * Cuts an `about` past the cap at a word boundary, with an ellipsis, and says so: the field is
510 * optional, so a long one must never fail the call that carries it.
511 */
512function fitAbout(about: string | undefined): { about: string | undefined; note?: string } {
513 if (about === undefined) return { about }
514 const whole = about.trim().replace(/\s+/g, ' ')
515 if (whole.length <= ABOUT_MAX) return { about: whole }
516 const room = whole.slice(0, ABOUT_MAX - 1)
517 const atWord = room.lastIndexOf(' ')
518 const cut = `${(atWord > ABOUT_MAX / 2 ? room.slice(0, atWord) : room).trimEnd()}…`
519 return { about: cut, note: `about was ${whole.length} characters and is cut to ${ABOUT_MAX}: "${cut}"` }
520}
521
522/** An item by its number in the pane (counted across lists in drawn order) or by id. */
523function byNumber(todo: TodoBoard, arg: string): TodoItem | undefined {
524 const items = drawn(todo).flatMap(list => list.items)
525 const n = Number.parseInt(arg, 10)
526 if (Number.isFinite(n) && n >= 1 && n <= items.length) return items[n - 1]
527 return items.find(item => item.id === arg)
528}
529
530/** When the band above the prompt shows: never, only while the pane is closed, or always. */
531type BandMode = 'off' | 'closed' | 'always'
532const BAND_MODES: readonly BandMode[] = ['off', 'closed', 'always']
533const BAND_LABELS: Record<BandMode, string> = { off: 'off', closed: 'when the pane is closed', always: 'always' }
534
535/** Reads the band option; a boolean left over from when it was an on/off switch maps onto the modes. */
536function bandMode(value: unknown): BandMode {
537 if (value === false) return 'off'
538 if (typeof value === 'string' && (BAND_MODES as readonly string[]).includes(value)) return value as BandMode
539 return 'closed'
540}
541
542const USAGE = `Usage: /todo [add [@list] <text> | start <n> | done <n> | remove <n> | about <list> [text] | clear [list] | drop <list> | focus <list> | theme <${THEME_NAMES.join('|')}> | compact [on|off] | band [${BAND_MODES.join('|')}]]`
543
544export const register: Register = (on, options) => {
545 const themeKey = themeName(options.theme)
546 const isCompact = options.compact === true
547 const band = bandMode(options.band)
548 const theme = THEMES[themeKey] as Theme
549
550 on('session.start', async ($, e, next) => {
551 await $.tool.register({
552 name: 'todo',
553 description: TOOL_DESCRIPTION,
554 inputSchema: TOOL_SCHEMA,
555 isDeferred: false,
556 })
557 await $.command.register({
558 name: COMMAND,
559 description: 'The session todo pane: /todo, add [@list] <text>, start <n>, done <n>, remove <n>, about <list> [text], clear [list], drop <list>, focus <list>, theme <name>, compact [on|off], band [off|closed|always]',
560 argumentHint: '[add [@list] <text> | start <n> | done <n> | remove <n> | about <list> [text] | clear [list] | drop <list> | focus <list> | theme <name> | compact [on|off] | band [off|closed|always]]',
561 })
562
563 const saved = (await $.store.get(storeKey(await $.session.id()))) as TodoBoard | undefined
564 if (saved && Array.isArray(saved.lists)) await update($, board, () => saved)
565
566 // The pane opens on its own only when there is something to show: a restored board with
567 // items. Otherwise the first write (the agent's or /todo add) opens it.
568 if (saved && Array.isArray(saved.lists) && allItems(saved).length > 0) void autoOpenPane($)
569 return next(e)
570 })
571
572 on('session.end', async ($, e, next) => {
573 if (e.reason === 'clear') await update($, board, () => EMPTY)
574 return next(e)
575 })
576
577 on('ui.close', { id: PANE }, async ($, e, next) => {
578 const closed = await next(e)
579 if (e.origin.kind !== 'unload') redraw($)
580 return closed
581 })
582
583 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
584 if (band === 'off' || e.props.hasSurvey) return next(e)
585 const todo = await read($, board)
586 const items = allItems(todo)
587 if (items.length === 0 || (band === 'closed' && (await isPaneShown($)))) return next(e)
588
589 const { Box, Text, Button } = $.ui.resolve(e)
590 const done = items.filter(item => item.status === 'done').length
591 const current = items.filter(item => item.status === 'in_progress')
592 // With nothing in progress the band names the next pending item, so a freshly added item
593 // shows up at once; with nothing pending either, it says so.
594 const upcoming = current.length === 0 ? items.find(item => item.status === 'pending') : undefined
595 const now =
596 current.length > 0
597 ? `TODO: ${current[0]?.text}${current.length > 1 ? ` (+${current.length - 1})` : ''}`
598 : upcoming
599 ? `TODO: ${upcoming.text}`
600 : 'TODO: nothing left'
601
602 const isIdle = current.length === 0 && upcoming === undefined
603 // An item that just went from in progress to done holds the band for a moment, ticked, before
604 // the next item takes its place.
605 const finished = await read($, justDone)
606 const glyph = finished ? GLYPH.done : current.length > 0 ? GLYPH.in_progress : GLYPH.pending
607 const glyphColor = finished ? theme.status.done : current.length > 0 ? theme.status.in_progress : undefined
608 const label = finished ? `DONE: ${finished.text}` : now
609 return (
610 <Box flexDirection="row" justifyContent="space-between" gap={2}>
611 <Box flexDirection="row" gap={1} minWidth={0}>
612 <Text bold color={glyphColor} dimColor={isIdle && !finished}>
613 {glyph}
614 </Text>
615 {isIdle && !finished ? (
616 <Text wrap="truncate-end" dimColor>
617 {label}
618 </Text>
619 ) : (
620 <Button plain key="open-current" label={label} onPress={() => void openPane($)} />
621 )}
622 </Box>
623 <Box flexDirection="row" gap={1} flexShrink={0}>
624 <Text color={theme.accent} dimColor={!theme.accent}>{`${done}/${items.length}`}</Text>
625 <Button key="open-pane" label="All items" onPress={() => void openPane($)} />
626 </Box>
627 </Box>
628 )
629 })
630
631 on('prompt.compose', async ($, e, next) => {
632 const composed = await next(e)
633 return { sections: [...composed.sections, PROMPT_SECTION] }
634 })
635
636 on('tool.call', { tool: TOOL }, async ($, e) => {
637 const input = e as unknown as TodoToolInput
638 const before = await read($, board)
639 const fitted = fitAbout(input.about)
640 const outcome = applyTool(before, { ...input, about: fitted.about })
641 if (typeof outcome === 'string') return { deny: `todo: ${outcome}` }
642 const after = outcome === before ? before : await commit($, () => outcome)
643 if (input.action !== 'read') await autoOpenPane($)
644 return { result: fitted.note ? `${render(after)}\n(${fitted.note})` : render(after) }
645 })
646
647 on('command.run', { command: COMMAND }, async ($, e) => {
648 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
649 const todo = await read($, board)
650
651 if (verb === '') {
652 await openPane($)
653 return { text: render(todo) }
654 }
655 if (verb === 'add') {
656 const list = rest[0]?.startsWith('@') ? rest.shift()?.slice(1) : undefined
657 const text = rest.join(' ')
658 if (!text) return { text: 'Usage: /todo add [@list] <text>' }
659 const outcome = applyTool(todo, { action: 'add', list, text, owner: 'user' })
660 if (typeof outcome === 'string') return { text: outcome }
661 const after = await commit($, () => outcome)
662 await openPane($)
663 return { text: render(after) }
664 }
665 if (verb === 'clear' || verb === 'drop' || verb === 'focus') {
666 const outcome = applyTool(todo, { action: verb, list: rest[0] })
667 if (typeof outcome === 'string') return { text: outcome }
668 return { text: render(await commit($, () => outcome)) }
669 }
670 if (verb === 'about') {
671 const [list, ...words] = rest
672 if (!list) return { text: 'Usage: /todo about <list> [text] (no text clears it)' }
673 const fitted = fitAbout(words.join(' '))
674 const outcome = applyTool(todo, { action: 'describe', list, about: fitted.about })
675 if (typeof outcome === 'string') return { text: outcome }
676 const text = render(await commit($, () => outcome))
677 return { text: fitted.note ? `${text}\n(${fitted.note})` : text }
678 }
679 if (verb === 'start' || verb === 'done' || verb === 'remove') {
680 const arg = rest.join(' ')
681 const target = byNumber(todo, arg)
682 if (!target) return { text: `No item ${arg || '(none)'}. Items are numbered as the pane shows them.` }
683 const outcome = applyTool(
684 todo,
685 verb === 'remove'
686 ? { action: 'remove', id: target.id }
687 : { action: 'update', id: target.id, status: verb === 'start' ? 'in_progress' : 'done' },
688 )
689 if (typeof outcome === 'string') return { text: outcome }
690 return { text: render(await commit($, () => outcome)) }
691 }
692 if (verb === 'compact') {
693 const wanted = rest[0] === 'on' ? true : rest[0] === 'off' ? false : !isCompact
694 const failed = await setOption($, 'compact', wanted)
695 return { text: failed ? `Could not set compact: ${failed}` : `Compact rows ${wanted ? 'on' : 'off'}.` }
696 }
697 if (verb === 'band') {
698 // Without an argument the modes cycle; `on` still means what it did when the band was a switch.
699 const given = rest[0] === 'on' ? 'closed' : rest[0]
700 if (given !== undefined && !(BAND_MODES as readonly string[]).includes(given)) return { text: `Band modes: ${BAND_MODES.join(', ')}` }
701 const wanted = given === undefined ? (BAND_MODES[(BAND_MODES.indexOf(band) + 1) % BAND_MODES.length] ?? 'closed') : bandMode(given)
702 const failed = await setOption($, 'band', wanted)
703 return { text: failed ? `Could not set band: ${failed}` : `Band above the prompt: ${BAND_LABELS[wanted]}.` }
704 }
705 if (verb === 'theme') {
706 const name = rest[0]
707 if (!name || !THEMES[name]) return { text: `Themes: ${THEME_NAMES.join(', ')}` }
708 const failed = await setTheme($, name)
709 return { text: failed ? `Could not set the theme: ${failed}` : `Theme set to ${name}.` }
710 }
711 return { text: USAGE }
712 })
713
714 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
715 const { Box, Text, Button } = $.ui.resolve(e)
716 const todo = await read($, board)
717 const lists = drawn(todo)
718 const current = lists.flatMap(list => list.items).filter(item => item.status === 'in_progress')
719
720 const toggle = (item: TodoItem) => () =>
721 void commit($, todo => mapItem(todo, item.id, one => ({ ...one, status: nextStatus(one.status) })))
722
723 // The bar sits in the title row, so it stays short: a sixth of the pane, between 8 and 18 cells.
724 const barWidth = Math.max(4, Math.round(Math.min(18, Math.max(8, Math.round(e.props.bodyColumns / 6))) * (theme.barScale ?? 1)))
725 const isSettingsOpen = await read($, settingsOpen)
726 const friday = isFriday(await $.clock.now())
727
728 const settings =
729 !isSettingsOpen || e.surface === 'mobile' ? null : (
730 <Box flexDirection="row" gap={1} marginBottom={1}>
731 <Text dimColor>Theme</Text>
732 {(() => {
733 const { Select } = $.ui.resolve(e)
734 return (
735 <Select
736 key="theme"
737 value={themeKey}
738 options={THEME_NAMES.map(name => ({ value: name, label: name }))}
739 onSelect={value =>
740 void setTheme($, value).then(failed => {
741 if (failed) $.ui.toast(`Could not set the theme: ${failed}`)
742 })
743 }
744 />
745 )
746 })()}
747 <Text dimColor>Band</Text>
748 {(() => {
749 const { Select } = $.ui.resolve(e)
750 return (
751 <Select
752 key="band"
753 value={band}
754 options={BAND_MODES.map(mode => ({ value: mode, label: BAND_LABELS[mode] }))}
755 onSelect={value =>
756 void setOption($, 'band', value).then(failed => {
757 if (failed) $.ui.toast(`Could not set band: ${failed}`)
758 })
759 }
760 />
761 )
762 })()}
763 <Button
764 plain
765 key="compact"
766 label={`${isCompact ? GLYPH.done : GLYPH.pending} compact`}
767 dimColor={!isCompact}
768 onPress={() =>
769 void setOption($, 'compact', !isCompact).then(failed => {
770 if (failed) $.ui.toast(`Could not set compact: ${failed}`)
771 })
772 }
773 />
774 </Box>
775 )
776
777 let n = 0
778 return (
779 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows}>
780 <Box flexDirection="row" justifyContent="flex-end">
781 <Button
782 plain
783 key="settings"
784 label="⚙"
785 dimColor={!isSettingsOpen}
786 onPress={() => void update($, settingsOpen, open => !open)}
787 />
788 </Box>
789 {settings}
790 {lists.length === 0 && (
791 <Text dimColor wrap="wrap">
792 Nothing planned yet. The agent fills this in as work is planned; /todo add adds your own.
793 </Text>
794 )}
795 {lists.map((list, index) => {
796 const done = list.items.filter(item => item.status === 'done').length
797 return (
798 <Box key={`list:${list.name}`} flexDirection="column" marginTop={index === 0 ? 0 : 1}>
799 <Box flexDirection="row" justifyContent="space-between" gap={2}>
800 <Text bold color={theme.title} wrap="wrap">
801 {list.title}
802 </Text>
803 <Box flexDirection="row" gap={1} flexShrink={0}>
804 <Box flexDirection="row">
805 {barSegments(list.items, barWidth)
806 .filter(segment => segment.cells > 0)
807 .map(segment => (
808 <Text
809 key={`bar:${list.name}:${segment.status}`}
810 color={theme.status[segment.status]}
811 dimColor={segment.status === 'pending' && !theme.status.pending}
812 >
813 {(segment.status === 'pending' ? theme.barRest : theme.barFill).repeat(segment.cells)}
814 </Text>
815 ))}
816 </Box>
817 <Text
818 bold={done === list.items.length && done > 0}
819 dimColor={done !== list.items.length && !theme.accent}
820 color={theme.accent}
821 >
822 {list.items.length === 0 ? 'empty' : `${done}/${list.items.length}`}
823 </Text>
824 </Box>
825 </Box>
826 <Box marginBottom={list.items.length === 0 ? 0 : 1}>
827 {list.about && (
828 <Text dimColor italic wrap="wrap">
829 {list.about}
830 </Text>
831 )}
832 </Box>
833 {list.items.map((item, index) => {
834 n++
835 return (
836 <Box key={`row:${item.id}`} flexDirection="column" marginTop={index === 0 || isCompact ? 0 : 1}>
837 <Box flexDirection="row" gap={1}>
838 <Button
839 plain
840 key={`toggle:${item.id}`}
841 label={GLYPH[item.status]}
842 dimColor={item.status === 'done'}
843 onPress={toggle(item)}
844 />
845 <Text
846 wrap="wrap"
847 bold={item.status === 'in_progress'}
848 dimColor={item.status === 'done'}
849 strikethrough={item.status === 'done'}
850 color={item.status === 'done' || item.status === 'pending' ? undefined : theme.status[item.status]}
851 >
852 {`${n}. ${item.text}`}
853 </Text>
854 <Text color={item.owner === 'user' ? USER_MARK_COLOR : AGENT_MARK_COLOR} dimColor={item.status === 'done'}>
855 {item.owner === 'user' ? USER_MARK : AGENT_MARK}
856 </Text>
857 </Box>
858 {item.note && (
859 <Box paddingLeft={4}>
860 <Text dimColor wrap="wrap">
861 {item.note}
862 </Text>
863 </Box>
864 )}
865 </Box>
866 )
867 })}
868 </Box>
869 )
870 })}
871 {current.length > 0 && (
872 <Box flexDirection="column" marginTop={1}>
873 {current.map(item => (
874 <Text key={`now:${item.id}`} dimColor={!theme.accent} color={theme.accent} wrap="wrap">
875 {`now: ${item.text}`}
876 </Text>
877 ))}
878 </Box>
879 )}
880 <Box flexGrow={1} />
881 {friday && (
882 <Box flexDirection="column" marginTop={1}>
883 {FRIDAY_BUBBLE.map((line, i) => (
884 <Text key={`bubble:${i}`} wrap="truncate-end">
885 {line}
886 </Text>
887 ))}
888 {FRIDAY_GUY.map((line, i) => (
889 <Text key={`guy:${i}`} color={theme.accent} wrap="truncate-end">
890 {line}
891 </Text>
892 ))}
893 </Box>
894 )}
895 <Box marginTop={1}>
896 <Text dimColor wrap="wrap">
897 {LEGEND}
898 </Text>
899 </Box>
900 </Box>
901 )
902 })
903}
904types/index.d.ts 45 lines1export type TodoStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
2
3export type TodoItem = {
4 /** Unique across every list, so an item is addressed by id alone. */
5 id: string
6 text: string
7 status: TodoStatus
8 /** A short note on why it is blocked, or what was decided. */
9 note?: string
10 /** Who does it: the agent (the default for items it writes) or the person (items they add, or steps only they can take). */
11 owner?: 'agent' | 'user'
12}
13
14export type TodoList = {
15 /** What the tool and /todo address the list by. */
16 name: string
17 title: string
18 /** One short line under the title saying what the list is for; optional, at most ABOUT_MAX characters. */
19 about?: string
20 items: TodoItem[]
21}
22
23export type TodoBoard = {
24 /** In creation order; the active list is drawn first whatever its position. */
25 lists: TodoList[]
26 /** The name of the list the agent is working in, drawn first with a bold title. */
27 active?: string
28 /** Feeds the next item id, so ids stay unique after removals. */
29 nextId: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'session-todo': {
35 board: TodoBoard
36 /** Whether the pane's settings row (the theme selector) is unfolded. */
37 settingsOpen: boolean
38 /** The item that just went from in progress to done, shown on the band for a moment; null otherwise. */
39 justDone: { id: string; text: string } | null
40 /** Whether the pane has already opened on its own this session; it does so once at most. */
41 paneAutoOpened: boolean
42 }
43 }
44}
45