SLOPSHOPPER

next-steps

At the end of a turn Claude offers 2-4 follow-ups as buttons above the prompt, each showing the whole prompt it sends; a click sends that one. Gone as soon as…

newbandguardtoastprompttool
v0.2.0MITupdated 2026-10-15aecheman27-gif/claude-mods/next-steps
A shopper browsing a rack in a slop shop
README

claude-mods

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.

Source 2 files
hooks/register.tsx 155 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Card, Step } from '../types'
5
6// Claude ends a turn by offering 2-4 follow-ups through the next_steps tool;
7// they show as buttons above the prompt, each with the whole prompt it sends
8// beside it, and a click sends that follow-up as your own prompt: a short label
9// can never hide what a click says. The card goes away as soon as any prompt
10// is sent. The
11// model reads one extra line of system prompt and the tool's description; the
12// mod itself makes no model calls, no network, no processes.
13
14const TOOL = 'mcp__next-steps__next_steps'
15const MAX_STEPS = 4
16const MAX_LABEL = 40
17// short enough to read whole beside its button
18const MAX_PROMPT = 200
19const card = atom({ plugin: 'next-steps', key: 'card' } as const, null)
20
21const HINT = {
22  id: 'next-steps:hint',
23  scope: 'session',
24  text:
25    'When you finish a turn and the user is likely to continue with one of a few clear follow-ups, call the next_steps tool as your last action with 2-4 of them, ' +
26    'instead of listing them in prose. Skip it when the work is complete with no obvious follow-up, and use AskUserQuestion instead when you cannot continue without a decision.',
27} as const
28
29const clip = (text: string, max: number) => {
30  const t = text.replace(/\s+/g, ' ').trim()
31  return t.length > max ? t.slice(0, max - 1) + '…' : t
32}
33// control, zero-width and direction characters: none may hide or reorder the words of a prompt shown as sent
34const HIDING = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
35
36/** The steps the model gave, cleaned, or why they can't be shown. */
37export function stepsFrom(input: unknown): Step[] | string {
38  const raw = (input as { steps?: unknown }).steps
39  if (!Array.isArray(raw) || raw.length === 0) return 'steps must be a non-empty list'
40  const steps: Step[] = []
41  for (const s of raw.slice(0, MAX_STEPS)) {
42    const label = typeof s?.label === 'string' ? clip(s.label, MAX_LABEL) : ''
43    const prompt = typeof s?.prompt === 'string' ? s.prompt.replace(HIDING, ' ').replace(/\s+/g, ' ').trim() : ''
44    if (!label || !prompt) return 'each step needs a label and a prompt'
45    // cut, it would send less than it asks: the model writes a shorter one
46    if (prompt.length > MAX_PROMPT) return `each prompt must be at most ${MAX_PROMPT} characters: it is shown in full beside its button`
47    steps.push({ label, prompt })
48  }
49  return steps
50}
51
52export const register: Register = on => {
53  on('session.start', async ($, e, next) => {
54    await $.tool.register({
55      name: 'next_steps',
56      description:
57        'Offer the user 2-4 likely follow-ups as clickable buttons above the prompt. Call it as the last action of a turn, after your reply. ' +
58        'Each button shows its whole prompt beside it, and a click sends exactly that prompt as if the user typed it; the buttons disappear when the user sends anything. Not for blocking questions (use AskUserQuestion).',
59      inputSchema: {
60        type: 'object',
61        properties: {
62          question: { type: 'string', description: 'Optional short heading, e.g. "What next?"' },
63          steps: {
64            type: 'array',
65            minItems: 1,
66            maxItems: MAX_STEPS,
67            items: {
68              type: 'object',
69              properties: {
70                label: { type: 'string', description: `Button text, a few words (at most ${MAX_LABEL} characters).` },
71                prompt: {
72                  type: 'string',
73                  maxLength: MAX_PROMPT,
74                  description: `What gets sent when clicked, shown in full beside the button (at most ${MAX_PROMPT} characters), written as the user asking you, e.g. "Run the tests and fix any failures".`,
75                },
76              },
77              required: ['label', 'prompt'],
78            },
79          },
80        },
81        required: ['steps'],
82      },
83      // in the prompt's tool list: offered at the end of most turns, behind ToolSearch it would cost a round trip
84      isDeferred: false,
85    })
86    return next(e)
87  })
88
89  on('tool.call', { tool: TOOL }, async ($, e) => {
90    if (e.agentId) return { result: 'ignored: only the main conversation offers next steps' }
91    const steps = stepsFrom(e)
92    if (typeof steps === 'string') return { deny: `next_steps: ${steps}` }
93    const asked = (e as { question?: unknown }).question
94    const question = typeof asked === 'string' ? clip(asked, 80) : ''
95    const now: Card = question ? { question, steps } : { steps }
96    await update($, card, () => now)
97    return { result: `shown: ${steps.length} button${steps.length === 1 ? '' : 's'} above the prompt` }
98  }).catch(() => ({ result: 'not shown: the next-steps card failed; carry on without it' }))
99
100  on('prompt.compose', async ($, e, next) => {
101    const composed = await next(e)
102    if (!e.tools.includes(TOOL)) return composed
103    return { ...composed, sections: [...composed.sections, HINT] }
104  }).catch(($, e, next) => next(e))
105
106  // Anything sent, typed or clicked, answers the card.
107  on('prompt.submit', async ($, e, next) => {
108    await update($, card, () => null).catch(() => undefined)
109    return next(e)
110  }).catch(($, e, next) => next(e))
111
112  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
113    const below = await next(e)
114    const c = await read($, card)
115    if (!c || e.props.hasSurvey || e.props.isWorking) return below
116    const { Box, Button, Text } = $.ui.resolve(e)
117    const send = (s: Step) => () =>
118      void update($, card, () => null)
119        .then(() => $.prompt.submit({ text: s.prompt, asUser: true }))
120        .catch(err => $.ui.toast(`next-steps: could not send: ${err instanceof Error ? err.message : String(err)}`))
121    // each button with the whole prompt it sends beside it: the label can't say one thing and the click another
122    const mine = (
123      <Box flexDirection="column" width={e.props.bodyColumns}>
124        <Box flexDirection="row" columnGap={1}>
125          <Box flexShrink={1}>
126            <Text wrap="truncate-end">
127              <Text color="#38bdf8" bold>
128                [NEXT]{' '}
129              </Text>
130              {c.question ?? 'Pick one, or type your own:'}
131            </Text>
132          </Box>
133          <Button key="dismiss" label="✕" onPress={() => void update($, card, () => null)} />
134        </Box>
135        {c.steps.map((s, i) => (
136          <Box key={`row-${i}`} flexDirection="row" columnGap={1}>
137            <Button key={`step-${i}`} label={s.label} variant={i === 0 ? 'primary' : undefined} onPress={send(s)} />
138            <Box flexShrink={1}>
139              <Text dimColor wrap="wrap">
140                {s.prompt}
141              </Text>
142            </Box>
143          </Box>
144        ))}
145      </Box>
146    )
147    return (
148      <Box flexDirection="column">
149        {mine}
150        {below}
151      </Box>
152    )
153  })
154}
155
types/index.d.ts 9 lines
1export type Step = { label: string; prompt: string }
2export type Card = { question?: string; steps: Step[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'next-steps': { card: Card | null }
7  }
8}
9