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…

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.

Send, or use a combined pill like Do all or Skip all.
✕ dismisses them.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.
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.
hooks/register.tsx 339 lines1import { 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}
339types/index.d.ts 10 lines1export 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