After each answer, suggests up to three next prompts: above the input in the terminal and desktop app, and in a /next-steps pane on any surface (mobile and VS…

After each answer, suggests up to three next prompts. In the terminal and the desktop app they appear above the input box on their own; on any surface (mobile and VS Code included) type /next-steps to open them in a pane.
next:
1: run the tests you just wrote
2: do the same for the settings page
3: /code-review high
dismiss
Tap a suggestion (or press 1, 2, 3 from an empty prompt box) and it is written into the box as a draft — edit it, then send it yourself. 0 / dismiss clears them. It never submits a prompt on its own.
claude plugin marketplace add DansiDanutz/Crime-Doc
claude plugin install next-steps-anywhere@dansidanutz --scope user
A function-hooks plugin (hooks/register.tsx):
turn.complete — after a main-loop answer of at least minAnswerChars, asks $.model.fork for likely next prompts. The fork reuses the session's prompt cache, so it costs about one short reply per turn.$.command.list — the session's skills and slash commands go into the question, so a suggestion can be one of them; a suggestion naming a command the session lacks is dropped.ui.render on AbovePrompt — draws the suggestions as buttons above the prompt. Claude Code raises that site on the terminal and desktop only./next-steps — opens the same buttons in a Pane, which every surface (mobile and VS Code included) draws.$.prompt.fill. The plugin leaves the box's dim ghost text ($.prompt.suggest) to Claude Code's own suggestion.turn.start / prompt.submit — clear stale suggestions.Skipped after subagent turns, interrupted turns, errors and short answers.
| Option | Default | What it does |
|---|---|---|
minAnswerChars | 80 | Skip suggestions after answers shorter than this |
suggestSkills | true | Tell the suggester which skills and slash commands the session has |
claude plugin validate plugins/next-steps-anywhere
claude plugin test plugins/next-steps-anywherehooks/register.tsx 181 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { Suggestions } from '../types'
5
6const suggestions = atom({ plugin: 'next-steps-anywhere', key: 'suggestions' } as const, null as Suggestions | null)
7
8const PANE = 'next-steps'
9const COMMAND = 'next-steps'
10const MAX_SUGGESTIONS = 3
11const MAX_LENGTH = 160
12const MAX_COMMANDS = 40
13
14function buildQuestion(commands: { name: string; description: string }[]): string {
15 const lines = [
16 'Based on this conversation so far, what are the 1 to 3 prompts the user is most likely to send next?',
17 'Write each one as the user would type it: short, concrete, in their voice, under 80 characters.',
18 'Prefer the obvious next step in the work that is under way; never repeat something already done.',
19 ]
20 if (commands.length > 0) {
21 lines.push(
22 'A suggestion may be one of these slash commands (write it as "/name arguments"); never invent others:',
23 ...commands.slice(0, MAX_COMMANDS).map(c => `/${c.name} — ${c.description}`),
24 )
25 }
26 lines.push('Reply with ONLY a JSON array of strings and nothing else. If there is no sensible next step, reply [].')
27 return lines.join('\n')
28}
29
30function parseSuggestions(text: string, known: Set<string> | null): string[] {
31 const start = text.indexOf('[')
32 const end = text.lastIndexOf(']')
33 if (start === -1 || end <= start) return []
34 let parsed: unknown
35 try {
36 parsed = JSON.parse(text.slice(start, end + 1))
37 } catch {
38 return []
39 }
40 if (!Array.isArray(parsed)) return []
41 const out: string[] = []
42 for (const item of parsed) {
43 if (typeof item !== 'string') continue
44 const s = item.replace(/\s+/g, ' ').trim()
45 if (s.length === 0 || s.length > MAX_LENGTH) continue
46 if (s.startsWith('/')) {
47 const name = s.slice(1).split(' ')[0] ?? ''
48 if (known === null || !known.has(name)) continue
49 }
50 if (!out.includes(s)) out.push(s)
51 if (out.length === MAX_SUGGESTIONS) break
52 }
53 return out
54}
55
56// Bumped by every clear: a fork that started before a clear must not write its now-stale result.
57let generation = 0
58
59async function clear($: EngineInterface): Promise<void> {
60 generation += 1
61 await update($, suggestions, () => null)
62 await $.ui.close({ id: PANE })
63}
64
65async function suggestNext(
66 $: EngineInterface,
67 turnId: string,
68 suggestSkills: boolean,
69 startedAt: number,
70): Promise<void> {
71 const commands = suggestSkills ? await $.command.list() : []
72 const known = suggestSkills ? new Set(commands.map(c => c.name)) : null
73 const reply = await $.model.fork({ prompt: buildQuestion(commands) })
74 if (!reply.isAnswered) return
75
76 const items = parseSuggestions(reply.text, known)
77 if (items.length === 0) return
78
79 // No $.prompt.suggest ghost text: the engine already proposes its own there, and a plugin's
80 // proposal can stay pending into a later turn with no way to withdraw it.
81 // Checked inside the update, so a clear that lands while this write retries still wins.
82 await update($, suggestions, current => (generation === startedAt ? { turnId, items } : current))
83}
84
85/** The suggestions as buttons: a press writes one into the prompt box as a draft. */
86function drawSuggestions(
87 $: EngineInterface,
88 e: RenderInput<'AbovePrompt' | 'Pane'>,
89 items: string[],
90 columns: number,
91) {
92 const { Box, Text, Button } = $.ui.resolve(e)
93 return (
94 <Box flexDirection="column">
95 <Text dimColor>next:</Text>
96 {items.map((item, i) => (
97 <Button
98 key={`suggestion-${i + 1}`}
99 hotkey={String(i + 1)}
100 plain
101 label={fit(item, columns)}
102 onPress={async () => {
103 await $.prompt.fill({ text: item })
104 await clear($)
105 }}
106 />
107 ))}
108 <Button key="dismiss" hotkey="0" plain dimColor label="dismiss" onPress={() => clear($)} />
109 </Box>
110 )
111}
112
113function fit(label: string, columns: number): string {
114 const room = Math.max(12, columns - 6)
115 return label.length <= room ? label : `${label.slice(0, room - 1)}…`
116}
117
118export const register: Register = (on, options) => {
119 const minAnswerChars = typeof options.minAnswerChars === 'number' ? options.minAnswerChars : 80
120 const suggestSkills = options.suggestSkills !== false
121
122 on('turn.start', async ($, e, next) => {
123 await clear($)
124 return next(e)
125 })
126
127 on('prompt.submit', async ($, e, next) => {
128 await clear($)
129 return next(e)
130 })
131
132 on('turn.complete', async ($, e, next) => {
133 // Taken before any await: a clear from here on makes this turn's suggestions stale.
134 const startedAt = generation
135 const result = await next(e)
136 if (e.agentId || e.reason !== 'answer' || e.isAborted || e.answer.trim().length < minAnswerChars) {
137 return result
138 }
139
140 // Suggestions are optional: a failure here must never fail the turn that already answered.
141 try {
142 await suggestNext($, e.turnId, suggestSkills, startedAt)
143 } catch {
144 // nothing to show this turn
145 }
146 return result
147 })
148
149 // The band above the prompt: raised on the terminal and the desktop app.
150 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151 const current = await read($, suggestions)
152 if (current === null || current.items.length === 0 || e.props.hasSurvey || e.props.isWorking) {
153 return next(e)
154 }
155 return drawSuggestions($, e, current.items, e.props.bodyColumns)
156 })
157
158 // Mobile and VS Code raise no AbovePrompt; /next-steps opens the same list in a pane,
159 // which every surface draws.
160 on('session.start', async ($, e, next) => {
161 await $.command.register({ name: COMMAND, description: 'Show suggested next prompts in a pane' })
162 return next(e)
163 })
164
165 on('command.run', { command: COMMAND }, async $ => {
166 const current = await read($, suggestions)
167 if (current === null || current.items.length === 0) {
168 return { text: 'No suggestions yet: they appear after the next answer.' }
169 }
170 await $.ui.open({ id: PANE, title: 'Next steps' })
171 return {}
172 })
173
174 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175 const { Text } = $.ui.resolve(e)
176 const current = await read($, suggestions)
177 if (current === null || current.items.length === 0) return <Text dimColor>No suggestions.</Text>
178 return drawSuggestions($, e, current.items, e.viewport?.columns ?? 80)
179 })
180}
181types/index.d.ts 8 lines1export type Suggestions = { turnId: string; items: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'next-steps-anywhere': { suggestions: Suggestions | null }
6 }
7}
8