SLOPSHOPPER

subtask-icons

A single ⑂ Subtask button under Claude's last answer opens a picker of its items; picking one puts /subtask <item> in the prompt box

newpanebandrowscommandtoast
★ 5v0.3.7MITupdated 2026-10-05mutlumehmet/claude-plugins/plugins/subtask-icons
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subtask-icons
│ ┃ st-picker ✕ › fix the failing auth test and add an audit log call │ ┃ Pick an item, or type its number. Esc │ ┃ closes. ⏺ 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 │ │ › /st │ ⎿ subtask-icons: The last answer has no items. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · st-picker
Pick an item, or type its number. Esc closes.
README

subtask-icons

A Claude Code mod that puts one small ⑂ Subtask button under Claude's last answer. It opens a picker of the answer's items; a pick puts /subtask <the item's text> in the prompt box and sends nothing, so you can add your own words and press Enter. Part of claude-plugins.

What it does

  • One button: a quiet ⑂ Subtask (N) under Claude's last answer, where N is the number of items (numbered items and top level bullets, at most 12; code blocks are skipped).
  • The picker: pressing it opens a small pane with every item's full first line. Click one or press its number; /subtask <item> goes into the prompt box and nothing is sent, so you can add your own words and press Enter. Esc closes it.
  • Several items into one subtask: if the prompt box already holds a /subtask, the next pick is added on a new line.
  • From the keyboard: /st opens the same picker; /st 3 puts item 3 in the prompt box directly.
  • Pinned list: the Pin list button beside Subtask (or /st pin for the newest list) pins that answer's items in the band above the prompt, so the list stays in view however the conversation scrolls. Nothing pins by itself: the pin stays on that list through later answers until you unpin it or pin another one. Items you then send with /subtask get a ⑂; the first 5 show, then a +N more line. Unpin list, /st unpin or the Unpin button clears it. The list sits at the right end of the band, beside the other band mods (an Arcade game, the account line), and every item is a button: a press puts /subtask <item> in the prompt box.
  • Pin an earlier list: /st pins shows the last few answers that had a list, newest first, and /st pin N pins list N, so a list from a few answers back can still be pinned.

What it reads and does: Claude's last answer (to find the items), the prompt box (to add a pick), and the text of each /subtask you run (to mark pinned items as sent). It watches /subtask and always passes it on unchanged, so every /subtask runs as usual. It sends nothing anywhere.

Nothing is drawn in the VS Code chat panel; use /st there.

What it reads and does

  • Reads: the text of the last answers, to find their list items, and the prompt box, to add an item to it.
  • Keeps: the last 10 answers that had a list, and the pin, in Claude Code's session state (in memory, this session only), so a hot reload keeps them.
  • Does: draws the Subtask and Pin list buttons, the item picker and the pinned list above the prompt; fills the prompt box and never sends it.
  • Hooks: turn.start and turn.complete track the latest answer. command.run answers /st (including /st pin, /st pin N, /st pins and /st unpin); /subtask is only watched, to mark pinned items as sent, and always passes on unchanged. ui.render adds its rows and leaves the answer unchanged.
  • Privacy: see PRIVACY.md.
  • Never: sends anything anywhere (no network calls, no telemetry).

Install

Needs Claude Code 2.1.287 or later (mods). Tested on 2.1.289.

/plugin marketplace add mutlumehmet/claude-plugins
/plugin install subtask-icons@mehmetmutlu

A mod runs inside Claude Code with your permissions. Read the code before you install any mod.

Known gaps

  • Plain fact bullets count as items too, so the picker can list a few lines that are not tasks; the 12 item cap keeps it short.
  • After editing the mod while a session is open, restart the session: a hot reload has dropped the /st command once.
  • An item counts as sent when the /subtask text holds its whole first line; an edited pick is not marked. A new session starts with no pin.

About

Mehmet Mutlu: work that's shipped, at mehmetmutlu.dev

Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.

License

MIT

