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…

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.
hooks/register.tsx 155 lines1import { 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}
155types/index.d.ts 9 lines1export 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