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

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.
⑂ 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)./subtask <item> goes into the prompt box and nothing is sent, so you can add your own words and press Enter. Esc closes it./subtask, the next pick is added on a new line./st opens the same picker; /st 3 puts item 3 in the prompt box directly.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./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.
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.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.
/st command once./subtask text holds its whole first line; an edited pick is not marked. A new session starts with no pin.Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.
hooks/register.tsx 435 lines1// 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}
435types/index.d.ts 10 lines1// 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