SLOPSHOPPER

quick-replies

Answer Claude in one click. After each reply, pills above the prompt suggest what you'd most likely say next (Yes, No, Confirm, Check it for me, the options it…

newbandrowspromptmodeltimer
★ 2v0.4.0MITupdated 2026-10-15Ryan-LT/claude-mods/quick-replies
A shopper browsing a rack in a slop shop
README

quick-replies

Answer Claude in one click. When Claude ends a reply with a question, an offer or a step for you, pills appear above the prompt with what you'd most likely say: Yes, No, Confirm, Check it for me, or the options it just listed. Click one and it's sent as your message.

quick-reply pills above the Claude Code prompt

  • Fits the reply. Haiku reads the end of Claude's message (and your last prompt) and suggests up to 4 short replies, in your language.
  • Several decisions at once. When Claude asks about more than one thing, each gets its own row of answers. Pick one per row and press Send, or use a combined pill like Do all or Skip all.

one row per decision, with combined pills on top

  • Cheap. Haiku is only asked when the reply actually asks or offers something; a plain report shows no pills and costs nothing. Each ask is a few hundred Haiku tokens.
  • Out of the way. Pills hide while Claude is working and clear when you send anything. ✕ dismisses them.
  • Free extra. With Claude Code's prompt suggestions on, its own guess at your next prompt joins the pills.

Settings (/config, or pluginConfigs in settings): mode is ai (default), rules (simple patterns, no tokens) or off; model is haiku (default) or sonnet; place is inline (default: under the reply, in the desktop app) or band (above the prompt, as the terminal always does).

Works in the terminal and the desktop app's Code tab. It shares the band above the prompt with limits: bars on top, pills underneath.

Install

Run this in a terminal claude session:

/plugin install quick-replies --marketplace Ryan-LT/claude-mods

Answer y to add the marketplace, then choose the user scope.

