SLOPSHOPPER

checklist

Live pane (/checklist) with the task list and the files the working tree changes, plus a status line counter

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-24kqesar/claude-mods/checklist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checklist
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No task yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Changes — 1 file Full diff ⎿ Read 6 lines │ ┃ M src/auth.ts ?? src/auth.test.ts ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /checklist │ ⎿ checklist: Checklist opened: no task yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No task yet. Changes — 1 file Full diff M src/auth.ts ?? src/auth.test.ts
README

checklist

When Claude works through a plan, the plan scrolls away. Twenty tool calls later you are scrolling back up to figure out which step it is on, and which files it has touched so far. I wanted both pinned next to the chat, updating as Claude goes. That's this mod.

The checklist pane: four tasks done, one in progress, one to do, then the four files the working tree changes

What you get:

  • A pane that stays beside the conversation and updates live: ☐ to do, ▶ in progress (in bold, with what Claude is doing right now), ☑ done and struck through.
  • The files Claude changed, under the plan: each file the working tree changes against HEAD, marked M, A, D, R or ? (untracked), with its lines added and removed. Full diff opens Claude Code's own diff panel. Claude Code docks one pane at a time, so this keeps the plan and the changes on screen together.
  • A counter under the prompt for when the pane is closed. It turns into 7/7 ✓ at the end.

The counter under the prompt: checklist 5/6, then the task in progress

  • A ✕ on every open task to drop it from Claude's list. It asks before deleting, and finished tasks stay put.
  • Claude actually fills it. The mod tells Claude to turn every approved plan into tasks, and reminds it the moment you accept a plan.

/checklist opens the pane. In the fullscreen terminal it also opens by itself as soon as the first task shows up.

Setup

  1. Install the mod from a Claude Code session:
   /plugin install checklist --marketplace kqesar/claude-mods
  1. Turn on the task tools. Some models don't get TaskCreate / TaskUpdate by default, and without them the pane stays empty. Add this to ~/.claude/settings.json (inside your existing env block if you have one), then restart Claude Code:
   { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "1" } }

If you forget, the mod tells you with a toast.

Good to know

  • It is a terminal thing. claude.ai/code and the mobile app don't show plugin panes, and the counter doesn't show there either.
  • Subagents keep their own lists; only the main conversation's tasks appear.
  • Models that use TodoWrite instead of the task tools are shown too, just without the ✕ (those lists have no task ids to delete by).
  • Outside a git repository the pane shows the plan alone. The first 30 changed files are listed, then a count of the rest.

How it works

The plugin API doesn't expose Claude's task list, so the mod keeps a copy in the session state and updates it from every TaskCreate, TaskUpdate and TodoWrite call it sees. When Claude calls TaskList, the copy is replaced by the real list, which is how it catches up after a /resume. The ✕ calls TaskUpdate with status: "deleted" itself, so Claude's own list changes too, not just the pane.

The changes come from git status --porcelain and git diff --numstat HEAD, run again after every tool that can write (Edit, Write, NotebookEdit, Bash), at the end of each turn and when the session starts. The pane only redraws when that list actually moved.

