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

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.

What you get:
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.7/7 ✓ at the end.
/checklist opens the pane. In the fullscreen terminal it also opens by itself as soon as the first task shows up.
/plugin install checklist --marketplace kqesar/claude-mods
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.
TodoWrite instead of the task tools are shown too, just without the ✕ (those lists have no task ids to delete by).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.
hooks/register.tsx 450 lines1import { 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}
450types/index.d.ts 30 lines1export 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