Pin a list from Claude's reply as a checklist at the top of your session: short items, click to expand, check off or remove

A Claude Code mod that turns a list from Claude's reply into a checklist pinned to the top of your session, so the plan stays in view while the conversation scrolls on.

Inside Claude Code, run:
/plugin marketplace add tchoedak/claude-checklist
/plugin install checklist
/reload-plugins
That's it. The next time Claude replies with a list, a Save as checklist button appears above the prompt.
claude plugin marketplace add tchoedak/claude-checklist
claude plugin install checklist@claude-checklist
Then run /reload-plugins inside a session, or start a new one.
When a reply has a list of two or more items, the mod offers it above the prompt:

Save it and it sits above the prompt, titled after the list's heading:

Once the conversation scrolls past it, it pins to the top of the screen, as in the picture at the top of this page.
☐ to mark an item done and watch the progress bar fill. ✕ removes an item, and ↶ undo puts it back.3. stays 3., so "do #3" still means the same thing to you and to Claude.claude --resume brings it back.| Command | What it does |
|---|---|
/checklist | Shows the current checklist, or saves Claude's latest list if there isn't one |
/checklist save | Saves the list in your mouse selection, else Claude's latest list, replacing the current checklist |
/checklist clear | Removes the checklist |
A reply often has more than one list. The mod takes the last numbered list, since plans and next steps are usually numbered and usually last, and falls back to the last bulleted list. It skips anything inside code fences. The heading or Label: line right above the list becomes the checklist's title.
To pick a different list, select it with the mouse and run /checklist save. A selection that starts partway through the reply works, including the ⏺ and ⎿ glyphs the terminal draws.
Markdown task boxes are read too: - [x] shipped starts out checked.
Claude Checklist is a mod:
turn.complete it parses the answer for lists and, if it finds one with two or more items, offers it in the band above the prompt.… N more. When no row is cut off, the band above the prompt draws it instead.Claude Checklist is local-only. It makes no network requests and runs no processes. It reads Claude's replies in the current session and your mouse selection when you run /checklist save, and writes the checklist to Claude Code's per-plugin store.
Run claude plugin validate on the repo to see every event it hooks and every call it makes.
No "Save as checklist" button appears. The reply needs a list with at least two items, outside a code fence. Run /checklist to save the latest list directly. If that says the command is unknown, run /plugin and check that checklist is listed as an active mod, then /reload-plugins.
It picked the wrong list. Select the list you want with the mouse and run /checklist save.
git clone https://github.com/tchoedak/claude-checklist
cd claude-checklist
# Load it for one session without installing
claude --plugin-dir .
# Check it and run the tests
claude plugin validate .
claude plugin test .
Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on.
MIT. See LICENSE.
hooks/register.tsx 398 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { Checklist, Removed } from '../types'
5import { normalizeSelection, pickChecklist } from './parse'
6
7const STORE_PREFIX = 'list:'
8const STORE_LIMIT = 50
9
10const list = atom({ plugin: 'checklist', key: 'list' } as const, null)
11const isExpanded = atom({ plugin: 'checklist', key: 'isExpanded' } as const, false)
12const expanded = atom({ plugin: 'checklist', key: 'expanded' } as const, [])
13const offer = atom({ plugin: 'checklist', key: 'offer' } as const, null)
14const dismissed = atom({ plugin: 'checklist', key: 'dismissed' } as const, [])
15const removed = atom({ plugin: 'checklist', key: 'removed' } as const, null)
16
17// Transcript rows that say which of their rows are on screen. The one cut off
18// at the viewport's top edge draws the checklist over that edge, so it stays
19// pinned to the top of the screen whatever is scrolled beneath it.
20const TOP_SITES = [
21 'UserMessage',
22 'AssistantMessage',
23 'ToolUse',
24 'ToolResult',
25 'ToolGroup',
26 'CommandOutput',
27 'TurnDuration',
28 'InfoNotice',
29] as const
30
31// Greys, so a 256-colour terminal (tmux by default) draws exactly these shades:
32// each lands on a step of its grey ramp (#303030 is 236).
33const THEME = {
34 background: '#303030',
35 text: '#ffffff',
36 muted: '#a8a8a8',
37 done: '#8a8a8a',
38} as const
39
40type $ = EngineInterface
41type Table = Pick<Elements['terminal'], 'Box' | 'Button' | 'Markdown' | 'Text'>
42
43// Which transcript row draws the pinned checklist now, if any. Module state on
44// purpose: it is a fact about this drawing, rebuilt by the next one after a reload.
45let topSite: string | null = null
46
47function claimTop($: $, site: string, isTop: boolean) {
48 const before = topSite
49 if (isTop) {
50 topSite = site
51 } else if (topSite === site) {
52 topSite = null
53 }
54 // The band shows the checklist only while nothing is pinned at the top.
55 if (before !== topSite) $.ui.invalidate('ui.render')
56}
57
58const doneCount = (checklist: Checklist) => checklist.items.filter(item => item.isDone).length
59
60const progress = (checklist: Checklist) => `${doneCount(checklist)}/${checklist.items.length}`
61
62function progressBar(checklist: Checklist): [string, string] {
63 const total = checklist.items.length
64 if (total === 0) return ['', '']
65 const cells = Math.min(total, 10)
66 const filled = Math.round((doneCount(checklist) / total) * cells)
67
68 return ['━'.repeat(filled), '━'.repeat(cells - filled)]
69}
70
71// Every change goes through here so a resumed session finds its checklist again.
72async function changeList($: $, change: (current: Checklist | null) => Checklist | null) {
73 const next = await update($, list, change)
74 const key = STORE_PREFIX + (await $.session.id())
75 if (next === null) {
76 await $.store.delete(key)
77 } else {
78 await $.store.set(key, next)
79 }
80
81 return next
82}
83
84async function save($: $, checklist: Checklist) {
85 await changeList($, () => checklist)
86 await update($, isExpanded, () => true)
87 await update($, expanded, () => [])
88 await update($, removed, () => null)
89 await update($, offer, () => null)
90}
91
92/** The newest assistant message holding a list, scanning back through the session. */
93async function latestFromSession($: $): Promise<Checklist | null> {
94 const messages = await $.session.messages()
95 for (let i = messages.length - 1; i >= 0; i -= 1) {
96 const message = messages[i]
97 if (message?.role !== 'assistant') continue
98 const found = pickChecklist(message.text, 1)
99 if (found) return found
100 }
101
102 return null
103}
104
105async function toggleDone($: $, id: string) {
106 await changeList($, checklist =>
107 checklist && {
108 ...checklist,
109 items: checklist.items.map(item => (item.id === id ? { ...item, isDone: !item.isDone } : item)),
110 },
111 )
112}
113
114async function remove($: $, id: string) {
115 let taken: Removed | null = null
116 await changeList($, checklist => {
117 const index = checklist?.items.findIndex(item => item.id === id) ?? -1
118 const item = checklist?.items[index]
119 if (!checklist || !item) return checklist
120 taken = { item, index }
121
122 return { ...checklist, items: checklist.items.filter(one => one.id !== id) }
123 })
124 const wasTaken = taken as Removed | null
125 if (wasTaken) await update($, removed, () => wasTaken)
126}
127
128async function undoRemove($: $) {
129 const last = await read($, removed)
130 if (!last) return
131 await changeList($, checklist => {
132 if (!checklist) return checklist
133 const items = [...checklist.items]
134 items.splice(Math.min(last.index, items.length), 0, last.item)
135
136 return { ...checklist, items }
137 })
138 await update($, removed, () => null)
139}
140
141// Rows an open item takes: its line, then its text wrapped beside the indent.
142// Counted short of the indent's real width so wrapping at words is not undercounted.
143function openItemRows(body: string, width: number): number {
144 const columns = Math.max(1, width - 11)
145
146 return 1 + body.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / columns)), 0)
147}
148
149/**
150 * The checklist as drawn at the top or in the band: one line, or the line and its items.
151 * Given `room`, a list taller than that keeps to it: the title, the items that fit as
152 * one line each, and how many are left. Answers the tree and the rows it takes.
153 */
154async function drawChecklist($: $, ui: Table, current: Checklist, width: number, room = Infinity) {
155 const { Box, Button, Markdown, Text } = ui
156 const isOpen = await read($, isExpanded)
157 const openItems = await read($, expanded)
158 const lastRemoved = await read($, removed)
159 const [filled, empty] = progressBar(current)
160 // The row's padding, box, gap, gap and ✕ take 6 columns; the title takes the rest.
161 const titleColumns = Math.max(8, width - 6)
162
163 const isEmpty = isOpen && current.items.length === 0
164 const fullRows = !isOpen
165 ? 1
166 : 1 +
167 (isEmpty ? 1 : 0) +
168 (lastRemoved ? 1 : 0) +
169 current.items.reduce(
170 (sum, item) => sum + (openItems.includes(item.id) ? openItemRows(item.body, width) : 1),
171 0,
172 )
173 const isTrimmed = fullRows > room
174 const items = !isOpen ? [] : isTrimmed ? current.items.slice(0, Math.max(0, room - 2)) : current.items
175 const hidden = isOpen && isTrimmed && room >= 2 ? current.items.length - items.length : 0
176 const rows = isTrimmed ? room : fullRows
177
178 const tree = (
179 <Box flexDirection="column" width={width}>
180 <Box flexDirection="row" backgroundColor={THEME.background} paddingX={1} width={width}>
181 <Button key="toggle" plain onPress={() => update($, isExpanded, value => !value)}>
182 <Text color={THEME.muted}>{isOpen ? '▾' : '▸'} </Text>
183 <Text color={THEME.text} bold>
184 {current.title}
185 </Text>
186 <Text color={THEME.muted}> {progress(current)} done </Text>
187 <Text color={THEME.text}>{filled}</Text>
188 <Text color={THEME.done}>{empty}</Text>
189 </Button>
190 </Box>
191 {isEmpty && (
192 <Box backgroundColor={THEME.background} paddingX={1} width={width}>
193 <Text color={THEME.done}>All items removed.</Text>
194 </Box>
195 )}
196 {items.map(item => {
197 const isItemOpen = !isTrimmed && openItems.includes(item.id)
198
199 return (
200 <Box flexDirection="column" width={width}>
201 <Box flexDirection="row" backgroundColor={THEME.background} paddingX={1} width={width}>
202 <Button key={`done:${item.id}`} plain onPress={() => toggleDone($, item.id)}>
203 <Text color={THEME.text}>{item.isDone ? '☒' : '☐'}</Text>
204 </Button>
205 <Box width={titleColumns} marginLeft={1}>
206 <Button
207 key={`row:${item.id}`}
208 plain
209 onPress={() =>
210 update($, expanded, ids =>
211 ids.includes(item.id) ? ids.filter(one => one !== item.id) : [...ids, item.id],
212 )
213 }
214 >
215 <Text color={THEME.muted}>{isItemOpen ? '▾' : '▸'} </Text>
216 <Text color={THEME.muted}>{item.marker} </Text>
217 <Text
218 color={item.isDone ? THEME.done : THEME.text}
219 strikethrough={item.isDone}
220 wrap="truncate-end"
221 >
222 {item.title}
223 </Text>
224 </Button>
225 </Box>
226 <Box marginLeft={1}>
227 <Button key={`x:${item.id}`} plain onPress={() => remove($, item.id)}>
228 <Text color={THEME.muted}>✕</Text>
229 </Button>
230 </Box>
231 </Box>
232 {isItemOpen && (
233 <Box backgroundColor={THEME.background} paddingLeft={6} paddingRight={1} width={width}>
234 <Markdown text={item.body} dimColor={item.isDone} />
235 </Box>
236 )}
237 </Box>
238 )
239 })}
240 {hidden > 0 && (
241 <Box backgroundColor={THEME.background} paddingX={1} width={width}>
242 <Text color={THEME.muted}>… {hidden} more</Text>
243 </Box>
244 )}
245 {isOpen && !isTrimmed && lastRemoved && (
246 <Box backgroundColor={THEME.background} paddingX={1} width={width}>
247 <Button key="undo" plain onPress={() => undoRemove($)}>
248 <Text color={THEME.muted}>↶ undo remove "{lastRemoved.item.title}"</Text>
249 </Button>
250 </Box>
251 )}
252 </Box>
253 )
254
255 return { tree, rows }
256}
257
258export const register: Register = on => {
259 on('session.start', async ($, e, next) => {
260 await $.command.register({
261 name: 'checklist',
262 description: 'Pin the latest list from Claude as a checklist at the top of the screen',
263 argumentHint: '[save | clear]',
264 })
265
266 if ((await read($, list)) === null) {
267 const stored = await $.store.get(STORE_PREFIX + (await $.session.id()))
268 if (stored) await update($, list, () => stored as Checklist)
269 }
270
271 const keys = (await $.store.keys()).filter(key => key.startsWith(STORE_PREFIX))
272 for (const key of keys.slice(0, Math.max(0, keys.length - STORE_LIMIT))) {
273 await $.store.delete(key)
274 }
275
276 return next(e)
277 })
278
279 on('command.run', { command: 'checklist' }, async ($, e) => {
280 const verb = e.args.trim().toLowerCase()
281
282 if (verb === 'clear') {
283 await changeList($, () => null)
284 await update($, removed, () => null)
285
286 return { text: 'Checklist cleared.' }
287 }
288
289 const current = await read($, list)
290 if (verb === '' && current) {
291 await update($, isExpanded, () => true)
292
293 return { text: `Checklist: ${current.title} (${progress(current)} done).` }
294 }
295
296 if (verb !== '' && verb !== 'save') {
297 return { text: 'Usage: /checklist [save | clear]' }
298 }
299
300 const selection = await $.ui.selection()
301 const fromSelection = selection ? pickChecklist(normalizeSelection(selection.text), 1) : null
302 const found = fromSelection ?? (await latestFromSession($))
303 if (!found) {
304 return { text: 'No list found in the selection or in any reply from Claude.' }
305 }
306 await save($, found)
307 const source = fromSelection ? 'the selection' : "Claude's latest list"
308
309 return { text: `Saved ${found.items.length} items from ${source} as "${found.title}".` }
310 })
311
312 on('turn.complete', async ($, e, next) => {
313 if (e.agentId === undefined && !e.isAborted) {
314 const found = pickChecklist(e.answer, 2)
315 await update($, offer, () => found)
316 }
317
318 return next(e)
319 })
320
321 on('ui.render', { component: TOP_SITES }, async ($, e, next) => {
322 const drawn = await next(e)
323 const shown = e.props.onScreen
324 const site = `${e.component}:${e.requestId}`
325 // Only the row whose top is scrolled away holds the viewport's top edge;
326 // reading the checklist here alone keeps every other row unsubscribed.
327 const isEdge = shown !== null && shown !== undefined && shown.first > 0
328 const current = isEdge ? await read($, list) : null
329 claimTop($, site, current !== null)
330 if (current === null || !shown) return drawn
331
332 const ui = $.ui.resolve(e)
333 // The transcript is a column narrower than the terminal; rows as wide as
334 // the terminal lose their background there.
335 const width = (e.viewport?.columns ?? 80) - 1
336 // The rows after this one paint over anything that spills past its last
337 // row on screen, so the checklist keeps to the rows this one shows.
338 const room = shown.last - shown.first + 1
339 const { tree, rows } = await drawChecklist($, ui, current, width, room)
340
341 return (
342 <ui.Box flexDirection="column">
343 {drawn}
344 <ui.Box
345 key="pinned"
346 position="absolute"
347 top={shown.first}
348 left={0}
349 width={width}
350 height={Math.min(rows, room)}
351 overflow="hidden"
352 >
353 {tree}
354 </ui.Box>
355 </ui.Box>
356 )
357 })
358
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 if (e.props.hasSurvey) return next(e)
361
362 const current = await read($, list)
363 const pending = await read($, offer)
364 const isOffered =
365 pending !== null &&
366 pending.sourceKey !== current?.sourceKey &&
367 !(await read($, dismissed)).includes(pending.sourceKey)
368 const isFallback = current !== null && topSite === null
369 if (!isOffered && !isFallback) return next(e)
370
371 const ui = $.ui.resolve(e)
372 const { Box, Button, Text } = ui
373
374 return (
375 <Box flexDirection="column">
376 {isFallback && current && (await drawChecklist($, ui, current, e.props.bodyColumns)).tree}
377 {isOffered && pending && (
378 <Box flexDirection="row" gap={1}>
379 <Text color={THEME.muted} wrap="truncate-end">
380 {pending.items.length}-item list in the last reply
381 </Text>
382 <Button key="save-offer" variant="primary" onPress={() => save($, pending)}>
383 Save as checklist
384 </Button>
385 <Button
386 key="dismiss-offer"
387 plain
388 dimColor
389 label="✕"
390 onPress={() => update($, dismissed, keys => [...keys, pending.sourceKey].slice(-50))}
391 />
392 </Box>
393 )}
394 </Box>
395 )
396 })
397}
398hooks/parse.ts 163 lines1import type { Checklist, ChecklistItem } from '../types'
2
3type ParsedItem = { marker: string; lines: string[] }
4type ParsedList = { heading: string; isOrdered: boolean; items: ParsedItem[] }
5
6// A list marker as written in markdown, or as the terminal draws one when the
7// text came from a mouse selection (`•`).
8const MARKER = /^(\s*)(\d{1,3}[.)]|[-*+•◦▪])(\s+)(.*)$/
9const FENCE = /^\s*(```|~~~)/
10const TASK_BOX = /^\[([ xX])\]\s+/
11// The glyphs the terminal draws before a reply or a command's output, present in a selection.
12const REPLY_GLYPH = /^\s*[⏺●⎿]\s*/
13
14const indentOf = (line: string) => line.length - line.trimStart().length
15const isBlank = (line: string) => line.trim() === ''
16const isOrderedMarker = (marker: string) => /\d/.test(marker)
17
18function stripInline(text: string): string {
19 return text
20 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
21 .replace(/(\*\*|__)(.+?)\1/g, '$2')
22 .replace(/`([^`]*)`/g, '$1')
23 .replace(/\s+/g, ' ')
24 .trim()
25}
26
27/** The short form of an item: its bold lead, else its first clause. */
28export function shortTitle(firstLine: string): string {
29 const line = firstLine.replace(TASK_BOX, '').trim()
30 const bold = /^(\*\*|__)(.+?)\1/.exec(line)
31 if (bold?.[2]) {
32 return stripInline(bold[2]).replace(/[:.\s-]+$/, '')
33 }
34 const plain = stripInline(line)
35 const cut = /^(.{8,80}?)(?:\s+[-–—]\s+|:\s+|\.\s+)/.exec(plain)
36
37 return cut?.[1] ?? plain
38}
39
40function headingAbove(lines: string[], start: number): string {
41 let i = start - 1
42 while (i >= 0 && isBlank(lines[i] ?? '')) i -= 1
43 const line = (lines[i] ?? '').trim()
44 const heading = /^#{1,6}\s+(.*)$/.exec(line)
45 if (heading?.[1]) return stripInline(heading[1])
46 if (line.endsWith(':')) return stripInline(line).replace(/:$/, '').slice(0, 80)
47
48 return ''
49}
50
51/** Every top-level list in a reply, in order, nested items kept inside their parent. */
52export function findLists(text: string): ParsedList[] {
53 const lines = text.split('\n').map(line => line.replace(REPLY_GLYPH, ''))
54 const lists: ParsedList[] = []
55 let isInFence = false
56 let i = 0
57
58 while (i < lines.length) {
59 const line = lines[i] ?? ''
60 if (FENCE.test(line)) isInFence = !isInFence
61 const start = isInFence ? null : MARKER.exec(line)
62 if (!start) {
63 i += 1
64 continue
65 }
66
67 const baseIndent = start[1]?.length ?? 0
68 const isOrdered = isOrderedMarker(start[2] ?? '')
69 const list: ParsedList = { heading: headingAbove(lines, i), isOrdered, items: [] }
70 let item: ParsedItem | null = null
71 let contentIndent = 0
72 let isAfterBlank = false
73
74 for (; i < lines.length; i += 1) {
75 const current = lines[i] ?? ''
76 const marker = MARKER.exec(current)
77 const isSibling =
78 marker !== null &&
79 (marker[1]?.length ?? 0) === baseIndent &&
80 isOrderedMarker(marker[2] ?? '') === isOrdered
81
82 if (isSibling && marker) {
83 item = { marker: marker[2] ?? '', lines: [marker[4] ?? ''] }
84 list.items.push(item)
85 contentIndent = baseIndent + (marker[2]?.length ?? 0) + (marker[3]?.length ?? 1)
86 isAfterBlank = false
87 continue
88 }
89 if (isBlank(current)) {
90 item?.lines.push('')
91 isAfterBlank = true
92 continue
93 }
94 const isNested = indentOf(current) > baseIndent
95 const isLazy = !isAfterBlank && marker === null && !/^\s*(#|```|~~~|\|)/.test(current)
96 if (item && (isNested || isLazy)) {
97 const strip = Math.min(indentOf(current), contentIndent)
98 item.lines.push(current.slice(strip))
99 isAfterBlank = false
100 continue
101 }
102 break
103 }
104
105 for (const done of list.items) {
106 while (done.lines.length > 0 && isBlank(done.lines[done.lines.length - 1] ?? '')) {
107 done.lines.pop()
108 }
109 }
110 lists.push(list)
111 }
112
113 return lists
114}
115
116/**
117 * A mouse selection that starts on a list marker loses that line's indent while
118 * the lines after it keep theirs; give it back so the items stay siblings.
119 */
120export function normalizeSelection(text: string): string {
121 const lines = text.split('\n').map(line => line.replace(REPLY_GLYPH, ''))
122 const rest = lines.slice(1).filter(line => !isBlank(line))
123 const first = lines[0] ?? ''
124 if (rest.length === 0) return lines.join('\n')
125 const restIndent = Math.min(...rest.map(indentOf))
126 if (indentOf(first) >= restIndent) return lines.join('\n')
127
128 return [' '.repeat(restIndent) + first.trimStart(), ...lines.slice(1)].join('\n')
129}
130
131function hash(text: string): string {
132 let h = 5381
133 for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h + text.charCodeAt(i)) | 0
134
135 return (h >>> 0).toString(36)
136}
137
138/**
139 * The list in a reply worth tracking: the last numbered one (task lists are
140 * usually numbered and usually last), else the last bulleted one.
141 */
142export function pickChecklist(text: string, minItems: number): Checklist | null {
143 const lists = findLists(text).filter(list => list.items.length >= minItems)
144 const chosen = [...lists].reverse().find(list => list.isOrdered) ?? lists[lists.length - 1]
145 if (!chosen) return null
146
147 const sourceKey = hash(chosen.items.map(item => item.lines.join('\n')).join('\n\u0000'))
148 const items: ChecklistItem[] = chosen.items.map((parsed, index) => {
149 const first = parsed.lines[0] ?? ''
150 const box = TASK_BOX.exec(first)
151
152 return {
153 id: `${sourceKey}-${index}`,
154 marker: parsed.marker,
155 title: shortTitle(first) || `Item ${index + 1}`,
156 body: [first.replace(TASK_BOX, ''), ...parsed.lines.slice(1)].join('\n'),
157 isDone: box !== null && box[1] !== ' ',
158 }
159 })
160
161 return { title: chosen.heading || 'Checklist', items, sourceKey }
162}
163types/index.d.ts 35 lines1export type ChecklistItem = {
2 id: string
3 /** The list's own marker ("3." or "-"), kept so "do #3" still means something. */
4 marker: string
5 /** One-line short form shown collapsed. */
6 title: string
7 /** The item's full original markdown, shown expanded. */
8 body: string
9 isDone: boolean
10}
11
12export type Checklist = {
13 title: string
14 items: ChecklistItem[]
15 /** Hash of the source list, so a list already saved is not offered again. */
16 sourceKey: string
17}
18
19export type Removed = { item: ChecklistItem; index: number }
20
21declare module 'claude-code' {
22 interface PluginState {
23 checklist: {
24 list: Checklist | null
25 /** Whether the pinned line shows its items. */
26 isExpanded: boolean
27 /** Ids of the items showing their full text. */
28 expanded: string[]
29 offer: Checklist | null
30 dismissed: string[]
31 removed: Removed | null
32 }
33 }
34}
35