Source 2 files
hooks/register.tsx 339 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Suggestions } from '../types'
4
5const suggestions = atom({ plugin: 'quick-replies', key: 'suggestions' } as const, null)
6
7const MAX_REPLIES = 4
8const MAX_LENGTH = 40
9const MAX_DECISIONS = 4
10/** Replies under one decision: its own row has less room. */
11const MAX_PER_DECISION = 3
12const MAX_TOPIC = 24
13/** How much of the reply's end is read: where the question or the offer is. */
14const TAIL = 4000
15const ASK_TIMEOUT = 10_000
16
17/** The reply asks, offers or waits on the person. */
18const ASKS =
19  /\?|\b(want me to|should i|shall i|would you like|do you want|let me know|confirm|go ahead|ready to|prefer|approve|ok to|okay to|your call|tell me to|either)\b/i
20/** The reply hands the person a step to do themselves. */
21const HANDS_OFF =
22  /```(bash|sh|zsh|shell)\b|\b(yourself|on your end|manually|in your terminal|and let me know|then tell me)\b/i
23const YES_NO = /\b(want me to|should i|shall i|do you want|would you like|ok to|okay to|ready to|is that ok|sound good)\b[^?]*\?/i
24const CONFIRM = /\b(confirm|proceed|go ahead)\b[^?]*\?/i
25/** A numbered choice, its name: `1. **Name** — why` or `2) Name: why`. */
26const CHOICE = /^\s*(\d)[.)]\s+(?:\*\*([^*\n]{1,40})\*\*|([^\n:—–(]{1,40}))/gm
27/** Work waiting on the person's go-ahead: `Ready to merge`. */
28const READY = /\bready (?:to|for) (merge|deploy|ship|release|review)\b/i
29/** A numbered line that is itself a question: one decision of several. */
30const QUESTION_ITEM = /^\s*\d[.)]\s+(.+\?)\s*$/gm
31/** The ask around a question, dropped to leave its subject. */
32const ASK_WORDS =
33  /^(?:(?:should|shall|can|could) i|(?:do you )?want me to|would you like (?:me )?to|do you want|ok to|okay to|also|and|then|to)\s+/i
34
35const SYSTEM = `You write the quick-reply buttons of a chat app where a person works with an AI coding assistant.
36Given the assistant's latest message, return the replies the person is most likely to send next, best first.
37Read the whole message, not only its last line, and find what it leaves to the person:
38- Work it reports as ready for their go-ahead, even with no question: "Ready to merge", "ready for review", "ready to deploy", a suggested order or next step. The reply gives the go-ahead: "Merge them", "Merge in order", "Deploy it".
39- Questions and offers: "Want me to…?", "Either X yourself, or tell me to Y". Yes/no: "Yes" / "No". Named options: the options themselves, short.
40- Steps it hands to the person (run, check, open, restart, verify): "Do it for me" or "Check it for me".
41Put first the reply that moves the main work forward, usually the go-ahead on what the message is mostly about, before a side question at its end.
42- Each reply 1 to 5 words, written as the person would type it, in their voice ("I'll add the keys", not "Add keys yourself"), in the language the person writes in, and specific to this message ("Merge in order", "Fail with clear error"), not generic.
43- No thanks, no greetings, nothing already answered. Skip things that wait on someone else and need nothing from the person.
44- If nothing needs a reply (a finished report with no go-ahead, question, offer or step for the person), return [].
45One decision: return a JSON array of at most ${MAX_REPLIES} strings.
46Two or more independent decisions (work ready to ship and a separate question, numbered questions, several offers): return an object instead:
47{"decisions":[{"topic":"Deploy","replies":["Deploy now","Not yet"]},{"topic":"Changelog","replies":["Write it","Skip it"]}],"combined":["Do both","Deploy only","Skip both"]}
48- One decision per thing, main work first, at most ${MAX_DECISIONS}. "topic" names it in 1 to 3 words; "replies" are 2 or 3 answers to that one thing.
49- "combined" answers several at once, best first, at most 3: "Do both" / "Do all", or the likely mix ("Deploy only", "1 and 3").
50Return only the JSON.`
51
52type Decision = { topic: string; replies: string[] }
53/** What one reply offers: the pills, and its separate decisions when it asks several things. */
54export type Offer = { replies: string[]; decisions?: Decision[]; answer?: string }
55
56const clean = (items: unknown[], max = MAX_REPLIES): string[] => {
57  const seen = new Set<string>()
58  const out: string[] = []
59  for (const item of items) {
60    if (typeof item !== 'string') continue
61    const text = item.replace(/\s+/g, ' ').trim()
62    const id = text.toLowerCase()
63    if (!text || text.length > MAX_LENGTH || seen.has(id)) continue
64    seen.add(id)
65    out.push(text)
66  }
67  return out.slice(0, max)
68}
69
70const cleanDecisions = (items: unknown[]): Decision[] =>
71  items
72    .flatMap(item => {
73      if (typeof item !== 'object' || item === null) return []
74      const { topic, replies } = item as { topic?: unknown; replies?: unknown }
75      const name = typeof topic === 'string' ? topic.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim() : ''
76      const answers = Array.isArray(replies) ? clean(replies, MAX_PER_DECISION) : []
77      return name && name.length <= MAX_TOPIC && answers.length > 0 ? [{ topic: name, replies: answers }] : []
78    })
79    .slice(0, MAX_DECISIONS)
80
81/** A question's subject in a few words: `Should I also update the README?` → `update the README`; a bold lead names it. */
82export const topicOf = (question: string): string => {
83  const named = question.match(/^\*\*([^*]{1,24})\*\*/)?.[1]?.replace(/[:.]$/, '').trim()
84  if (named) return named
85  let text = question.replace(/[*`?]/g, '').trim()
86  for (let prev = ''; prev !== text; ) {
87    prev = text
88    text = text.replace(ASK_WORDS, '')
89  }
90  const words = text.split(/\s+/).slice(0, 4)
91  while (words.length > 1 && words.join(' ').length > MAX_TOPIC) words.pop()
92  return words.join(' ').slice(0, MAX_TOPIC)
93}
94
95/** Replies from patterns alone: no model call. */
96export const byRules = (answer: string): Offer => {
97  const questions = [...answer.slice(-TAIL).matchAll(QUESTION_ITEM)].map(m => topicOf(m[1] ?? ''))
98  if (questions.length >= 2) {
99    const decisions = cleanDecisions(questions.map(topic => ({ topic, replies: ['Yes', 'No'] })))
100    if (decisions.length >= 2) return { replies: ['Yes to all', 'No to all'], decisions }
101  }
102  const end = answer.slice(-800)
103  const out: string[] = []
104  if (CONFIRM.test(end)) out.push('Confirm', 'Cancel')
105  else if (YES_NO.test(end)) out.push('Yes', 'No')
106  if (end.includes('?')) {
107    for (const m of end.matchAll(CHOICE)) out.push((m[2] ?? m[3] ?? '').replace(/[*`]/g, '').trim())
108  }
109  if (HANDS_OFF.test(end)) out.push('Check it for me')
110  const ready = answer.slice(-TAIL).match(READY)?.[1]?.toLowerCase()
111  if (ready) out.unshift(ready === 'review' ? 'Review it' : `${ready[0]!.toUpperCase()}${ready.slice(1)} it`)
112  return { replies: clean(out) }
113}
114
115const parseJson = (text: string | undefined): unknown => {
116  if (!text) return undefined
117  try {
118    return JSON.parse(text)
119  } catch {
120    return undefined
121  }
122}
123
124/**
125 * The model's reply: a JSON array of replies, or an object of decisions; null
126 * when it is neither. One decision alone is just its replies.
127 */
128export const parseReplies = (text: string): Offer | null => {
129  const object = parseJson(text.match(/\{[\s\S]*\}/)?.[0])
130  if (typeof object === 'object' && object !== null && 'decisions' in object) {
131    const { decisions, combined } = object as { decisions?: unknown; combined?: unknown }
132    const found = Array.isArray(decisions) ? cleanDecisions(decisions) : []
133    const together = Array.isArray(combined) ? clean(combined, 3) : []
134    if (found.length >= 2) return { replies: together, decisions: found }
135    if (found.length === 1) return { replies: found[0]!.replies }
136  }
137  const list = parseJson(text.match(/\[[\s\S]*\]/)?.[0])
138  return Array.isArray(list) ? { replies: clean(list) } : null
139}
140
141export const isWorthAsking = (answer: string): boolean => {
142  const end = answer.slice(-TAIL)
143  return ASKS.test(end) || HANDS_OFF.test(end)
144}
145
146const byModel = async ($: EngineInterface, answer: string, prompt: string): Promise<Offer | null> => {
147  const asked = prompt ? `The person's previous message:\n<person>\n${prompt.slice(-800)}\n</person>\n\n` : ''
148  const result = await $.model.complete({
149    model,
150    system: SYSTEM,
151    prompt: `${asked}The assistant's latest message${answer.length > TAIL ? ' (its end)' : ''}:\n<assistant>\n${answer.slice(-TAIL)}\n</assistant>`,
152    maxTokens: 300,
153    timeoutMs: ASK_TIMEOUT,
154  })
155  return result.isAnswered ? parseReplies(result.text) : null
156}
157
158/** The main loop's newest turn and the prompt it began with; a reload resets them, which is harmless. */
159let latestTurn: string | undefined
160let latestPrompt = ''
161let mode = 'ai'
162let model = 'haiku'
163let place = 'inline'
164
165const suggest = async ($: EngineInterface, turnId: string, answer: string) => {
166  if (!answer.trim() || !isWorthAsking(answer)) return
167  const found = mode === 'rules' ? byRules(answer) : ((await byModel($, answer, latestPrompt)) ?? byRules(answer))
168  if ((found.replies.length === 0 && !found.decisions) || (latestTurn !== undefined && latestTurn !== turnId)) return
169  await offer($, turnId, { ...found, answer })
170}
171
172/** Adds replies to the ones offered for this turn: ahead of them, or after with `isLast`. */
173const offer = ($: EngineInterface, turnId: string, found: Offer, isLast = false) =>
174  update($, suggestions, current => {
175    const same = current?.turnId === turnId ? current : null
176    const kept = same?.replies ?? []
177    const decisions = found.decisions ?? same?.decisions
178    const answer = found.answer ?? same?.answer
179    return {
180      turnId,
181      ...(answer ? { answer: answer.slice(-TAIL) } : {}),
182      replies: clean(isLast ? [...kept, ...found.replies] : [...found.replies, ...kept]),
183      ...(decisions ? { decisions, picks: found.decisions ? decisions.map(() => -1) : (same?.picks ?? decisions.map(() => -1)) } : {}),
184    }
185  })
186
187/** Picks reply `j` of decision `i`, or takes the pick back when it is the one picked. */
188const pick = ($: EngineInterface, i: number, j: number) =>
189  update($, suggestions, current => {
190    if (!current?.picks) return current
191    const picks = current.picks.map((p, k) => (k === i ? (p === j ? -1 : j) : p))
192    return { ...current, picks }
193  })
194
195/** The picked answers as one message: a line per decision answered. */
196export const composed = (decisions: Decision[], picks: number[]): string =>
197  decisions
198    .flatMap((d, i) => {
199      const reply = d.replies[picks[i] ?? -1]
200      return reply ? [`${d.topic}: ${reply}`] : []
201    })
202    .join('\n')
203
204const send = async ($: EngineInterface, text: string) => {
205  await update($, suggestions, () => null)
206  await $.prompt.submit({ text, asUser: true })
207}
208
209const isOffering = (offered: Suggestions | null): offered is Suggestions =>
210  !!offered && (offered.replies.length > 0 || !!offered.decisions)
211
212/** The block that ends the reply: its last words are the answer's. */
213export const endsReply = (answer: string | undefined, block: string): boolean => {
214  const end = block.trimEnd().slice(-200)
215  return !!answer && end.length >= 8 && answer.trimEnd().endsWith(end)
216}
217
218type Ui = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Button' | 'Text'>
219
220/** The pills: a row of replies, and a row per decision when the reply asks several things. */
221const replyTree = ($: EngineInterface, { Box, Button, Text }: Ui, offered: Suggestions) => {
222  const { decisions, picks = [] } = offered
223  const picked = picks.filter(p => p >= 0).length
224  const head = (
225    <Box key="quick-replies" flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1} alignItems="center">
226      <Text dimColor>↩ Reply</Text>
227      {offered.replies.map((reply, i) => (
228        <Button
229          key={`reply-${i}`}
230          label={reply}
231          {...(i === 0 && !decisions ? { variant: 'primary' as const } : {})}
232          onPress={() => void send($, reply)}
233        />
234      ))}
235      {decisions && picked > 0 ? (
236        <Button
237          key="send"
238          label={`Send ${picked} ${picked === 1 ? 'answer' : 'answers'}`}
239          variant="primary"
240          onPress={() => void send($, composed(decisions, picks))}
241        />
242      ) : null}
243      <Button key="dismiss" label="✕" role="dismiss" plain dimColor onPress={() => void update($, suggestions, () => null)} />
244    </Box>
245  )
246  if (!decisions) return head
247  // Several decisions: a row each, a pill per answer; picks add up to one message.
248  const labelWidth = Math.max(...decisions.map(d => d.topic.length)) + 4
249  return (
250    <Box key="quick-replies-decisions" flexDirection="column" rowGap={1}>
251      {head}
252      {decisions.map((d, i) => (
253        <Box key={`decision-${i}`} flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1} alignItems="center">
254          <Box minWidth={labelWidth}>
255            <Text dimColor>{`${i + 1}. ${d.topic}`}</Text>
256          </Box>
257          {d.replies.map((reply, j) => (
258            <Button
259              key={`pick-${i}-${j}`}
260              label={picks[i] === j ? `✓ ${reply}` : reply}
261              {...(picks[i] === j ? { variant: 'primary' as const } : {})}
262              onPress={() => void pick($, i, j)}
263            />
264          ))}
265        </Box>
266      ))}
267    </Box>
268  )
269}
270
271export const register: Register = (on, options) => {
272  mode = String(options.mode ?? 'ai')
273  model = String(options.model ?? 'haiku')
274  place = String(options.place ?? 'inline')
275
276  on('turn.start', async ($, e, next) => {
277    latestTurn = e.turnId
278    if (e.text) latestPrompt = e.text
279    await update($, suggestions, () => null)
280    return next(e)
281  })
282
283  on('turn.complete', async ($, e, next) => {
284    const result = await next(e)
285    if (mode === 'off' || e.agentId !== undefined || e.reason !== 'answer') return result
286    const { turnId, answer } = e
287    // Off the turn's own dispatch, so its end never waits on the suggestion.
288    $.clock.after(0, () => void suggest($, turnId, answer))
289    return result
290  })
291
292  // Claude Code's own guess at the next prompt (prompt suggestions on) is one more pill, at no cost.
293  on('prompt.suggest', async ($, e, next) => {
294    const result = await next(e)
295    if (mode !== 'off' && e.origin?.kind === 'suggestion' && latestTurn !== undefined) {
296      await offer($, latestTurn, { replies: [e.text] }, true)
297    }
298    return result
299  })
300
301  on('session.end', async ($, e, next) => {
302    await update($, suggestions, () => null)
303    return next(e)
304  })
305
306  // The terminal's transcript rows do not take presses, so its replies stay in the band.
307  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
308    const below = await next(e)
309    if (place === 'inline' && e.surface !== 'terminal') return below
310    if (e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) return below
311    const offered = await read($, suggestions)
312    if (!isOffering(offered)) return below
313    const ui = $.ui.resolve(e)
314    const row = replyTree($, ui, offered)
315    // Another band (the limits bar) keeps its place above the replies.
316    return below.type === 'engine' ? row : (
317      <ui.Box flexDirection="column" rowGap={1}>
318        {below}
319        {row}
320      </ui.Box>
321    )
322  })
323
324  // Under the reply's last block, where the reading ends.
325  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
326    const below = await next(e)
327    if (place !== 'inline' || e.surface === 'terminal' || e.props.isSummary) return below
328    const offered = await read($, suggestions)
329    if (!isOffering(offered) || !endsReply(offered.answer, e.props.text)) return below
330    const ui = $.ui.resolve(e)
331    return (
332      <ui.Box flexDirection="column" rowGap={1}>
333        {below}
334        <ui.Box marginTop={1}>{replyTree($, ui, offered)}</ui.Box>
335      </ui.Box>
336    )
337  })
338}
339
types/index.d.ts 10 lines
1export type Decision = { topic: string; replies: string[] }
2/** The replies offered for one finished turn: `decisions` when one reply asks several things; `picks` the answer index per decision, -1 for none yet; `answer` the reply's end, to find the block they go under. */
3export type Suggestions = { turnId: string; replies: string[]; decisions?: Decision[]; picks?: number[]; answer?: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'quick-replies': { suggestions: Suggestions | null }
8  }
9}
10