Source 2 files
hooks/register.tsx 450 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Change, Task, TaskStatus } from '../types'
5
6const PANE = 'checklist'
7const TITLE = 'Plan'
8// The engine exposes no task list to plugins: this is a mirror rebuilt from
9// the task tools' calls. Bump the tag when Task changes shape.
10const tasks = atom({ plugin: 'checklist', key: 'tasks' } as const, [], {
11  shape: 'tasks-1',
12})
13// The task whose ✕ was pressed, waiting for its confirmation.
14const armed = atom({ plugin: 'checklist', key: 'armed' } as const, null)
15// What the working tree changes against HEAD, read again after every tool
16// that can write: the diff panel's summary, beside the plan in one pane.
17const changes = atom({ plugin: 'checklist', key: 'changes' } as const, null, {
18  shape: 'changes-1',
19})
20
21// The tools after which the working tree may have moved.
22const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
23// Rows of the Changes section before the rest is counted.
24const MAX_CHANGES = 30
25
26const KIND: Record<string, Change['kind']> = { M: 'M', A: 'A', D: 'D', R: 'R', C: 'A', T: 'M', U: 'M' }
27
28const MARK: Record<TaskStatus, string> = {
29  pending: '☐',
30  in_progress: '▶',
31  completed: '☑',
32}
33
34const GUIDANCE = [
35  'Task list: whenever a plan is approved (ExitPlanMode accepted, or the user',
36  'validates a plan you proposed), immediately convert every step of it into',
37  'tasks with TaskCreate (or TodoWrite when that is the available tool), one',
38  'task per step, in order, before starting the work. Then keep the list',
39  'current: mark a task in_progress when you start it (only one at a time)',
40  'and completed as soon as it is done. The user follows progress through',
41  'this list, so never work through a multi-step plan without it.',
42].join('\n')
43
44const REMINDER =
45  'The plan was approved. Before anything else, create one task per plan step ' +
46  'with TaskCreate (or TodoWrite), then mark the first one in_progress.'
47
48const isTaskTool = (name: string) => name === 'TaskCreate' || name === 'TodoWrite'
49
50const labelOf = (task: Task) =>
51  task.status === 'in_progress' ? (task.activeForm ?? task.subject) : task.subject
52
53function statusOf(list: readonly Task[]): string | undefined {
54  if (list.length === 0) {
55    return undefined
56  }
57  const done = list.filter(task => task.status === 'completed').length
58  const current = list.find(task => task.status === 'in_progress')
59  const count = `${done}/${list.length}`
60
61  if (current) {
62    return `${count} ▸ ${labelOf(current)}`
63  }
64
65  return done === list.length ? `${count} ✓` : count
66}
67
68// A done task stays as a record; TodoWrite items have no id to delete by.
69const isDeletable = (task: Task) =>
70  task.status !== 'completed' && !task.id.startsWith('todo-')
71
72// Deletes the task in the engine's own list, then in the mirror: the mod's
73// own TaskUpdate hook does not see a call the mod raised itself.
74async function remove($: EngineInterface, taskId: string) {
75  await update($, armed, () => null)
76  const ran = await $.tool.call({ tool: 'TaskUpdate', taskId, status: 'deleted' })
77
78  if (ran.deny === undefined && ran.isError !== true) {
79    const after = await update($, tasks, list => list.filter(task => task.id !== taskId))
80    $.ui.status(statusOf(after))
81  }
82}
83
84async function drawChecklist(
85  $: EngineInterface,
86  { Box, Text, Button }: Pick<ElementTable, 'Box' | 'Text' | 'Button'>,
87) {
88  const list = await read($, tasks)
89  const armedId = await read($, armed)
90  const done = list.filter(task => task.status === 'completed').length
91
92  return (
93    <Box flexDirection="column">
94      <Text bold>
95        {done}/{list.length} done
96      </Text>
97      {list.map(task => {
98        const isDone = task.status === 'completed'
99
100        return (
101          <Box flexDirection="row" gap={1}>
102            <Text bold={task.status === 'in_progress'} dimColor={isDone} strikethrough={isDone}>
103              {MARK[task.status]} {labelOf(task)}
104            </Text>
105            {isDeletable(task) && armedId !== task.id && (
106              <Button
107                key={`delete-${task.id}`}
108                plain
109                label="✕"
110                onPress={() => update($, armed, () => task.id)}
111              />
112            )}
113            {armedId === task.id && (
114              <Box flexDirection="row" gap={1}>
115                <Text color="red">Delete?</Text>
116                <Button key={`yes-${task.id}`} label="Yes" onPress={() => remove($, task.id)} />
117                <Button key={`no-${task.id}`} label="No" onPress={() => update($, armed, () => null)} />
118              </Box>
119            )}
120          </Box>
121        )
122      })}
123    </Box>
124  )
125}
126
127// `git status --porcelain=v1 -z` and `git diff --numstat -z HEAD`, joined: one
128// row per path, its kind, and its line counts when git counts them.
129function parseChanges(status: string, numstat: string): Change[] {
130  const counts = new Map<string, { added?: number; removed?: number }>()
131  const stat = numstat.split('\0')
132
133  for (let i = 0; i < stat.length; i++) {
134    const [added, removed, path] = (stat[i] ?? '').split('\t')
135
136    if (path === undefined) {
137      continue
138    }
139    // A rename's record is "a\tr\t" then the old and the new path
140    const name = path === '' ? stat[(i += 2)] : path
141
142    if (name !== undefined) {
143      counts.set(name, {
144        added: added === '-' ? undefined : Number(added),
145        removed: removed === '-' ? undefined : Number(removed),
146      })
147    }
148  }
149
150  const rows: Change[] = []
151  const entries = status.split('\0')
152
153  for (let i = 0; i < entries.length; i++) {
154    const entry = entries[i] ?? ''
155
156    if (entry.length < 4) {
157      continue
158    }
159    const code = entry.slice(0, 2)
160    const path = entry.slice(3)
161    // A rename or copy is followed by its old path
162    if (code.includes('R') || code.includes('C')) {
163      i += 1
164    }
165    const kind = code === '??' ? '?' : (KIND[code.trim().charAt(0)] ?? 'M')
166    rows.push({ path, kind, ...counts.get(path) })
167  }
168
169  return rows.sort((a, b) => a.path.localeCompare(b.path))
170}
171
172// Reads the working tree's changes; null outside a repository. Writes only
173// when they moved, so a read that finds nothing new redraws nothing.
174async function refreshChanges($: EngineInterface) {
175  const status = await $.process.run(['git', 'status', '--porcelain=v1', '-z'])
176  let next: Change[] | null = null
177
178  if (status.exitCode === 0) {
179    // A repository with no commit yet has no HEAD to diff against
180    const numstat = await $.process.run(['git', 'diff', '--numstat', '-z', 'HEAD'])
181    next = parseChanges(status.stdout, numstat.exitCode === 0 ? numstat.stdout : '')
182  }
183
184  const before = await read($, changes)
185
186  if (JSON.stringify(before) !== JSON.stringify(next)) {
187    await update($, changes, () => next)
188  }
189}
190
191// "+40 -1"; a side with no line is left out ("+120", "-1"), as git's stat does
192function countsOf({ added, removed }: Change): string {
193  const parts = [added ? `+${added}` : '', removed ? `-${removed}` : '']
194
195  return parts.filter(Boolean).join(' ')
196}
197
198async function drawChanges(
199  $: EngineInterface,
200  { Box, Text, Button }: Pick<ElementTable, 'Box' | 'Text' | 'Button'>,
201  bodyColumns: number,
202) {
203  const list = await read($, changes)
204
205  if (list === null) {
206    return null
207  }
208
209  const shown = list.slice(0, MAX_CHANGES)
210  // The counts' column, as wide as the widest, the path in what is left
211  const countWidth = Math.max(0, ...shown.map(change => countsOf(change).length))
212  const pathWidth = Math.max(8, bodyColumns - countWidth - 3)
213
214  return (
215    <Box flexDirection="column" marginTop={1}>
216      <Box flexDirection="row" gap={1}>
217        <Text bold>
218          Changes {list.length === 0 ? '— none' : `— ${list.length} file${list.length === 1 ? '' : 's'}`}
219        </Text>
220        {list.length > 0 && (
221          <Button
222            key="full-diff"
223            plain
224            label="Full diff"
225            onPress={() => $.command.run({ command: 'diff' })}
226          />
227        )}
228      </Box>
229      {shown.map(change => (
230        <Box key={`change-${change.path}`} flexDirection="row" gap={1}>
231          <Text color={change.kind === 'D' ? 'red' : change.kind === 'M' ? undefined : 'green'}>
232            {change.kind}
233          </Text>
234          <Box width={pathWidth}>
235            <Text wrap="truncate-start">{change.path}</Text>
236          </Box>
237          <Box width={countWidth} justifyContent="flex-end">
238            <Text dimColor>{countsOf(change)}</Text>
239          </Box>
240        </Box>
241      ))}
242      {list.length > shown.length && <Text dimColor>+{list.length - shown.length} more</Text>}
243    </Box>
244  )
245}
246
247// Applies a change to the mirror, then refreshes the status line and opens
248// the pane unasked on the first task where a surface docks it as a sidebar.
249async function change(
250  $: EngineInterface,
251  docking: ReadonlySet<RenderSurface>,
252  fn: (list: Task[]) => Task[],
253) {
254  const before = (await read($, tasks)).length
255  const after = await update($, tasks, list => fn([...list]))
256  $.ui.status(statusOf(after))
257
258  if (before === 0 && after.length > 0 && docking.size > 0) {
259    void $.ui.open({ id: PANE, title: TITLE })
260  }
261}
262
263export const register: Register = on => {
264  // Surfaces whose last drawing said they dock a pane beside the transcript.
265  const docking = new Set<RenderSurface>()
266  let hasWarned = false
267
268  on('session.start', async ($, e, next) => {
269    await $.command.register({
270      name: 'checklist',
271      description: 'Show the current task list in a live pane',
272    })
273    $.ui.status(statusOf(await read($, tasks)))
274    void refreshChanges($)
275
276    return next(e)
277  })
278
279  // Any tool that can write the tree, the main conversation's or an agent's
280  on('tool.call', async ($, e, next) => {
281    const ran = await next(e)
282
283    // Awaited: the pane is current by the time the tool's result is read
284    if (WRITERS.has(e.tool)) {
285      await refreshChanges($)
286    }
287
288    return ran
289  })
290
291  // A change made outside a tool call (the person's own editor) shows by the
292  // next turn's end
293  on('turn.complete', async ($, e, next) => {
294    void refreshChanges($)
295
296    return next(e)
297  })
298
299  on('command.run', { command: 'checklist' }, async $ => {
300    const opened = await $.ui.open({ id: PANE, title: TITLE })
301    const count = (await read($, tasks)).length
302
303    if (!opened.isPlaced) {
304      return { text: `Checklist pane not shown: ${opened.reason}` }
305    }
306
307    return {
308      text: count === 0 ? 'Checklist opened: no task yet.' : 'Checklist opened.',
309    }
310  })
311
312  // A new plan starts once every task of the previous one is done.
313  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
314    const ran = await next(e)
315
316    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
317      const task: Task = {
318        id: ran.result.task.id,
319        subject: e.subject,
320        activeForm: e.activeForm,
321        status: 'pending',
322      }
323      await change($, docking, list => {
324        const isFinished = list.every(one => one.status === 'completed')
325
326        return isFinished ? [task] : [...list, task]
327      })
328    }
329
330    return ran
331  })
332
333  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
334    const ran = await next(e)
335
336    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
337      const { status } = e
338      await change($, docking, list => {
339        if (status === 'deleted') {
340          return list.filter(task => task.id !== e.taskId)
341        }
342
343        return list.map(task =>
344          task.id === e.taskId
345            ? {
346                ...task,
347                subject: e.subject ?? task.subject,
348                activeForm: e.activeForm ?? task.activeForm,
349                status: status ?? task.status,
350              }
351            : task,
352        )
353      })
354    }
355
356    return ran
357  })
358
359  // TaskList is the source of truth: it resyncs a mirror that drifted (resume).
360  on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
361    const ran = await next(e)
362
363    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
364      const known = new Map((await read($, tasks)).map(task => [task.id, task]))
365      await change($, docking, () =>
366        ran.result.tasks.map(task => ({
367          id: task.id,
368          subject: task.subject,
369          activeForm: known.get(task.id)?.activeForm,
370          status: task.status,
371        })),
372      )
373    }
374
375    return ran
376  })
377
378  // Models that ship TodoWrite instead of the Task* tools rewrite the whole list.
379  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
380    const ran = await next(e)
381
382    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
383      await change($, docking, () =>
384        ran.result.newTodos.map((todo, index) => ({
385          id: `todo-${index}`,
386          subject: todo.content,
387          activeForm: todo.activeForm,
388          status: todo.status,
389        })),
390      )
391    }
392
393    return ran
394  })
395
396  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
397    const ran = await next(e)
398    const isApproved =
399      e.agentId === undefined &&
400      ran.deny === undefined &&
401      ran.isError !== true &&
402      ran.result.awaitingLeaderApproval !== true
403
404    return isApproved ? { ...ran, context: [...(ran.context ?? []), REMINDER] } : ran
405  })
406
407  on('prompt.compose', async ($, e, next) => {
408    const composed = await next(e)
409
410    if (!e.tools.some(isTaskTool) && !hasWarned) {
411      hasWarned = true
412      $.ui.toast('checklist: no task tool in this session, set CLAUDE_CODE_ENABLE_TODO_TOOLS=1')
413    }
414
415    return {
416      sections: [
417        ...composed.sections,
418        { id: 'checklist:tasks', text: GUIDANCE, scope: 'session' },
419      ],
420    }
421  })
422
423  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
424    if (e.viewport?.isFullscreen === true) {
425      docking.add(e.surface)
426    } else if (e.viewport?.isFullscreen === false) {
427      docking.delete(e.surface)
428    }
429
430    return next(e)
431  })
432
433  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
434    const ui = $.ui.resolve(e)
435    const plan =
436      (await read($, tasks)).length === 0 ? (
437        <ui.Text dimColor>No task yet.</ui.Text>
438      ) : (
439        await drawChecklist($, ui)
440      )
441
442    return (
443      <ui.Box flexDirection="column">
444        {plan}
445        {await drawChanges($, ui, e.props.bodyColumns)}
446      </ui.Box>
447    )
448  })
449}
450
types/index.d.ts 30 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4  id: string
5  subject: string
6  activeForm?: string
7  status: TaskStatus
8}
9
10/** One file the working tree changes against HEAD, as git reports it. */
11export type Change = {
12  path: string
13  /** `M` modified, `A` added, `D` deleted, `R` renamed, `?` untracked. */
14  kind: 'M' | 'A' | 'D' | 'R' | '?'
15  /** Lines added and removed; absent for a binary or untracked file. */
16  added?: number
17  removed?: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    checklist: {
23      tasks: Shaped<Task[]>
24      armed: string | null
25      /** Null outside a git repository: the pane then shows the plan alone. */
26      changes: Shaped<Change[] | null>
27    }
28  }
29}
30