Quick prompts: four follow-up prompts above the prompt after each turn

A Claude Code mod that suggests four follow-up prompts after each turn, in the band above the prompt box. Type 1–4 into an empty prompt and pause to put one in the prompt box, then press Enter to send it or edit it first.
quick prompts · smart · haiku (type 1-4 to pick · Enter to send · /qp off)
1: Run the tests and smoke-check the band hotkeys
2: Add a unit test for parseSuggestions
3: Why does $.model.fork need the final answer passed in?
4: Explain how the generation ticket drops stale suggestions
Mods need Claude Code 2.1.287 or later. qp was tested on 2.1.305.
In a Claude Code session:
/plugin marketplace add Jaleel-VS/qp-mod
/plugin install qp@qp
Third-party marketplaces don't update by themselves. Turn on auto-update in /plugin > Marketplaces to get new versions.
To run from a clone instead, load the folder for one session:
claude --plugin-dir /path/to/qp-mod
To load the clone in every session, add it to your shell profile:
export CLAUDE_CODE_PLUGIN_DIRS=/path/to/qp-mod
| Action | How |
|---|---|
| Put suggestion N in the prompt box | Type N into an empty prompt and pause, or run /qp edit N. Press Enter to send it, or edit it first. |
| Send suggestion N at once | /qp N |
| Show the current mode and model | /qp |
| Change mode | /qp smart, /qp learn, /qp fixed, or /qp off |
| Change the model behind smart and learn | /qp model haiku or /qp model session |
The mode and model are saved and carry over to new sessions.
haiku (the default) | session | |
|---|---|---|
| Request | $.model.complete on Haiku | $.model.fork on the session's model |
| What it reads | Your last message, Claude's reply, and which tools the turn used | The whole conversation, then Claude's reply |
| Latency | About 1.6 s | About 2.4 s with the prompt cache warm, 7 s or more when it isn't |
| Suggestions | About the last exchange only | Can refer back to earlier turns |
The latencies are medians from 9 turns over 3 new sessions, on Sonnet on Amazon Bedrock. A session request resends the session's full context, about 100K tokens even early in a session, because that includes the system prompt and tool list.
If a smart or learn request fails, takes more than 30 seconds, or its reply can't be parsed, the band falls back to the fixed prompts and the header says why.
1–4 becomes that suggestion, whether you pause or press Enter straight away. Type anything more to send your own text.haiku, a few hundred tokens to Haiku. With session, a fork of the conversation on the session's model, mostly served from the prompt cache. Fixed and off mode make no model calls./qp.Run claude plugin validate . in this folder to list the hooks and calls yourself.
| Where | Works |
|---|---|
claude in any terminal | Yes |
| Claude Desktop, Code tab | Expected to, since the band is drawn there; not tested |
| VS Code extension | No: the band above the prompt isn't drawn there |
claude -p and the Agent SDK | No: nothing is drawn |
hooks/register.js 291 lines1// Quick prompts: after each finished turn, show four follow-up prompts in the
2// band above the prompt. Type 1-4 into an empty prompt (and pause) to put one in
3// the prompt box, then press Enter to send it or edit it first. `/qp N` sends at once.
4//
5// Modes (`/qp <mode>`, remembered across sessions):
6// smart a side request suggests four follow-ups from the last exchange
7// learn the same, but the suggestions deepen understanding (why, what-if, quiz)
8// fixed no model call: four prompts picked from what the turn did
9// off nothing
10//
11// Models for smart and learn (`/qp model <model>`, remembered across sessions):
12// haiku Haiku reads the last exchange and a summary of the turn: fast, cheap
13// session a fork of the whole conversation on the session's model: sees every
14// earlier turn, but resends the session's full context each time
15
16const MODES = ['smart', 'learn', 'fixed', 'off']
17const MODELS = ['haiku', 'session']
18const ANSWER_MAX = 6000
19const REQUEST_TIMEOUT_MS = 30000
20const MAX_LEN = 70
21
22const PROMPTS = {
23 smart: `You suggest the user's next message in this coding-agent conversation.
24Write exactly 4 short follow-up prompts the USER would plausibly send next, based on the last exchange.
25Make them concrete and different from each other: e.g. continue the work, verify or test it, dig into a detail, change direction.
26If the assistant asked the user a question, the first suggestion answers it.
27Each under ${MAX_LEN} characters, imperative, no numbering, no quotes.
28Reply with only a JSON array of 4 strings.`,
29 learn: `You coach a software engineer who wants to understand, not just finish, the work in this conversation.
30Write exactly 4 short prompts the USER could send next to deepen their understanding of the last exchange:
31one "why does this work" question, one "what would happen if" question, one asking for the general concept or system picture,
32and one "quiz me on this" request. Name the specific thing (file, service, error, command) in each.
33Each under ${MAX_LEN} characters, no numbering, no quotes.
34Reply with only a JSON array of 4 strings.`,
35}
36
37let mode = 'smart'
38let model = 'haiku'
39let userText = ''
40let prompts = []
41let status = 'idle' // 'idle' | 'thinking' | 'shown'
42let label = ''
43let generation = 0
44let turn = { tools: [], edited: false, failed: false }
45
46const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
47
48export function fixedPrompts(t) {
49 const out = []
50 if (t.asked) out.push('Yes, go ahead', 'No, keep it as is')
51 if (t.failed) out.push('Why did that fail? Fix it')
52 if (t.edited) out.push('Run the tests and smoke-check it', 'Review the diff for bugs')
53 out.push('Explain that more simply', 'Continue', 'Learn mode, retro: what should I take from this?', 'Retro: what could we do better?')
54 return [...new Set(out)].slice(0, 4)
55}
56
57export function parseSuggestions(reply) {
58 let items = []
59 const json = reply.match(/\[[\s\S]*\]/)
60 if (json) {
61 try {
62 items = JSON.parse(json[0])
63 } catch {}
64 }
65 if (!items.length) items = reply.split('\n')
66 return items
67 .filter((s) => typeof s === 'string')
68 .map((s) => s.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/^["'`]|["'`,]$/g, '').trim())
69 .filter((s) => s.length > 0)
70 .slice(0, 4)
71}
72
73const endsWithQuestion = (text) => /\?\s*\**\s*$/.test(text.trim().split('\n').slice(-3).join(' '))
74
75function render($, nextStatus, nextLabel) {
76 status = nextStatus
77 label = nextLabel
78 $.ui.invalidate('ui.render')
79}
80
81async function clear($) {
82 generation++
83 prompts = []
84 await render($, 'idle', '')
85}
86
87async function suggest($, t) {
88 const ticket = ++generation
89 const current = mode
90 const source = model
91 if (current === 'fixed') {
92 prompts = fixedPrompts(t)
93 return render($, 'shown', 'fixed')
94 }
95 prompts = []
96 render($, 'thinking', `${current} · ${source}`)
97 const r = await within($, () => request($, current, source, t), REQUEST_TIMEOUT_MS)
98 if (ticket !== generation) return
99 const parsed = r.isAnswered ? parseSuggestions(r.text) : []
100 prompts = parsed.length ? parsed : fixedPrompts(t)
101 const why = r.isAnswered ? 'unparsed' : r.reason
102 if (!parsed.length) $.ui.log(`qp: ${current} (${source}) fell back to fixed prompts: ${why}`, { to: 'debug' })
103 render($, 'shown', parsed.length ? `${current} · ${source}` : `${current} · ${source} failed (${why}), fixed`)
104}
105
106const tail = (s) => (s.length > ANSWER_MAX ? '…' + s.slice(-ANSWER_MAX) : s)
107
108function turnSummary(t) {
109 const counts = {}
110 for (const name of t.tools) counts[name] = (counts[name] ?? 0) + 1
111 const tools = Object.entries(counts).map(([name, n]) => (n > 1 ? `${name}×${n}` : name))
112 const notes = [tools.length ? `tools used: ${tools.join(', ')}` : 'no tools used']
113 if (t.edited) notes.push('files were edited')
114 if (t.failed) notes.push('a tool call failed')
115 return notes.join('; ')
116}
117
118function request($, current, source, t) {
119 if (source === 'session') {
120 // The fork replays the main thread's last request, which ends before this turn's
121 // final answer, so hand the answer over explicitly (its tail matters most).
122 const prompt = `The assistant's final reply this turn was:\n<reply>\n${tail(t.answer)}\n</reply>\n\n${PROMPTS[current]}`
123 return $.model.fork({ prompt })
124 }
125 const exchange = `<user>\n${tail(t.userText)}\n</user>\n<assistant>\n${tail(t.answer)}\n</assistant>\n<turn>${turnSummary(t)}</turn>`
126 return $.model.complete({ model: 'haiku', system: PROMPTS[current], prompt: exchange, maxTokens: 300 })
127}
128
129// $.model.fork takes no time limit, and a rejected call would leave the band on
130// "thinking…" for good, so bound every request and turn a rejection into a reason.
131async function within($, call, ms) {
132 let timer
133 const timeout = new Promise((resolve) => {
134 timer = $.clock.after(ms, () => resolve({ isAnswered: false, reason: 'timed out' }))
135 })
136 try {
137 return await Promise.race([call(), timeout])
138 } catch (error) {
139 return { isAnswered: false, reason: error instanceof Error ? error.message : String(error) }
140 } finally {
141 timer?.cancel()
142 }
143}
144
145async function pick($, n, send) {
146 const prompt = prompts[n - 1]
147 if (!prompt) return { text: `No quick prompt ${n}` }
148 if (send) {
149 await clear($)
150 // Resolves when the turn starts, so don't hold the command on it.
151 void $.prompt.submit({ text: prompt, asUser: true })
152 } else {
153 await $.prompt.fill({ text: prompt })
154 }
155 return {}
156}
157
158export function register(on) {
159 on('session.start', async ($, e, next) => {
160 const saved = await $.store.get('mode')
161 if (MODES.includes(saved)) mode = saved
162 const savedModel = await $.store.get('model')
163 if (MODELS.includes(savedModel)) model = savedModel
164 await $.command.register({
165 name: 'qp',
166 description: 'Quick prompts: /qp [smart|learn|fixed|off|model haiku|model session|1-4|edit N]',
167 argumentHint: '[smart|learn|fixed|off|model haiku|model session|1-4|edit N]',
168 })
169 return next(e)
170 })
171
172 on('session.end', async ($, e, next) => {
173 await clear($)
174 return next(e)
175 })
176
177 // A digit typed and sent at once beats the band's hotkey, which waits for a
178 // pause, so swap a bare 1-4 for that suggestion before the turn starts.
179 on('prompt.submit', async ($, e, next) => {
180 if (e.command || e.isMeta || e.origin.kind !== 'composer' || status !== 'shown') return next(e)
181 const n = /^\s*([1-4])\s*$/.exec(e.text)?.[1]
182 const suggestion = n && prompts[Number(n) - 1]
183 if (!suggestion) return next(e)
184 // Clear now, not at turn.start, so a second quick digit can't swap again.
185 await clear($)
186 return next({ ...e, text: suggestion })
187 })
188
189 on('turn.start', async ($, e, next) => {
190 if (e.agentId !== undefined) return next(e)
191 turn = { tools: [], edited: false, failed: false }
192 userText = e.text
193 await clear($)
194 return next(e)
195 })
196
197 on('tool.call', async ($, e, next) => {
198 if (e.agentId !== undefined) return next(e)
199 const result = await next(e)
200 turn.tools.push(e.tool)
201 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool)) turn.edited = true
202 if (result && (result.isError || result.deny)) turn.failed = true
203 return result
204 })
205
206 on('turn.complete', async ($, e, next) => {
207 if (e.agentId !== undefined || mode === 'off' || e.isAborted || e.reason !== 'answer') return next(e)
208 if (!e.answer.trim()) return next(e)
209 const t = { ...turn, userText, answer: e.answer, asked: endsWithQuestion(e.answer) }
210 // Don't hold the turn's end on the side request.
211 void suggest($, t)
212 return next(e)
213 })
214
215 on('command.run', { command: 'qp' }, async ($, e) => {
216 const [verb, arg] = (e.args || '').trim().split(/\s+/)
217 if (!verb) {
218 return { text: `Quick prompts: ${mode} · model ${model}${prompts.length ? ` · ${prompts.length} shown` : ''}` }
219 }
220 if (MODES.includes(verb)) {
221 mode = verb
222 await $.store.set('mode', mode)
223 await clear($)
224 return { text: `Quick prompts: ${mode}` }
225 }
226 if (verb === 'model') {
227 if (!arg) return { text: `Quick prompts model: ${model}` }
228 if (!MODELS.includes(arg)) return { text: 'Usage: /qp model [haiku|session]' }
229 model = arg
230 await $.store.set('model', model)
231 await clear($)
232 return { text: `Quick prompts model: ${model}` }
233 }
234 if (/^[1-4]$/.test(verb)) return pick($, Number(verb), true)
235 if (verb === 'edit' && /^[1-4]$/.test(arg ?? '')) return pick($, Number(arg), false)
236 return { text: 'Usage: /qp [smart|learn|fixed|off|model haiku|model session|1-4|edit N]' }
237 })
238
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 const theirs = await next(e)
241 if (mode === 'off' || status === 'idle' || e.props.hasSurvey) return theirs
242 const { Box, Text, Button } = $.ui.resolve(e)
243 const width = e.props.bodyColumns
244
245 const header = Text({
246 dimColor: true,
247 wrap: 'truncate-end',
248 children: [
249 status === 'thinking'
250 ? `quick prompts · ${label} · thinking…`
251 : `quick prompts · ${label} (type 1-4 to pick · Enter to send · /qp off)`,
252 ],
253 })
254
255 if (status === 'thinking') return Box({ flexDirection: 'column', children: [theirs, header].filter(Boolean) })
256
257 // A bare digit fires only for buttons wholly inside the band's window, so all
258 // four must fit: drop the header, then pack the prompts into columns.
259 const fullRows = prompts.length + 1
260 const showHeader = e.props.maxRows >= fullRows
261 const rowsFree = Math.max(1, showHeader ? e.props.maxRows - 1 : e.props.maxRows)
262 const cols = Math.ceil(prompts.length / Math.min(rowsFree, prompts.length))
263 const cell = Math.max(10, Math.min(MAX_LEN, Math.floor(width / cols) - 5))
264
265 const button = (p, i) =>
266 Button({
267 key: 'qp-send-' + i,
268 label: clip(p, cell),
269 hotkey: String(i + 1),
270 plain: true,
271 onPress: async () => {
272 await pick($, i + 1, false)
273 },
274 })
275
276 const lines = []
277 for (let i = 0; i < prompts.length; i += cols) {
278 lines.push(
279 Box({
280 key: 'qp-line-' + i,
281 flexDirection: 'row',
282 columnGap: 2,
283 children: prompts.slice(i, i + cols).map((p, j) => button(p, i + j)),
284 }),
285 )
286 }
287
288 return Box({ flexDirection: 'column', children: [theirs, showHeader && header, ...lines].filter(Boolean) })
289 })
290}
291