SLOPSHOPPER

next-steps

Suggests next steps after every prompt and keeps them in a to-do list by category, in a panel you can click (/todo) or drive with commands.

newpaneguardcommandstatusprompt
v0.3.0no licenseupdated 2026-10-09josh99smith/claude-mods/plugins/next-steps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-steps
│ ┃ Next steps ✕ › fix the failing auth test and add an audit log call │ ┃ 0 open [ Tracking on ] │ ┃ Nothing yet. Steps Claude suggests after ⏺ Read(src/auth.ts) │ ┃ each prompt land here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Category : General ▾ ⎿ Added 2 lines, removed 1 line │ ┃ + : Add a step to General (or "Bugs: fix it" ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Show done ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ ⎿ next-steps: Next-steps panel opened (terminal). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ next-steps: todo: on · 0 open

Draws

Pane · Next steps
0 open [ Tracking on ] Nothing yet. Steps Claude suggests after each prompt land here. Category : General ▾ + : Add a step to General (or "Bugs: fix it") ⏎ add [ Show done ]
README

claude-mods

Claude Code mods for every project.

next-steps

After every prompt, Claude ends its reply with suggested next steps, grouped by category, and adds them to a to-do list for the project.

  • /todo opens a clickable panel (terminal, desktop app, VS Code): tick, remove and add steps, switch tracking, show or clear finished ones.
  • /todo on / /todo off switch tracking; #notodo or #todo in a prompt skips or forces one prompt.
  • /todo list, all, add <Category>: <text>, done <id>, undo <id>, clear [all].
  • A board in the Claude app: put .claude/next-steps.json in a project, { "board": "<claude.ai artifact url>" }, naming a board page whose database collection items holds the list. Then that project's list lives on the board, shared by every session, and /todo links to it.

Without a board the list is kept per project on the machine running the session.

It ticks itself. Claude marks an item done the moment it finishes it, mid-turn, not only at the end. And when Claude works through its own task list (TaskCreate/TaskUpdate, or TodoWrite), completing a task ticks the open item it stands for: one whose #id the task names, or with the same words. On a board, Claude is told to tick it there.

A cloud session's list lives in its container, so it never reaches your computer's list: give the project a board (above) to have cloud and local sessions share one list.

Install

On your computer, for every project (this repo is private, so the machine needs GitHub access to it: gh auth login or an SSH key):

claude plugin marketplace add josh99smith/claude-mods
claude plugin install next-steps@claude-mods --scope user

or inside Claude Code: /plugin install next-steps --marketplace josh99smith/claude-mods, then pick user scope.

Getting updates on your computer

An installed plugin runs the copy made at install time; a new version reaches it only through an update. Each change to the mod bumps version in plugins/next-steps/.claude-plugin/plugin.json. To take it:

claude plugin marketplace update claude-mods
claude plugin update next-steps@claude-mods

then /reload-plugins in an open session (or start a new one). To have it happen by itself, turn on auto-update for the marketplace: /plugin → Marketplaces → claude-mods → Enable auto-update (third-party marketplaces start with it off); Claude Code then pulls new versions when it starts.

Every cloud session

Cloud containers skip plugin marketplaces (SKIP_PLUGIN_MARKETPLACE) and adopt a plugin folder only from ~/.claude/skills/<name>, so install it in the cloud environment's setup script (environment menu in the session's title bar → Edit → Setup script), which runs before Claude Code starts:

git clone --depth 1 https://github.com/josh99smith/claude-mods /tmp/claude-mods \
  && mkdir -p ~/.claude/skills && cp -r /tmp/claude-mods/plugins/next-steps ~/.claude/skills/ || true

This repo is private: the clone works only where the session can reach it. If the setup log shows it refused, add claude-mods to the session or make the repo public.

One project, on a computer

Add to its .claude/settings.json (a local Claude Code installs it from the marketplace):

"extraKnownMarketplaces": { "claude-mods": { "source": { "source": "github", "repo": "josh99smith/claude-mods" } } },
"enabledPlugins": { "next-steps@claude-mods": true }

Develop

claude plugin test plugins/next-steps runs its tests; claude plugin validate . checks the marketplace and manifests.

