SLOPSHOPPER

qp

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

newbandguardcommandpromptmodel
v0.3.1MITupdated 2026-10-21Jaleel-VS/qp-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qp
› fix the failing auth test and add an audit log call ● qp: qp: smart (haiku) fell back to fixed prompts: undefined is not an object (evaluating 's.length') ⏺ 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 › /qp ⎿ qp: Quick prompts: smart · model haiku · 4 shown ⟨Claude Code's own drawing⟩ quick prompts · smart · haiku failed (undefined is not an object (evaluating 's.length')), fixed (type 1-4 to pic… 1: Why did that fail? Fix it 2: Run the tests and smoke-check it 3: Review the diff for bugs 4: Explain that more simply ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ quick prompts · smart · haiku failed (undefined is not an object (evaluating 's.length')), fixed … 1: Why did that fail? Fix it 2: Run the tests and smoke-check it 3: Review the diff for bugs 4: Explain that more simply
README

qp: quick prompts for Claude Code

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

Install

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

Use

ActionHow
Put suggestion N in the prompt boxType 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.

Modes

  • smart (the default): after each turn, one side request suggests four likely next messages. If Claude ended its turn with a question, the first suggestion answers it.
  • learn: the same side request, but the four suggestions help you understand the work: a "why does this work" question, a "what if" question, the underlying concept, and a "quiz me".
  • fixed: no model call. Four stock prompts chosen from what the turn did (asked a question, edited files, hit an error).
  • off: nothing.

Models

haiku (the default)session
Request$.model.complete on Haiku$.model.fork on the session's model
What it readsYour last message, Claude's reply, and which tools the turn usedThe whole conversation, then Claude's reply
LatencyAbout 1.6 sAbout 2.4 s with the prompt cache warm, 7 s or more when it isn't
SuggestionsAbout the last exchange onlyCan 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.

How it behaves

  • Suggestions appear only for the main conversation's turns that end with an answer. Subagent turns, interrupted turns, and errors are skipped.
  • Suggestions clear when the next turn starts, so a stale set is never sent.
  • The band is shared with other mods. qp draws other mods' band content above its own.
  • In a short terminal, qp drops its header and packs the suggestions into columns, so all four stay visible. A digit only picks a suggestion that's visible.
  • While suggestions are showing, a message that is just 1–4 becomes that suggestion, whether you pause or press Enter straight away. Type anything more to send your own text.

What it runs and costs

  • Model calls: in smart and learn mode, one request per turn, billed to your plan or API key. With 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.
  • Files, processes, network: none. qp reads no environment variables.
  • Storage: the mode and model, in Claude Code's plugin store.
  • Commands: adds /qp.
  • Tool calls: qp watches them to choose the fixed prompts. It passes every call through unchanged and never answers a permission prompt.

Run claude plugin validate . in this folder to list the hooks and calls yourself.

Where it works

WhereWorks
claude in any terminalYes
Claude Desktop, Code tabExpected to, since the band is drawn there; not tested
VS Code extensionNo: the band above the prompt isn't drawn there
claude -p and the Agent SDKNo: nothing is drawn

License

MIT

Source 1 files
hooks/register.js 291 lines
1// 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