Source 2 files
hooks/register.tsx 435 lines
1// subtask-icons: one small "⑂ Subtask" button under Claude's last answer. A press
2// opens a picker of the answer's items; picking one puts "/subtask <the item's text>" in the prompt box and sends nothing, so
3// you can add your own words ("let's do it", "but make it interactive") before
4// Enter. A second press while the box already holds a /subtask adds that item
5// on a new line, so several items go to one subtask.
6// From the keyboard: /st opens a picker pane with every item in full (a press
7// or the item's number fills it), /st 3 fills the third directly.
8// A "Pin list" button beside it pins that answer's items in the band above the prompt,
9// so the list stays in view however the conversation scrolls, until you unpin it or
10// pin another answer; nothing pins by itself. /st pin pins the newest list from the
11// keyboard. Items later sent with /subtask get a ⑂. /st unpin or the
12// Unpin button clears it. The pin and the recent lists live in the host's state, so a
13// later answer or a hot reload does not move or lose it.
14
15import { atom, read, update } from 'claude-code'
16import type { Hook, Register } from 'claude-code'
17
18import type { Pinned } from '../types'
19
20// The mods API a hook receives as $
21type Api = Parameters<Hook<'command.run'>>[0]
22
23const MAX_ITEMS = 12
24const LABEL_CHARS = 28
25const PICKER = 'st-picker'
26// Rows the pinned list shows before a "+N more" line
27const PIN_ROWS = 5
28// How many answers with a list stay pin candidates
29const KEEP_ANSWERS = 10
30
31const answers = atom({ plugin: 'subtask-icons', key: 'answers' } as const, [] as string[])
32const pinnedAtom = atom({ plugin: 'subtask-icons', key: 'pinned' } as const, null as Pinned | null)
33
34// label: the short icon text; title: the item's whole first line, for the picker
35export type Unit = { label: string; title: string; text: string }
36
37// Line shapes
38const FENCE = /^\s*(```|~~~)/
39const HEADING = /^#{1,6}\s/
40const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
41const BOLD_LINE = /^\s{0,3}\*\*[^*].*\*\*:?\s*$/
42const NUMBERED_BOLD = /^\s{0,3}\*\*\d+[.)]\s+.+\*\*:?\s*$/
43const NUMBERED_HEADING = /^#{1,6}\s+\d+[.)]\s+/
44const NUMBERED_ITEM = /^\s{0,3}\d+[.)]\s+\S/
45const BULLET_ITEM = /^\s{0,1}[-*+]\s+\S/
46const INDENTED = /^\s{2,}\S/
47
48// Splits an answer into the items that get an icon: numbered items (a
49// "1. ..." list item, a "**1. ...**" line or a "## 1. ..." heading, each with
50// what follows it), then top-level bullets that sit outside a numbered item.
51export function findUnits(answer: string): Unit[] {
52  const lines = answer.split('\n')
53  const at = (k: number) => lines[k] ?? ''
54
55  // Lines inside a code fence never start an item
56  const inFence: boolean[] = []
57  let fenced = false
58  for (const line of lines) {
59    if (FENCE.test(line)) {
60      inFence.push(true)
61      fenced = !fenced
62    } else inFence.push(fenced)
63  }
64
65  type Span = { start: number; end: number; numbered: boolean }
66  const spans: Span[] = []
67
68  for (let i = 0; i < lines.length; i++) {
69    if (inFence[i]) continue
70    const line = at(i)
71    const isSection = NUMBERED_BOLD.test(line) || NUMBERED_HEADING.test(line)
72    const isNumbered = NUMBERED_ITEM.test(line)
73    const isBullet = BULLET_ITEM.test(line) && !RULE.test(line)
74    if (!isSection && !isNumbered && !isBullet) continue
75
76    let end = i + 1
77    while (end < lines.length) {
78      const next = at(end)
79      if (!inFence[end]) {
80        if (HEADING.test(next) || BOLD_LINE.test(next) || RULE.test(next)) break
81        if (isSection) {
82          // A section runs to the next section, heading or bold line
83          end++
84          continue
85        }
86        if (NUMBERED_ITEM.test(next) || BULLET_ITEM.test(next)) break
87        if (next.trim() === '') {
88          // A blank line ends a list item unless indented text follows it
89          const after = lines.slice(end + 1).find((l) => l.trim() !== '')
90          if (after === undefined || !INDENTED.test(after)) break
91        } else if (!INDENTED.test(next)) break
92      }
93      end++
94    }
95    spans.push({ start: i, end, numbered: isSection || isNumbered })
96    if (isSection) i = end - 1
97  }
98
99  // Bullets inside a numbered item belong to it
100  const numbered = spans.filter((s) => s.numbered)
101  const kept = spans.filter(
102    (s) => s.numbered || !numbered.some((n) => s.start > n.start && s.start < n.end),
103  )
104  // A numbered list item inside a numbered section belongs to the section
105  const sections = kept.filter((s) => NUMBERED_BOLD.test(at(s.start)) || NUMBERED_HEADING.test(at(s.start)))
106  const units = kept.filter((s) => !sections.some((n) => s !== n && s.start > n.start && s.start < n.end))
107
108  return units.slice(0, MAX_ITEMS).map((s) => {
109    const text = lines
110      .slice(s.start, s.end)
111      .join('\n')
112      .replace(/\*\*/g, '')
113      .trim()
114      // A bullet's marker adds nothing to the subtask; a number does
115      .replace(/^[-*+]\s+/, '')
116    return { label: labelOf(at(s.start)), title: titleOf(at(s.start)), text }
117  })
118}
119
120// The whole first line without its marker, bold or backticks
121export function titleOf(line: string): string {
122  return line
123    .replace(/^#{1,6}\s+/, '')
124    .replace(/\*\*/g, '')
125    .replace(/`/g, '')
126    .trim()
127    .replace(/^(\d+[.)]|[-*+])\s+/, '')
128    .replace(/:\s*$/, '')
129    .trim()
130}
131
132function fit(s: string, width: number): string {
133  return s.length > width ? s.slice(0, Math.max(1, width - 1)).trimEnd() + '…' : s
134}
135
136// The first line without its marker, bold, backticks or trailing colon, cut short
137export function labelOf(line: string): string {
138  const clean = line
139    .replace(/^#{1,6}\s+/, '')
140    .replace(/\*\*/g, '')
141    .replace(/`/g, '')
142    .trim()
143    .replace(/^(\d+[.)]|[-*+])\s+/, '')
144    .replace(/:\s*$/, '')
145    .split(/:\s/)[0]
146    ?.trim() ?? ''
147  return clean.length > LABEL_CHARS ? clean.slice(0, LABEL_CHARS - 1).trimEnd() + '…' : clean
148}
149
150// What the prompt box holds after a press: a new /subtask, one more item on a
151// /subtask already there, or a /subtask in front of what was typed
152export function composeDraft(draft: string, item: string): string {
153  if (draft.trim() === '') return '/subtask ' + item + ' '
154  if (/^\/subtask\b/.test(draft.trimStart())) return draft.trimEnd() + '\n' + item + ' '
155  return '/subtask ' + item + '\n' + draft.trim() + ' '
156}
157
158// True for the text block that ends the last answer
159export function isLastBlock(blockText: string, answer: string): boolean {
160  const block = blockText.trim()
161  return block.length > 0 && answer.trim().endsWith(block)
162}
163
164// Module variables: a hot reload clears them, which only hides the button until
165// the next answer; the pin and its candidates are in the host's state
166let lastAnswer = ''
167let isWorking = false
168
169// The items a /subtask text names: an item counts as sent when the text holds its title
170export function sentItems(units: Unit[], text: string): number[] {
171  const body = text.toLowerCase()
172  return units.flatMap((u, i) => (u.title && body.includes(u.title.toLowerCase()) ? [i] : []))
173}
174
175// Which answer a /subtask was taken from: the newest one whose items the text
176// names, else the newest one that has a list. Candidates run oldest to newest.
177export function pickAnswer(candidates: readonly string[], text: string): string | null {
178  for (let k = candidates.length - 1; k >= 0; k--) {
179    const answer = candidates[k] ?? ''
180    if (sentItems(findUnits(answer), text).length > 0) return answer
181  }
182  return candidates[candidates.length - 1] ?? null
183}
184
185// Adds an answer with a list to the candidates, newest last, keeping the last few
186export function remember(candidates: readonly string[], answer: string): string[] {
187  if (findUnits(answer).length === 0) return [...candidates]
188  return [...candidates.filter((a) => a !== answer), answer].slice(-KEEP_ANSWERS)
189}
190
191// The remembered answers with a list, newest first
192export function recentLists(candidates: readonly string[]): string[] {
193  return [...candidates].reverse().filter((a) => findUnits(a).length > 0)
194}
195
196// One line naming a list: its first item and how many there are
197export function listLabel(answer: string): string {
198  const units = findUnits(answer)
199  return (units[0]?.label ?? '') + ' (' + units.length + ' items)'
200}
201
202// The newest answer with a list: the last one, or an earlier one when the last had none
203async function listAnswer($: Api): Promise<string> {
204  if (findUnits(lastAnswer).length > 0) return lastAnswer
205  const all = await read($, answers)
206  return all[all.length - 1] ?? ''
207}
208
209// Pins this answer's items; pinning the answer already pinned keeps its marks
210async function pinAnswer($: Api, answer: string): Promise<boolean> {
211  if (findUnits(answer).length === 0) return false
212  await update($, pinnedAtom, (old) => (old && old.answer === answer ? old : { answer, sent: [] }))
213  $.ui.invalidate('ui.render')
214  return true
215}
216
217// Marks the items a /subtask names on the pinned list; the pin itself never moves here
218async function markSent($: Api, args: string) {
219  const pinned = await read($, pinnedAtom)
220  if (!pinned) return
221  const hits = sentItems(findUnits(pinned.answer), args)
222  if (hits.length === 0) return
223  await update($, pinnedAtom, (old) => {
224    if (!old) return old
225    const sent = new Set([...old.sent, ...hits])
226    return { ...old, sent: [...sent].sort((a, b) => a - b) }
227  })
228  $.ui.invalidate('ui.render')
229}
230
231async function unpin($: Api) {
232  await update($, pinnedAtom, () => null)
233  $.ui.invalidate('ui.render')
234}
235// The items the open picker shows, fixed when /st opened it
236let pickerUnits: Unit[] = []
237
238async function fillItem($: Api, item: string) {
239  const box = await $.prompt.read()
240  const done = await $.prompt.fill({ text: composeDraft(box.text, item), mode: 'replace' })
241  if (!done.isFilled) $.ui.toast('⑂ Could not fill the prompt box (a dialog may be open)')
242}
243
244// Opens the Subtask picker with these items; true when the pane is drawn
245async function openPicker($: Api, units: Unit[]): Promise<boolean> {
246  pickerUnits = units
247  const opened = await $.ui.open({
248    id: PICKER,
249    title: 'Subtask',
250    focus: true,
251    closeOnEscape: true,
252    rows: Math.min(units.length + 2, 16),
253  })
254  return opened.isPlaced
255}
256
257async function pick($: Api, item: string) {
258  await $.ui.close({ id: PICKER })
259  await fillItem($, item)
260}
261
262export const register: Register = (on) => {
263  on('session.start', async ($, e, next) => {
264    await $.command.register({
265      name: 'st',
266      description: 'Pick an item of the last answer for /subtask, or put item N in the prompt box',
267      argumentHint: '[N | pin | pin N | pins | unpin]',
268    })
269    return next(e)
270  })
271
272  on('turn.start', async ($, e, next) => {
273    isWorking = true
274    $.ui.invalidate('ui.render')
275    return next(e)
276  })
277
278  on('turn.complete', async ($, e, next) => {
279    // Subagent turns end inside the main one; only the main answer counts
280    if (!e.agentId) {
281      isWorking = false
282      lastAnswer = e.reason === 'answer' ? e.answer : ''
283      if (findUnits(lastAnswer).length > 0) {
284        const answer = lastAnswer
285        await update($, answers, (old) => remember(old, answer))
286      }
287      $.ui.invalidate('ui.render')
288    }
289    return next(e)
290  })
291
292  // /subtask belongs to Claude Code; the mod only watches it to mark pinned items sent
293  // and always passes it on unchanged (unpinning is /st unpin)
294  on('command.run', { command: 'subtask' }, async ($, e, next) => {
295    await markSent($, String(e.args ?? '').trim())
296    return next(e)
297  })
298
299  on('command.run', { command: 'st' }, async ($, e) => {
300    const arg = String(e.args ?? '').trim()
301    if (arg === 'unpin') {
302      await unpin($)
303      return { text: 'Unpinned the list.' }
304    }
305    if (arg === 'pin') {
306      return { text: (await pinAnswer($, await listAnswer($))) ? 'Pinned the list.' : 'No answer with a list to pin.' }
307    }
308    // An earlier answer's list: /st pins shows the recent lists, /st pin N pins one of them
309    if (arg === 'pins') {
310      const lists = recentLists(await read($, answers))
311      if (lists.length === 0) return { text: 'No recent answer with a list.' }
312      return {
313        text:
314          lists.map((a, i) => i + 1 + '. ' + listLabel(a)).join('\n') +
315          '\n\n/st pin N pins list N (1 is the newest).',
316      }
317    }
318    const pinN = /^pin\s+(\d+)$/.exec(arg)
319    if (pinN) {
320      const lists = recentLists(await read($, answers))
321      const n = Number(pinN[1])
322      const answer = lists[n - 1]
323      if (!answer) return { text: 'No list ' + n + '. There are ' + lists.length + ' recent lists (/st pins shows them).' }
324      await pinAnswer($, answer)
325      return { text: 'Pinned list ' + n + ': ' + listLabel(answer) }
326    }
327    const units = findUnits(await listAnswer($))
328    if (units.length === 0) return { text: 'The last answer has no items.' }
329    const n = Number.parseInt(String(e.args ?? '').trim(), 10)
330    if (!Number.isInteger(n)) {
331      if (await openPicker($, units)) return {}
332      // Where no pane can be drawn, the list in full as text
333      return { text: units.map((u, i) => i + 1 + '. ' + u.title).join('\n') + '\n\n/st N puts item N in the prompt box.' }
334    }
335    if (n < 1 || n > units.length) return { text: 'No item ' + n + '. The last answer has ' + units.length + '.' }
336    // Wait for the box to clear of the "/st N" just sent before filling it
337    const item = units[n - 1]?.text ?? ''
338    $.clock.after(150, () => fillItem($, item))
339    return {}
340  })
341
342  on('ui.render', { component: 'Pane', requestId: 'st-picker' }, async ($, e) => {
343    const { Box, Button, Text } = $.ui.resolve(e)
344    const width = Math.max(10, e.props.bodyColumns - 2)
345    return (
346      <Box flexDirection="column">
347        <Text dimColor>{fit('Pick an item, or type its number. Esc closes.', width)}</Text>
348        {pickerUnits.map((u, i) => (
349          <Button
350            key={'pick-' + (i + 1)}
351            plain
352            {...(i < 9 ? { hotkey: String(i + 1) } : {})}
353            {...(i === 0 ? { autoFocus: true as const } : {})}
354            onPress={() => pick($, u.text)}
355          >
356            {fit(i + 1 + '. ' + u.title, width)}
357          </Button>
358        ))}
359      </Box>
360    )
361  })
362
363  // The pinned list sits on top of the band, above whatever else is there
364  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
365    const below = await next(e)
366    const pinned = await read($, pinnedAtom)
367    if (!pinned || e.props.hasSurvey) return below
368    const units = findUnits(pinned.answer)
369    if (units.length === 0) return below
370    const { Box, Button, Text } = $.ui.resolve(e)
371    // A column at the right end of the band, like the Arcade games, so the rest of the band keeps its place.
372    const columns = e.props.bodyColumns ?? 80
373    const width = Math.max(24, Math.min(44, Math.floor(columns / 3)))
374    const shown = units.slice(0, PIN_ROWS)
375    const more = units.length - shown.length
376    const sent = new Set(pinned.sent)
377    const list = (
378      <Box flexDirection="column" flexShrink={0} width={width} marginLeft={2}>
379        <Box flexDirection="row">
380          <Text dimColor>{'⑂ Pinned  '}</Text>
381          <Button key="pin-unpin" plain dimColor onPress={() => unpin($)}>
382            Unpin
383          </Button>
384        </Box>
385        {shown.map((u, i) => (
386          // Each item is a button: a press puts /subtask <item> in the prompt box, as the picker does.
387          <Button key={'pin-' + (i + 1)} plain dimColor onPress={() => fillItem($, u.text)}>
388            {fit((sent.has(i) ? '⑂ ' : '  ') + (i + 1) + '. ' + u.title, width)}
389          </Button>
390        ))}
391        {more > 0 && <Text key="pin-more" dimColor>{fit('   +' + more + ' more (/st lists them all)', width)}</Text>}
392      </Box>
393    )
394    if (!below) return <Box flexDirection="row" justifyContent="flex-end">{list}</Box>
395    return (
396      <Box flexDirection="row" alignItems="flex-end">
397        <Box flexGrow={1} flexDirection="column">
398          {below}
399        </Box>
400        {list}
401      </Box>
402    )
403  })
404
405  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
406    const own = await next(e)
407    if (isWorking || e.surface === 'vscode' || e.surface === 'mobile') return own
408    if (!isLastBlock(e.props.text, lastAnswer)) return own
409    const units = findUnits(lastAnswer)
410    if (units.length === 0) return own
411
412    // Two quiet buttons: Subtask opens the picker, Pin keeps this list in the band
413    const { Box, Button, Text } = $.ui.resolve(e)
414    const answer = lastAnswer
415    const pinned = await read($, pinnedAtom)
416    const isPinned = pinned?.answer === answer
417    return (
418      <Box flexDirection="column">
419        {own}
420        <Box flexDirection="row">
421          <Button key="st-open" plain dimColor onPress={async () => {
422            if (!(await openPicker($, units))) $.ui.toast('⑂ Use /st to list the items')
423          }}>
424            {'⑂ Subtask (' + units.length + ')'}
425          </Button>
426          <Text dimColor>{'   '}</Text>
427          <Button key="st-pin" plain dimColor onPress={() => (isPinned ? unpin($) : pinAnswer($, answer))}>
428            {isPinned ? 'Unpin list' : 'Pin list'}
429          </Button>
430        </Box>
431      </Box>
432    )
433  })
434}
435
types/index.d.ts 10 lines
1// The answers that had a list, newest last, and the list pinned in the band. Kept in
2// the host's state so a hot reload does not lose them.
3export type Pinned = { answer: string; sent: number[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'subtask-icons': { answers: string[]; pinned: Pinned | null }
8  }
9}
10