The main model lists next steps at the end of its final answer; they show as checkboxes above the prompt, with no extra model calls

Mods for Claude Code: plugins that run inside Claude Code and draw in its interface.
Claude ends each answer with a few things you might ask next. They show up as checkboxes above the prompt. Tick one or more, and they go into the prompt box for you to edit and send.
<img src="docs/demo.svg" alt="The end of Claude's answer, then four next-step checkboxes above the prompt with one ticked, and the prompt box holding the ticked request" width="820"> <sub>Illustration of the Claude Code desktop app with the mod on.</sub>
Requires Claude Code with mods support (v2.1.286 or later).
claude plugin marketplace add shijieli708-ship-it/claude-mods
claude plugin install inline-next-steps@claude-mods
Then start a new session, or run /reload-plugins in an open one.
<img src="docs/how-it-works.svg" alt="Four steps: attach an instruction, Claude answers with a next-steps block, the block is shown as checkboxes, ticked steps fill the prompt box" width="820">
<next-steps> block. Each line is short label | the full request.Each turn costs roughly 100 extra output tokens for the block and about 250 input tokens for the instruction, which is cached afterwards. Mods that ask a second model for suggestions after each turn pay for re-reading the conversation, and see only part of it.
claude -p) nothing is attached, so no raw block appears.Each mod is a plugin directory under plugins/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module. See the mods documentation. Check a mod before loading it with:
claude plugin validate plugins/<mod>
Claude 在每次回答的末尾列出几条你接下来可能会问的事情,以复选框的形式显示在输入框上方。勾选一条或几条后,它们会被填进输入框,你可以先修改,确认后再发送。
安装
claude plugin marketplace add shijieli708-ship-it/claude-mods
claude plugin install inline-next-steps@claude-mods
然后新开一个会话,或者在已打开的会话里运行 /reload-plugins。
原理
<next-steps> 块,每行格式是 短标签 | 完整指令。成本:每轮大约多 100 个输出 token,外加约 250 个输入 token 的说明,这部分之后会被缓存。如果改用第二个模型在每轮结束后生成建议,就得把对话重新读一遍,而且只能看到其中一部分。
局限:模型偶尔会漏写这个块或写错格式,这一轮就不会出现按钮。在 VS Code 聊天面板和 claude -p 里不会附加说明,所以也不会出现块的原文。回答逐字输出时,块的开头标签一出现就会被隐藏。
hooks/register.tsx 142 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Step } from '../types'
5
6// The steps the last answer listed, and which of them are ticked, by index.
7const steps = atom({ plugin: 'inline-next-steps', key: 'steps' } as const, [] as Step[])
8const selected = atom({ plugin: 'inline-next-steps', key: 'selected' } as const, [] as number[])
9
10const MAX = 6
11const OPEN = '<next-steps>'
12const CLOSE = '</next-steps>'
13
14// Fixed text, so every prompt carries the same bytes and the cache keeps them.
15const INSTRUCTIONS = `# Next steps
16
17When you finish a turn with your final answer to the user in the main conversation, end it with a block listing what the user might reasonably ask you to do next:
18
19${OPEN}
20- short label | the full request, as the user would type it
21${CLOSE}
22
23- For work (code, files, tasks): 2 to ${MAX} items, distinct and specific to this conversation; the most useful first. The request names the concrete files, commands or values involved.
24- For a plain question (an explanation, a fact, a concept): 1 to 3 follow-up questions that go deeper or sideways from your answer, each phrased as the question the user would ask.
25- Label: at most 5 words. Request: one or two sentences.
26- Write both in the language the user writes in.
27- Only in a final answer. Never in progress notes between tool calls, never as a subagent, and leave it out when nothing useful follows (a greeting, a thank-you, a yes/no confirmation).
28- The user does not see this block as text: it is turned into buttons. Do not refer to it.`
29
30// Removes the block from a text, including a block still streaming in (no closing tag yet).
31function strip(text: string): string {
32 const start = text.indexOf(OPEN)
33 if (start === -1) return text
34 const end = text.indexOf(CLOSE, start)
35 const rest = end === -1 ? '' : text.slice(end + CLOSE.length)
36 return (text.slice(0, start) + rest).replace(/\s+$/, '')
37}
38
39function parse(answer: string): Step[] {
40 const start = answer.lastIndexOf(OPEN)
41 if (start === -1) return []
42 const end = answer.indexOf(CLOSE, start)
43 if (end === -1) return []
44 return answer
45 .slice(start + OPEN.length, end)
46 .split('\n')
47 .map(line => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim())
48 .filter(line => line !== '')
49 .map(line => {
50 const bar = line.indexOf('|')
51 const label = (bar === -1 ? line : line.slice(0, bar)).trim()
52 const prompt = (bar === -1 ? line : line.slice(bar + 1)).trim() || label
53 return { label, prompt }
54 })
55 .filter(s => s.label !== '')
56 .slice(0, MAX)
57}
58
59export const register: Register = on => {
60 // A new request makes the old steps stale. The instructions ride the prompt as context the
61 // user never sees: the desktop app's main system prompt does not pass through prompt.compose.
62 on('prompt.submit', async ($, e, next) => {
63 if (e.turnId) return next(e)
64 void Promise.all([update($, steps, () => []), update($, selected, () => [])])
65 // Only where the mod can draw the buttons; elsewhere the block would show as raw text.
66 const surfaces = await $.session.surfaces()
67 const canDraw = surfaces.includes('terminal') || surfaces.includes('desktop')
68 if (!canDraw) return next(e)
69 return next({ ...e, context: [...(e.context ?? []), INSTRUCTIONS] })
70 })
71
72 on('turn.complete', async ($, e, next) => {
73 const result = await next(e)
74 if (!e.agentId) {
75 const found = e.reason === 'answer' ? parse(e.answer) : []
76 await Promise.all([update($, steps, () => found), update($, selected, () => [])])
77 }
78 return result
79 })
80
81 // Hide the block in the transcript. This changes the drawing only; the stored message is untouched.
82 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
83 const text = strip(e.props.text)
84 if (text === e.props.text) return next(e)
85 return next({ ...e, props: { ...e.props, text } })
86 })
87
88 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
89 if (e.props.hasSurvey || e.props.isWorking) return next(e)
90 const [list, picked] = await Promise.all([read($, steps), read($, selected)])
91 if (list.length === 0) return next(e)
92
93 const inner = await next(e)
94 const { Box, Text, Button } = $.ui.resolve(e)
95
96 const toggle = (i: number) =>
97 update($, selected, l => (l.includes(i) ? l.filter(x => x !== i) : [...l, i].sort((a, b) => a - b)))
98
99 // Puts the ticked requests in the prompt box for the user to read and send.
100 const fill = async () => {
101 const chosen = picked.map(i => list[i]).filter(Boolean)
102 if (chosen.length === 0) return
103 const text = chosen.length === 1 ? chosen[0].prompt : chosen.map((s, i) => `${i + 1}. ${s.prompt}`).join('\n')
104 const filled = await $.prompt.fill({ text })
105 if (!filled.isFilled) {
106 $.ui.toast('The prompt box is busy; try again')
107 return
108 }
109 await update($, selected, () => [])
110 }
111
112 const item = (s: Step, i: number) => (
113 <Button
114 key={`step-${i}`}
115 label={`${picked.includes(i) ? '☑' : '☐'} ${s.label}`}
116 plain
117 dimColor={!picked.includes(i)}
118 onPress={() => toggle(i)}
119 />
120 )
121 const half = Math.ceil(list.length / 2)
122
123 return (
124 <Box flexDirection="column">
125 <Box flexDirection="row" columnGap={3}>
126 <Box flexDirection="column">{list.slice(0, half).map((s, i) => item(s, i))}</Box>
127 <Box flexDirection="column">{list.slice(half).map((s, i) => item(s, i + half))}</Box>
128 </Box>
129 <Box flexDirection="row" columnGap={2}>
130 {picked.length > 0 ? (
131 <Button key="fill" label={picked.length === 1 ? 'Put in prompt' : `Put ${picked.length} in prompt`} variant="primary" onPress={fill} />
132 ) : (
133 <Text dimColor>Tick next steps, then put them in the prompt</Text>
134 )}
135 <Button key="dismiss" label="Dismiss" plain dimColor onPress={() => update($, steps, () => [])} />
136 </Box>
137 {inner}
138 </Box>
139 )
140 })
141}
142types/index.d.ts 8 lines1export type Step = { label: string; prompt: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'inline-next-steps': { steps: Step[]; selected: number[] }
6 }
7}
8