Source 2 files
hooks/register.tsx 500 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, List } from '../types'
5
6// One to-do list per project (keyed by the session's directory), kept in the
7// plugin's store so it outlives the session; the on/off switch is global. The
8// session's copy lives in $.state so the panel redraws whenever it changes.
9
10const TOOL = 'mcp__next-steps__update'
11const PANE = 'next-steps'
12const SKIP = /(^|\s)#notodo\b/i
13const FORCE = /(^|\s)#todo\b/i
14const EMPTY: List = { next: 1, items: [] }
15
16const listAtom = atom({ plugin: 'next-steps', key: 'list' } as const, EMPTY)
17const onAtom = atom({ plugin: 'next-steps', key: 'isOn' } as const, true)
18const showDoneAtom = atom({ plugin: 'next-steps', key: 'showDone' } as const, false)
19const categoryAtom = atom({ plugin: 'next-steps', key: 'category' } as const, 'General')
20/** Claude's own tasks this session (TaskCreate), id → title, so a TaskUpdate that finishes one can be matched. */
21const tasksAtom = atom({ plugin: 'next-steps', key: 'tasks' } as const, {} as Record<string, string>)
22
23const listKey = (cwd: string) => `list:${cwd}`
24const BOARD_FILE = '.claude/next-steps.json'
25
26/**
27 * The project's board, a claude.ai artifact page that holds the list where the
28 * Claude app can show it, named in .claude/next-steps.json ({ "board": url }) so
29 * every session of the project (a fresh cloud container too) finds it. With a
30 * board the list lives there and Claude keeps it through ArtifactData; this
31 * plugin's own store is the list only where there is none.
32 */
33async function boardUrl($: EngineInterface): Promise<string | undefined> {
34  try {
35    const raw = await $.fs.read(`${await $.session.cwd()}/${BOARD_FILE}`)
36    const url = (JSON.parse(String(raw)) as { board?: unknown }).board
37    return typeof url === 'string' && url.startsWith('https://claude.ai/') ? url : undefined
38  } catch {
39    return undefined
40  }
41}
42
43function boardInstruction(url: string): string {
44  return [
45    'The next-steps tracker is on for this prompt. The to-do list lives on a board page,',
46    `${url}, in its database collection \`items\` (doc id = the item's id as a string; fields`,
47    'id (number), category, text, done (boolean), at (ISO time)). When you have finished the request:',
48    '1. Read the board: ArtifactData list on `items` (load ArtifactData with ToolSearch if needed).',
49    '   Items the user ticked there are done; never reopen them.',
50    '   As you finish an open item, even mid-turn, update it {done: true} on the board right away.',
51    '2. Suggest the next steps: end your reply with a short "Suggested next steps" section, one',
52    '   line each, grouped under a short category (reuse the board\'s categories where they fit: e.g.',
53    '   Bugs, Features, Tests, Docs, Refactor, Ops, Questions). Concrete steps the user or a later',
54    '   session could take, not restatements of what was done. Skip steps already open on the board.',
55    '3. One ArtifactData batch: `set` each suggestion as a new doc (ids continue from the highest),',
56    '   and `update` {done: true} (with its if_version) on any open item this work finished.',
57    '   Say nothing about these calls; the board updates by itself.',
58    'If nothing remains and nothing was finished, skip 2 and 3.',
59  ].join('\n')
60}
61
62async function fromStore($: EngineInterface): Promise<List> {
63  const raw = (await $.store.get(listKey(await $.session.cwd()))) as List | undefined
64  return raw && Array.isArray(raw.items) ? raw : EMPTY
65}
66
67async function isOn($: EngineInterface): Promise<boolean> {
68  return (await $.store.get('enabled')) !== false
69}
70
71async function showStatus($: EngineInterface, list: List, on: boolean): Promise<void> {
72  const open = list.items.filter(i => !i.done).length
73  $.ui.status(`todo: ${on ? 'on' : 'off'} · ${open} open`)
74}
75
76/** Changes the list (a copy), then writes the store, the panel's state and the status line. */
77async function edit($: EngineInterface, change: (list: List) => void): Promise<List> {
78  const list = JSON.parse(JSON.stringify(await fromStore($))) as List
79  change(list)
80  // Keep the store small: finished items beyond the newest 200 are dropped.
81  const done = list.items.filter(i => i.done)
82  if (done.length > 200) {
83    const drop = new Set(done.slice(0, done.length - 200).map(i => i.id))
84    list.items = list.items.filter(i => !drop.has(i.id))
85  }
86  await $.store.set(listKey(await $.session.cwd()), list)
87  await update($, listAtom, () => list)
88  await showStatus($, list, await isOn($))
89  return list
90}
91
92async function setOn($: EngineInterface, on: boolean): Promise<void> {
93  await $.store.set('enabled', on)
94  await update($, onAtom, () => on)
95  await showStatus($, await fromStore($), on)
96}
97
98function addItem(list: List, category: string, text: string, at: string): number | undefined {
99  const clean = text.trim()
100  if (!clean) return undefined
101  if (list.items.some(i => !i.done && i.text.toLowerCase() === clean.toLowerCase())) return undefined
102  list.items.push({ id: list.next, category: category.trim() || 'General', text: clean, done: false, at })
103  return list.next++
104}
105
106/** "Bugs: fix it" → category and text; plain text takes the fallback category. */
107function parseEntry(entry: string, fallback: string): { category: string; text: string } {
108  const m = /^([^:]{1,40}):\s*(.+)$/.exec(entry.trim())
109  return m ? { category: m[1]?.trim() ?? fallback, text: m[2]?.trim() ?? '' } : { category: fallback, text: entry.trim() }
110}
111
112function grouped(items: Item[]): [string, Item[]][] {
113  const groups = new Map<string, Item[]>()
114  for (const item of items) groups.set(item.category, [...(groups.get(item.category) ?? []), item])
115  return [...groups].sort(([a], [b]) => a.localeCompare(b))
116}
117
118/** The list grouped by category, as Markdown. */
119export function render(list: List, withDone = false): string {
120  const shown = list.items.filter(i => withDone || !i.done)
121  if (shown.length === 0) return 'No next steps recorded for this project.'
122  const out: string[] = []
123  for (const [category, items] of grouped(shown)) {
124    out.push(`**${category}**`)
125    for (const i of items) out.push(`- [${i.done ? 'x' : ' '}] #${i.id} ${i.text}`)
126    out.push('')
127  }
128  return out.join('\n').trim()
129}
130
131function instruction(list: List): string {
132  const open = list.items.filter(i => !i.done)
133  const current = open.length
134    ? open.slice(-60).map(i => `#${i.id} [${i.category}] ${i.text}`).join('\n')
135    : '(empty)'
136  return [
137    'The next-steps tracker is on for this prompt. When you have finished the request:',
138    '1. Suggest the next steps: end your reply with a short "Suggested next steps" section, one',
139    '   line each, grouped under a short category (reuse an existing one where it fits: e.g. Bugs,',
140    '   Features, Tests, Docs, Refactor, Ops, Questions). Concrete steps the user or a later session',
141    '   could take, not restatements of what was done. Skip steps already open below.',
142    `2. Call ${TOOL} once with exactly those suggestions as \`add\`, and the ids of any open item`,
143    '   this work finished as `done`. Say nothing about the call itself.',
144    'If nothing remains and nothing was finished, skip both.',
145    'Open items now:',
146    current,
147  ].join('\n')
148}
149
150const words = (s: string) =>
151  s.toLowerCase().replace(/#\d+/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim()
152
153/**
154 * The open item a task of Claude's stands for: one whose "#id" its title names,
155 * else one with the same words, or whose words hold the other's whole (the
156 * shorter at least three words, so "fix it" never ticks "fix the kickoff camera").
157 */
158export function matchItem(list: List, title: string): Item | undefined {
159  const open = list.items.filter(i => !i.done)
160  for (const m of title.matchAll(/#(\d+)\b/g)) {
161    const hit = open.find(i => i.id === Number(m[1]))
162    if (hit) return hit
163  }
164  const t = words(title)
165  if (!t) return undefined
166  const exact = open.find(i => words(i.text) === t)
167  if (exact) return exact
168  return open.find(i => {
169    const w = words(i.text)
170    const [short, long] = w.length < t.length ? [w, t] : [t, w]
171    return short.split(' ').length >= 3 && ` ${long} `.includes(` ${short} `)
172  })
173}
174
175/**
176 * Ticks the open items that tasks Claude just completed stand for. Without a
177 * board that is the list itself; with one, the board is Claude's to write, so
178 * Claude is told which finished task to tick there.
179 */
180async function tickFor($: EngineInterface, titles: string[]): Promise<string | undefined> {
181  if (titles.length === 0) return undefined
182  const board = await boardUrl($)
183  if (board) {
184    const names = titles.map(t => `"${t}"`).join(', ')
185    return `next-steps: if ${names} is an open item on the to-do board (${board}), mark it done there now (ArtifactData update {done: true}).`
186  }
187  const list = await fromStore($)
188  const ids = titles.map(t => matchItem(list, t)?.id).filter((n): n is number => n !== undefined)
189  if (ids.length === 0) return undefined
190  await edit($, l => {
191    for (const i of l.items) if (ids.includes(i.id)) i.done = true
192  })
193  return undefined
194}
195
196const now = async ($: EngineInterface) => new Date(await $.clock.now()).toISOString()
197
198export const register: Register = on => {
199  on('session.start', async ($, e, next) => {
200    await $.tool.register({
201      name: 'update',
202      description:
203        "Updates the project's next-steps to-do list: add new items, each with a category, and mark finished ones done by id.",
204      inputSchema: {
205        type: 'object',
206        properties: {
207          add: {
208            type: 'array',
209            items: {
210              type: 'object',
211              properties: {
212                category: { type: 'string', description: 'A short category, e.g. Bugs, Features, Tests, Docs' },
213                text: { type: 'string', description: 'One concrete next step' },
214              },
215              required: ['category', 'text'],
216            },
217          },
218          done: { type: 'array', items: { type: 'integer' }, description: 'Ids of items now finished' },
219        },
220      },
221    })
222    await $.command.register({
223      name: 'todo',
224      description: 'Next-steps panel; or list | all | on | off | done <id> | undo <id> | add <Category>: <text> | clear',
225      argumentHint: '[list|all|on|off|done <id>|undo <id>|add <Category>: <text>|clear [all]]',
226      immediate: true,
227    })
228    const list = await fromStore($)
229    const enabled = await isOn($)
230    await update($, listAtom, () => list)
231    await update($, onAtom, () => enabled)
232    await showStatus($, list, enabled)
233    return next(e)
234  })
235
236  on('prompt.submit', async ($, e, next) => {
237    // Plugins' own prompts and slash commands are left alone.
238    if (e.origin?.kind === 'plugin' || e.text.trimStart().startsWith('/')) return next(e)
239    const skip = SKIP.test(e.text)
240    const force = FORCE.test(e.text)
241    const text = e.text.replace(SKIP, ' ').replace(FORCE, ' ').trim() || e.text
242    const track = !skip && (force || (await isOn($)))
243    if (!track) return next({ ...e, text })
244    const board = await boardUrl($)
245    const note = board ? boardInstruction(board) : instruction(await fromStore($))
246    return next({ ...e, text, context: [...(e.context ?? []), note] })
247  })
248
249  on('tool.call', { tool: TOOL }, async ($, e) => {
250    const input = e as unknown as { add?: { category?: string; text?: string }[]; done?: number[] }
251    const added: number[] = []
252    const finished: number[] = []
253    const at = await now($)
254    await edit($, list => {
255      for (const id of input.done ?? []) {
256        const item = list.items.find(i => i.id === id)
257        if (item && !item.done) {
258          item.done = true
259          finished.push(id)
260        }
261      }
262      for (const a of input.add ?? []) {
263        const id = addItem(list, a.category ?? '', a.text ?? '', at)
264        if (id !== undefined) added.push(id)
265      }
266    })
267    const ids = (ns: number[]) => (ns.length ? ns.map(n => `#${n}`).join(', ') : 'nothing')
268    return { result: `Added ${ids(added)}; marked done ${ids(finished)}.` }
269  })
270
271  // Claude's own task list: completing a task (TaskUpdate, or a todo in
272  // TodoWrite) ticks the open item it stands for, so the checklist follows the work.
273  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
274    const ran = await next(e)
275    const task = ran.deny === undefined && !ran.isError ? (ran.result as { task?: { id?: string } } | undefined)?.task : undefined
276    if (task?.id) await update($, tasksAtom, t => ({ ...t, [task.id as string]: e.subject }))
277    return ran
278  })
279
280  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
281    const ran = await next(e)
282    if (ran.deny !== undefined || ran.isError) return ran
283    const tasks = await read($, tasksAtom)
284    const title = e.subject ?? tasks[e.taskId]
285    if (e.subject) await update($, tasksAtom, t => ({ ...t, [e.taskId]: e.subject as string }))
286    if (e.status !== 'completed' || !title) return ran
287    const note = await tickFor($, [title])
288    return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
289  })
290
291  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
292    const ran = await next(e)
293    if (ran.deny !== undefined || ran.isError) return ran
294    const was = new Set(
295      ((ran.result as { oldTodos?: { content: string; status: string }[] } | undefined)?.oldTodos ?? [])
296        .filter(t => t.status === 'completed')
297        .map(t => t.content),
298    )
299    const titles = e.todos.filter(t => t.status === 'completed' && !was.has(t.content)).map(t => t.content)
300    const note = await tickFor($, titles)
301    return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
302  })
303
304  on('command.run', { command: 'todo' }, async ($, e) => {
305    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
306    const arg = e.args.trim().slice(verb.length).trim()
307    const board = await boardUrl($)
308    if (board && !['on', 'off'].includes(verb.toLowerCase())) {
309      const state = (await isOn($)) ? 'on' : 'off'
310      return { text: `Your to-do list is on its board: ${board}\n\nOpen it to tick, add or remove steps. Tracking is ${state} (/todo on, /todo off).` }
311    }
312    switch (verb.toLowerCase()) {
313      case '': {
314        // A cloud session followed from the Claude app has no screen of its own
315        // that draws a mod's panel (no surface attached, or only a phone's), yet
316        // the open still reports placed: print the list there instead.
317        const surfaces = await $.session.surfaces()
318        const canDraw = surfaces.some(s => s === 'terminal' || s === 'desktop' || s === 'vscode')
319        const opened = canDraw ? await $.ui.open({ id: PANE, title: 'Next steps' }) : undefined
320        if (opened?.isPlaced) return { text: `Next-steps panel opened (${surfaces.join(', ')}).` }
321        const where = canDraw
322          ? 'The panel needs a wider window.'
323          : 'This screen does not draw panels (it works in Claude Code in a terminal, the desktop app or VS Code).'
324        return { text: `${render(await fromStore($))}\n\n_${where} /todo done <id>, add <Category>: <text>, on, off._` }
325      }
326      case 'on':
327      case 'off':
328        await setOn($, verb.toLowerCase() === 'on')
329        return { text: `Next-steps tracking is ${verb.toLowerCase()}. (#todo or #notodo in a prompt overrides it for that prompt.)` }
330      case 'done':
331      case 'undo': {
332        const ids = rest.map(s => Number(s.replace('#', ''))).filter(Number.isFinite)
333        const isDone = verb.toLowerCase() === 'done'
334        let hit: number[] = []
335        await edit($, list => {
336          const items = list.items.filter(i => ids.includes(i.id))
337          for (const i of items) i.done = isDone
338          hit = items.map(i => i.id)
339        })
340        return { text: hit.length ? `${isDone ? 'Done' : 'Reopened'}: ${hit.map(n => `#${n}`).join(', ')}` : 'No item with that id.' }
341      }
342      case 'add': {
343        const { category, text } = parseEntry(arg, 'General')
344        if (!text) return { text: 'Usage: /todo add <Category>: <text>' }
345        const at = await now($)
346        let id: number | undefined
347        await edit($, list => {
348          id = addItem(list, category, text, at)
349        })
350        return { text: id === undefined ? 'That step is already open.' : `Added #${id} to ${category}.` }
351      }
352      case 'clear': {
353        const all = arg.toLowerCase() === 'all'
354        await edit($, list => {
355          list.items = all ? [] : list.items.filter(i => !i.done)
356        })
357        return { text: all ? 'Cleared every item.' : 'Cleared finished items.' }
358      }
359      case 'all':
360        return { text: render(await fromStore($), true) }
361      default: {
362        const state = (await isOn($)) ? 'on' : 'off'
363        return { text: `${render(await fromStore($))}\n\n_Tracking is ${state}: /todo on, /todo off._` }
364      }
365    }
366  })
367
368  // The panel: the list by category, each step a checkbox to tick and an x to
369  // remove, a field to add one, and the tracking switch. Every control writes
370  // through edit(), so the panel, the store and the status line stay together.
371  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
372    const { Box, Text, Button } = $.ui.resolve(e)
373    const list = await read($, listAtom)
374    const tracking = await read($, onAtom)
375    const showDone = await read($, showDoneAtom)
376    const category = await read($, categoryAtom)
377    const open = list.items.filter(i => !i.done)
378    const doneCount = list.items.length - open.length
379    const shown = list.items.filter(i => showDone || !i.done)
380    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 60
381    const textWidth = Math.max(12, width - 12)
382    const clip = (s: string) => (s.length > textWidth * 3 ? `${s.slice(0, textWidth * 3 - 1)}…` : s)
383    const categories = [...new Set(['General', ...list.items.map(i => i.category)])].sort()
384
385    const toggle = (item: Item) => () =>
386      edit($, l => {
387        const it = l.items.find(i => i.id === item.id)
388        if (it) it.done = !it.done
389      })
390    const remove = (item: Item) => () =>
391      edit($, l => {
392        l.items = l.items.filter(i => i.id !== item.id)
393      })
394    const add = async (value: string) => {
395      const { category: c, text } = parseEntry(value, category)
396      const at = await now($)
397      await edit($, l => void addItem(l, c, text, at))
398      await update($, categoryAtom, () => c)
399    }
400
401    const header = (
402      <Box flexDirection="row" justifyContent="space-between">
403        <Text bold>
404          {open.length} open{doneCount ? `  ·  ${doneCount} done` : ''}
405        </Text>
406        <Box flexDirection="row" gap={1}>
407          <Button
408            key="tracking"
409            hotkey="t"
410            variant={tracking ? 'primary' : 'secondary'}
411            label={tracking ? 'Tracking on' : 'Tracking off'}
412            onPress={() => setOn($, !tracking)}
413          />
414        </Box>
415      </Box>
416    )
417
418    const rows =
419      shown.length === 0 ? (
420        <Text dimColor>
421          {list.items.length === 0
422            ? 'Nothing yet. Steps Claude suggests after each prompt land here.'
423            : 'Everything is done.'}
424        </Text>
425      ) : (
426        <Box flexDirection="column">
427          {grouped(shown).map(([name, items]) => (
428            <Box key={`cat-${name}`} flexDirection="column" marginTop={1}>
429              <Text bold color="cyan">
430                {name} <Text dimColor>({items.filter(i => !i.done).length})</Text>
431              </Text>
432              {items.map(item => (
433                <Box key={`row-${item.id}`} flexDirection="row" gap={1}>
434                  <Button key={`done-${item.id}`} plain label={item.done ? '☑' : '☐'} onPress={toggle(item)} />
435                  <Box flexGrow={1}>
436                    <Text dimColor={item.done} strikethrough={item.done} wrap="wrap">
437                      {clip(item.text)}
438                    </Text>
439                  </Box>
440                  <Text dimColor>#{item.id}</Text>
441                  <Button key={`del-${item.id}`} plain dimColor label="✕" onPress={remove(item)} />
442                </Box>
443              ))}
444            </Box>
445          ))}
446        </Box>
447      )
448
449    const footer = (
450      <Box flexDirection="row" gap={1} marginTop={1}>
451        <Button
452          key="show-done"
453          hotkey="d"
454          label={showDone ? 'Hide done' : 'Show done'}
455          onPress={() => update($, showDoneAtom, v => !v)}
456        />
457        {doneCount > 0 && (
458          <Button key="clear" hotkey="c" label="Clear done" onPress={() => edit($, l => void (l.items = l.items.filter(i => !i.done)))} />
459        )}
460      </Box>
461    )
462
463    if (e.surface === 'mobile') {
464      return (
465        <Box flexDirection="column">
466          {header}
467          {rows}
468          {footer}
469        </Box>
470      )
471    }
472
473    const { Input, Select } = $.ui.resolve(e)
474    return (
475      <Box flexDirection="column">
476        {header}
477        {rows}
478        <Box flexDirection="column" marginTop={1}>
479          <Select
480            key="category"
481            label="Category "
482            value={category}
483            options={categories.map(c => ({ value: c, label: c }))}
484            onSelect={v => update($, categoryAtom, () => v)}
485          />
486          <Input
487            key="new"
488            label="+ "
489            placeholder={`Add a step to ${category} (or "Bugs: fix it")`}
490            value=""
491            submitLabel="add"
492            onSubmit={value => void add(value)}
493          />
494        </Box>
495        {footer}
496      </Box>
497    )
498  })
499}
500
types/index.d.ts 9 lines
1export type Item = { id: number; category: string; text: string; done: boolean; at: string }
2export type List = { next: number; items: Item[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'next-steps': { list: List; isOn: boolean; showDone: boolean; category: string; tasks: Record<string, string> }
7  }
8}
9