SLOPSHOPPER

session-todo

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

newpanebandguardcommandtoast
v0.2.21no licenseupdated 2026-10-09jepperip/claude-session-todo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-todo
│ ┃ Todo ✕ › fix the failing auth test and add an audit log call │ ┃ ⚙ │ ┃ Nothing planned yet. The agent fills this ⏺ Read(src/auth.ts) │ ┃ in as work is planned; /todo add adds your ⎿ Read 6 lines │ ┃ own. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ ] pending [>] in progress [x] done ⏺ Bash(bun test) │ ┃ [!] blocked ✱ Claude's ♟ yours · click ⎿ 3 pass, 1 fail │ ┃ a glyph to cycle │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /todo │ ┃ ⎿ session-todo: Todo: no lists │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Todo
⚙ Nothing planned yet. The agent fills this in as work is planned; /todo add adds your own. [ ] pending [>] in progress [x] done [!] blocked ✱ Claude's ♟ yours · click a glyph to cycle
README

session-todo

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.

What you get

  • A "Todo" pane beside the transcript, opened on its own once per session at most: the first time anything is written to it (or at session start when a resumed session already has items). Close it and it stays closed; the band stands in for it. Lists are stacked sections, the one the agent is working in first, each with an optional one-line 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.
  • A band above the prompt while the pane is closed: [>] 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.
  • A progress bar in each list's title row, beside the 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.
  • A /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].
  • Fridays: a small figure with a beer stands at the bottom of the pane and wishes you a nice weekend.
  • Row spacing: a blank line between items by default; "Compact rows" in the ⚙ settings row, /todo compact on|off, or the /config row drops it.
  • Themes: 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.
  • A 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.

Install

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.

Run from a checkout instead

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.

Layout

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

Status

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.

Source 2 files
hooks/register.tsx 904 lines
1import { 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}
904
types/index.d.ts 45 lines
1export 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