SLOPSHOPPER

next-steps-anywhere

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…

newpanebandcommandpromptmodel
v0.1.0no licenseupdated 2026-10-06DansiDanutz/Crime-Doc/plugins/next-steps-anywhere
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-steps-anywhere
│ ┃ next-steps ✕ › fix the failing auth test and add an audit log call │ ┃ No suggestions. │ ⏺ 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 │ │ › /next-steps │ ⎿ next-steps-anywhere: No suggestions yet: they appear after the n │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · next-steps
No suggestions.
README

next-steps-anywhere

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.

Install (all projects)

claude plugin marketplace add DansiDanutz/Crime-Doc
claude plugin install next-steps-anywhere@dansidanutz --scope user

How it works

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.
  • A press calls $.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.

Options

OptionDefaultWhat it does
minAnswerChars80Skip suggestions after answers shorter than this
suggestSkillstrueTell the suggester which skills and slash commands the session has

Checks

claude plugin validate plugins/next-steps-anywhere
claude plugin test plugins/next-steps-anywhere
Source 2 files
hooks/register.tsx 181 lines
1import { 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}
181
types/index.d.ts 8 lines
1export type Suggestions = { turnId: string; items: string[] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'next-steps-anywhere': { suggestions: Suggestions | null }
6  }
7}
8