SLOPSHOPPER

checklist

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

newbandspinnerrowscommand
v0.1.1MITupdated 2026-10-13tchoedak/claude-checklist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checklist
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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: No list found in the selection or in any reply from Claude. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Checklist

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.

License Stars

Claude Checklist pinned to the top of a session, three items checked off and one expanded

Install

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.

What You See

When a reply has a list of two or more items, the mod offers it above the prompt:

A Save as checklist button above the prompt, under Claude's 7-item release checklist

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

The saved checklist above the prompt, 0 of 7 done

Once the conversation scrolls past it, it pins to the top of the screen, as in the picture at the top of this page.

  • Pinned while you scroll. The checklist sits on the top edge of the transcript whatever is scrolled beneath it. When nothing is scrolled off the top, it moves to the band above the prompt instead.
  • Short items, full text on demand. Each item shows its bold lead or first clause. Click it to expand the item's full markdown, nested bullets included.
  • Check off, remove, undo. Click ☐ to mark an item done and watch the progress bar fill. ✕ removes an item, and ↶ undo puts it back.
  • Collapse to one line. Click the title to fold the checklist down to its title and progress.
  • Keeps the list's own numbers. Item 3. stays 3., so "do #3" still means the same thing to you and to Claude.
  • Survives a resume. The checklist is saved per session, so claude --resume brings it back.

Commands

CommandWhat it does
/checklistShows the current checklist, or saves Claude's latest list if there isn't one
/checklist saveSaves the list in your mouse selection, else Claude's latest list, replacing the current checklist
/checklist clearRemoves the checklist

Which List It Picks

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.

How It Works

Claude Checklist is a mod:

  1. On 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.
  2. Saving stores the checklist in plugin state, and in the mod's store keyed by session id so a resumed session finds it again. Only the 50 most recent sessions are kept.
  3. Every transcript row reports which of its lines are on screen. The row cut off by the top of the viewport draws the checklist over that edge with absolute positioning, which is what keeps it pinned. Rows below paint over anything that spills past that row, so the checklist keeps to the rows it has on screen: when that row is short, you see the title, the items that fit and … N more. When no row is cut off, the band above the prompt draws it instead.

Security

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.

Requirements

  • Claude Code with mods support. Built and tested on v2.1.296.

Troubleshooting

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.

Development

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.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 398 lines
1import { 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}
398
hooks/parse.ts 163 lines
1import 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}
163
types/index.d.ts 35 lines
1export 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