Clickable follow-up prompts above the input: click one to put it in the prompt box, unsent

A Claude Code mod from Encore Mods. Claude often ends a reply with a few follow-ups ("Next steps: 1. … 2. …") or a question with choices. This mod turns them into buttons above the prompt.
ctrl+x tab to focus the buttons, then up/down. Each one shows as ghost text as you move./plugin marketplace add benjaminb10/encore-mods
/plugin install next-prompts@encore-mods
Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.
heading: one more heading to recognise, for example Ideas. Set it in /plugin, or with /plugin configure next-prompts@encore-mods.
Tip: ask Claude for follow-ups in your CLAUDE.md, for example: End each reply where work remains with Next steps: and 3 numbered, one-line follow-ups.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/next-promptshooks/register.js 136 lines1// next-prompts: the follow-ups Claude suggests at the end of a reply become buttons above the prompt.
2// Click one (or type its digit alone in the empty prompt and pause) to put it in the prompt box, unsent.
3// The first one also shows as dim ghost text in the prompt box (Tab takes it), and moving through the
4// buttons with the keyboard (ctrl+x tab, then up/down) previews each one the same way.
5
6const MAX = 6
7
8// Headings that introduce suggested follow-ups, in a few languages. Add your own with the `heading` option.
9const HEADINGS = [
10 'next steps?', 'suggested next steps?', 'possible next steps?', "what's next", 'follow[- ]ups?',
11 'suites possibles', 'prochaines étapes', 'próximos pasos', 'nächste schritte', 'prossimi passi',
12]
13
14let suggestions = []
15let pressed = -1 // the follow-up just chosen, highlighted for a moment
16let headingRe = buildHeading('')
17
18function buildHeading(extra) {
19 const parts = [...HEADINGS]
20 if (extra && extra.trim()) parts.push(extra.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
21 // The heading alone on its line ("**Next steps:**"), not quoted in the middle of a sentence
22 return new RegExp('^[\\s#>*_]*(?:' + parts.join('|') + ')[\\s*_]*:?[\\s*_]*$', 'i')
23}
24
25function clean(s) {
26 return s
27 .replace(/\*\*|__|`/g, '')
28 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
29 .replace(/\s+/g, ' ')
30 .trim()
31}
32
33// A top-level list item (not an indented sub-item)
34const SOURCES = /^[\s#>*_]*(sources|références|references|refs)[\s*_]*:?[\s*_]*$/i
35const ITEM = /^ {0,1}(?:\d+[.)]|[-*•])\s+(.+?)\s*$/
36
37// The follow-ups: the list that ends the reply, right after a "Next steps" heading or a question
38// ("Which one? - A - B"). Nothing may come after it, so a list in the middle of a reply never counts.
39export function parseSuggestions(answer, heading = headingRe) {
40 if (!answer) return []
41 let lines = answer.replace(/\r/g, '').split('\n')
42 // A trailing "Sources:" block (links after a web search) doesn't hide the follow-ups above it
43 for (let k = lines.length - 1; k >= 0; k--) {
44 if (SOURCES.test(lines[k])) {
45 const rest = lines.slice(k + 1).filter((l) => l.trim())
46 if (rest.length && rest.every((l) => /^\s*(?:[-*•]|\d+[.)])?\s*\[?[^\n]*\]?\(?https?:\/\//.test(l) || /https?:\/\//.test(l))) lines = lines.slice(0, k)
47 break
48 }
49 }
50 let i = lines.length - 1
51 const tail = []
52 while (i >= 0 && (ITEM.test(lines[i]) || !lines[i].trim())) {
53 if (lines[i].trim()) tail.unshift(lines[i].match(ITEM)[1])
54 i--
55 }
56 if (!tail.length || i < 0) return []
57 const intro = lines[i]
58 const ok = heading.test(intro) || (tail.length >= 2 && /\?\s*\**\s*$/.test(intro))
59 return ok ? tail.map(clean).filter(Boolean).slice(0, MAX) : []
60}
61
62// The text to insert at the cursor: a space first when the character before the cursor isn't one.
63async function joinAtCursor($, s) {
64 try {
65 const box = await $.prompt.read()
66 const before = (box.text || '').slice(0, box.cursor ?? (box.text || '').length)
67 return before && !/\s$/.test(before) ? ' ' + s : s
68 } catch {
69 return s
70 }
71}
72
73export function register(on, options = {}) {
74 headingRe = buildHeading(options.heading || '')
75
76 // After the mod reloads (or a resumed session), pick up the follow-ups of the last reply again
77 on('session.start', async ($, e, next) => {
78 try {
79 const last = (await $.session.messages()).filter((m) => m.role === 'assistant').at(-1)
80 if (last && last.text) suggestions = parseSuggestions(last.text)
81 } catch {}
82 return next(e)
83 })
84
85 on('prompt.submit', async ($, e, next) => {
86 suggestions = []
87 $.ui.invalidate('ui.render')
88 return next(e)
89 }).catch(() => undefined)
90
91 on('turn.complete', async ($, e, next) => {
92 if (e.agentId) return next(e)
93 suggestions = e.isAborted ? [] : parseSuggestions(e.answer)
94 if (suggestions.length) $.prompt.suggest({ text: suggestions[0] }).catch(() => {})
95 $.ui.invalidate('ui.render')
96 return next(e)
97 })
98
99 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
100 const m = /^next-prompt-(\d+)$/.exec(e.element || '')
101 if (m && suggestions[Number(m[1])]) $.prompt.suggest({ text: suggestions[Number(m[1])] }).catch(() => {})
102 return next(e)
103 })
104
105 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106 if (e.props.isWorking || !suggestions.length) return next(e)
107 const { Box, Text, Button } = $.ui.resolve(e)
108 const rows = [Text({ dimColor: true, children: ['Next (click, or type the digit):'] })]
109 suggestions.forEach((s, i) => {
110 // Just chosen: shown inverted for a moment, so you see which one went to the prompt box
111 if (i === pressed) {
112 rows.push(Text({ key: 'chosen-' + i, inverse: true, children: [(i + 1) + ': ' + s] }))
113 return
114 }
115 rows.push(
116 Button({
117 key: 'next-prompt-' + i,
118 label: s,
119 hotkey: String(i + 1),
120 plain: true,
121 onPress: async () => {
122 pressed = i
123 $.ui.invalidate('ui.render')
124 $.clock.after(1200, () => { pressed = -1; $.ui.invalidate('ui.render') })
125 // Insert at the cursor instead of replacing the draft, with a space if needed, so
126 // several follow-ups (and your own words between them) add up in the prompt box.
127 await $.prompt.fill({ text: await joinAtCursor($, s), mode: 'insert' })
128 },
129 }),
130 )
131 })
132 const theirs = await next(e)
133 return Box({ flexDirection: 'column', children: [...rows, theirs] })
134 })
135}
136