SLOPSHOPPER

Suggest

Clickable suggested replies under Claude's last message

newspinnerrowscommandstatusprompt
★ 1v1.0.8MITupdated 2026-10-25dophamine/claude-suggest
A shopper browsing a rack in a slop shop
README

Claude Suggest

Clickable suggested replies for Claude Code.<br> When Claude finishes a reply, the answers you'd most likely give show as buttons under it. Click one to send it.

Version CI Claude Code mod validates License: MIT

<img src="assets/demo-send.svg" alt="Suggested replies as buttons under Claude's last message; clicking one sends it" width="630">

<img src="assets/demo.gif" alt="Screen recording of Suggest in a Claude Code session" width="630">

Why

Most of what you type to Claude between turns is short and predictable: "yes, go ahead", "run the tests", "option 2", /compact. Claude Code already shows one grey guess in the prompt, which Tab accepts. Suggest is a mod that turns that guess, plus a couple more, into buttons under Claude's last message, so answering takes one click.

Install

Needs Claude Code 2.1.287 or later, the release that brought mods; tested on 2.1.309. Mods are early access: their API may change between releases.

/plugin marketplace add dophamine/claude-suggest
/plugin install suggest@claude-suggest
claude plugin marketplace add dophamine/claude-suggest
claude plugin install suggest@claude-suggest

If a session is already open, run /reload-plugins in it.

The buttons show in the terminal and in the Desktop app's Code tab. Under Remote Control they show in the terminal on your machine, not in claude.ai or the mobile app.

How it works

  1. It remembers the last few messages. It keeps the 8 most recent messages in total, your prompts and Claude's final answers counted together. In a normal back-and-forth that is about 4 of each. A message longer than 2,500 characters keeps only its last 2,500. Subagent replies, task notifications, scheduled prompts and messages from other sessions are left out. This lives in the session's memory only and is never written to disk.
  2. Claude finishes a turn. The first button is Claude Code's own grey prompt suggestion, the one Tab accepts, marked with a faint ✻. It comes from Claude Code itself and costs nothing extra. If it arrives after the model's buttons are already showing, it still moves to the top.
  3. It decides whether to ask a model for more. In smart mode (the default) it does so only when the end of Claude's reply asks something or offers something: a ?, or phrases like "should I", "want me to", "would you like", "let me know". In auto mode it asks after every reply, in manual mode never on its own.
  4. A small model writes the rest. Claude Haiku (by default, at low effort) reads the recent messages, newest first, up to 6,000 characters, and returns up to 3 replies written as you would write them. Claude Code's own suggestion goes first and the list is cut to 3, so when Claude Code has none, or repeats one of the model's, every button is still filled. When the next reply isn't clear the model returns none, and you keep the one free button. While it works, a dim "more suggestions…" line shows. It runs in the background, so the session never waits for it.
  5. The replies are cleaned up. "Run /compact" becomes the bare /compact, so clicking it runs it. Duplicates, including ones that differ only in case or punctuation, and bare numbers are dropped. If Claude Code had no grey suggestion of its own, the model's first reply becomes that grey suggestion, so Tab accepts it too.
  6. You click. A plain reply is sent as your next prompt. A slash command is run, and one that doesn't exist is put in the input instead. A ! shell command is put in the input without its !, with a dim tip: add ! at the beginning to run it in your shell, or press Enter to ask Claude. Type ! first and then click, and it goes straight into the shell prompt, so Enter runs it. The dim ✎ next to a reply puts it in the input to edit before you press Enter.
  7. The buttons go away as soon as you send any prompt, and a late model answer never lands on a newer reply.

Use

  • Click a reply to send it, or ✎ to edit it first.
  • /suggest shows buttons under Claude's last reply now, in any mode. Nothing is sent to the session.
  • /suggest <hint> steers them, like /suggest shorter or /suggest about testing. All buttons then come from the model.
  • /suggest auto, /suggest smart or /suggest manual switches the mode.

✎ puts a reply in the input, to edit before you send it.

<img src="assets/demo-edit.svg" alt="Clicking the pencil fills the input with the reply, which is then extended and sent" width="630">

A slash command runs when clicked.

<img src="assets/demo-slash.svg" alt="Clicking the /compact button runs /compact" width="630">

A shell command goes in the input, ready for !.

<img src="assets/demo-shell.svg" alt="Clicking !npm ci puts npm ci in the input with a tip; typing ! runs it in the shell" width="630">

/suggest <hint> replaces the buttons with replies about the hint.

<img src="assets/demo-hint.svg" alt="Typing /suggest about testing replaces the buttons with replies about testing" width="630">

Settings

Change them in /config (rows starting with "Suggest ›"). The mod reloads with the new values, no restart needed. In the setup dialog after install, number fields start empty: leave them empty to keep the default.

SettingDefaultWhat it does
modesmartClaude Code's own suggestion always shows (free). auto: extra replies after every reply. smart: only when Claude asks or offers something. manual: only when you run /suggest.
clicksendsend: a click sends (✎ fills the input). fill: a click fills the input, nothing is sent until you press Enter.
maxReplies3Buttons shown, 1 to 5. 1 keeps only Claude Code's own suggestion and makes no model call.
modelhaikuModel for the extra replies: haiku, sonnet or opus
effortlowHow hard that model thinks: low, medium, high, xhigh or max
contextChars6000How much recent conversation the model reads, 500 to 50,000
messageChars2500Longer messages keep only their last part, 200 to 20,000

They're stored in ~/.claude/settings.json under pluginConfigs["suggest"].options.

Cost

Claude Code's own suggestion is free. Each extra set of replies is one small model call (Haiku at low effort by default) on your plan or API key. In smart mode that's only after replies that ask or offer something. Set mode to manual or maxReplies to 1 to make none.

Privacy and safety

To write the extra replies, Suggest sends the recent messages (up to contextChars, 6,000 characters by default) to the model you picked, through Claude Code's own model call, on the same account as your session. Nothing goes anywhere else: it makes no other network requests, starts no processes, and reads or writes no files. A click sends only the text on the button, or runs the slash command it names.

To check this yourself, run claude plugin validate on the folder: its calls: line lists everything the mod asks Claude Code to do.

Develop

git clone https://github.com/dophamine/claude-suggest
cd claude-suggest
claude plugin validate .
claude plugin test .
npx prettier@3.3.3 --write .
claude --plugin-dir .

The animated demos in assets/demo-*.svg are drawn by python3 scripts/demo-svgs.py. Edit the scenes there and run it again to update them.

Claude Code writes the type declarations to .claude-plugin/types/ when it loads the folder. Issues and pull requests are welcome.

License

MIT. Use it freely, adapt it to your workflow.

Star this repo if it helps.

Source 3 files
hooks/register.tsx 340 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Buttons, LastReply, Message } from '../types'
5import {
6  clip,
7  formatTranscript,
8  invitesReply,
9  isAnswerTail,
10  isShellCommand,
11  mergeReplies,
12  normalizeSpace,
13  parseReplies,
14  parseSlashCommand,
15  replyKey,
16  systemPrompt,
17  toBareCommand,
18} from './text'
19
20// Messages kept for the model to read, prompts and answers together.
21const RECENT_MESSAGES = 8
22// Longest `/suggest <hint>` passed on to the model.
23const HINT_CHARS = 200
24const MODEL_TIMEOUT_MS = 20_000
25const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
26// Prompts that don't come from the person aren't part of the conversation the model reads.
27const NON_PERSON_ORIGINS = new Set(['task-notification', 'scheduled-trigger', 'peer'])
28// Drawn dim after a `!` shell command that was put in the input without its `!`.
29const SHELL_TIP = '  (add ! at the beginning to run it in your shell, or press Enter to ask Claude)'
30
31// Kept in session state, not module variables, so a settings change (which reloads the mod) keeps them.
32const buttons = atom({ plugin: 'suggest', key: 'buttons' } as const, null as Buttons | null)
33const recent = atom({ plugin: 'suggest', key: 'recent' } as const, [] as Message[])
34const lastReply = atom({ plugin: 'suggest', key: 'lastReply' } as const, null as LastReply | null)
35
36type Settings = {
37  mode: 'auto' | 'smart' | 'manual'
38  isFillOnClick: boolean
39  maxReplies: number
40  model: string
41  effort: (typeof EFFORTS)[number]
42  contextChars: number
43  messageChars: number
44}
45
46// Bumped on every prompt and every suggestion run, so a model answer that arrives late never lands on newer buttons.
47type Generation = { current: number }
48
49// What the person asked for when buttons are shown.
50type Request = {
51  // Show only the free buttons, without calling the model.
52  isFree?: boolean
53  // Steers the model, from `/suggest <hint>`, and leaves Claude Code's own suggestion out.
54  hint?: string
55}
56
57const readSettings = (options: Parameters<Register>[1]): Settings => ({
58  mode: options.mode === 'auto' || options.mode === 'manual' ? options.mode : 'smart',
59  isFillOnClick: options.click === 'fill',
60  maxReplies: Math.max(1, Math.floor(Number(options.maxReplies) || 3)),
61  model: String(options.model || 'haiku'),
62  effort: EFFORTS.find(effort => effort === options.effort) ?? 'low',
63  contextChars: Math.max(500, Number(options.contextChars) || 6000),
64  messageChars: Math.max(200, Number(options.messageChars) || 2500),
65})
66
67async function rememberMessage($: EngineInterface, message: Message, messageChars: number) {
68  const clipped = { ...message, text: clip(message.text, messageChars) }
69  await update($, recent, messages => [...messages, clipped].slice(-RECENT_MESSAGES))
70}
71
72// How many replies to ask the model for; 0 means no model call.
73function modelReplyCount(settings: Settings, request: Request) {
74  if (request.isFree) return 0
75  // A single button is Claude Code's own suggestion, unless a hint replaces it.
76  if (settings.maxReplies === 1 && !request.hint) return 0
77
78  // Asks for every button, not just the ones left: Claude Code's suggestion may never come or may repeat
79  // one of the model's, and the merge puts it first and cuts the list to the limit anyway.
80  return settings.maxReplies
81}
82
83async function askModel($: EngineInterface, settings: Settings, prompt: string, count: number) {
84  const result = await $.model
85    .complete({
86      model: settings.model,
87      system: systemPrompt(count),
88      prompt,
89      maxTokens: settings.effort === 'low' ? 300 : 4000,
90      effort: settings.effort,
91      timeoutMs: MODEL_TIMEOUT_MS,
92    })
93    .catch(() => null)
94
95  return result?.isAnswered ? parseReplies(result.text).slice(0, count) : []
96}
97
98async function addModelReplies(
99  $: EngineInterface,
100  settings: Settings,
101  generation: Generation,
102  runId: number,
103  prompt: string,
104  count: number,
105) {
106  const replies = await askModel($, settings, prompt, count)
107  if (runId !== generation.current) return
108  await update($, buttons, shown =>
109    shown ? { ...shown, replies: mergeReplies(shown.replies, replies, settings.maxReplies), isLoading: false } : shown,
110  )
111
112  // Without a suggestion from Claude Code, the model's first reply becomes the dim one in the input.
113  // Taken with Tab, unlike a fill, a `!` shell command there switches on shell mode.
114  const engineSuggestion = (await read($, lastReply))?.engineSuggestion
115  if (!engineSuggestion && replies[0]) void $.prompt.suggest({ text: replies[0] })
116}
117
118// Shows buttons under the last reply: Claude Code's own suggestion right away, then the model's replies
119// once they come. Returns false when there is no reply to suggest for yet.
120async function showButtons($: EngineInterface, settings: Settings, generation: Generation, request: Request = {}) {
121  const reply = await read($, lastReply)
122  if (!reply) return false
123
124  generation.current += 1
125  const runId = generation.current
126  const free = reply.engineSuggestion && !request.hint ? [reply.engineSuggestion] : []
127  const count = modelReplyCount(settings, request)
128  await update($, buttons, () => ({ answer: reply.answer, replies: free, isLoading: count > 0 }))
129  if (count === 0) return true
130
131  const transcript = formatTranscript(await read($, recent), settings.contextChars, settings.messageChars)
132  const hint = request.hint ? `\n\nThe user asked for replies that are: ${request.hint.slice(0, HINT_CHARS)}` : ''
133  // Runs after this dispatch, so the hook returns at once and the model call can outlive it.
134  $.clock.after(0, () => addModelReplies($, settings, generation, runId, transcript + hint, count))
135
136  return true
137}
138
139// Puts a reply in the input to edit; Enter sends it. `isShellMode` says the person already typed `!`.
140async function fillInput($: EngineInterface, text: string, isShellMode = false) {
141  if (!isShellCommand(text)) {
142    await $.prompt.fill({ text })
143    return
144  }
145
146  const command = text.slice(1).trim()
147  // Inserted into the prompt shell mode already holds, the command keeps the mode, so Enter runs it. A replace
148  // would drop the mode and Enter would send the command to Claude. The hint line can lag behind the prompt, so
149  // the prompt is read too: an insert into a draft would land in the middle of it.
150  if (isShellMode && (await $.prompt.read()).text === '') {
151    await $.prompt.fill({ text: command, mode: 'insert' })
152    return
153  }
154
155  // A filled `!` can't switch on shell mode, but typing one does. So the command goes in without its `!`,
156  // with the caret at the start where the `!` goes, and a dim tip after it. The tip is not part of the draft
157  // and goes away at the next edit.
158  await $.prompt.fill({
159    text: command,
160    cursor: 0,
161    decorations: [
162      {
163        start: command.length,
164        end: command.length,
165        virtualText: SHELL_TIP,
166        color: 'subtle',
167        dimColor: true,
168        italic: true,
169      },
170    ],
171  })
172}
173
174// Running a command sends no prompt, so the input keeps showing Claude Code's dim suggestion. An edit clears it,
175// but only an empty input is touched: a draft the person typed stays.
176async function clearInputSuggestion($: EngineInterface) {
177  if ((await $.prompt.read()).text !== '') return
178  await $.prompt.fill({ text: ' ' })
179  await $.prompt.fill({ text: '' })
180}
181
182// Sends a reply as the person's own words, not as the plugin's message, or runs it when it is a slash command.
183async function sendReply($: EngineInterface, text: string, isShellMode = false) {
184  await update($, buttons, () => null)
185
186  // A plugin's prompt can't run in shell mode, so a `!` shell command goes to the input, never to Claude.
187  if (isShellCommand(text)) {
188    await fillInput($, text, isShellMode)
189    return
190  }
191
192  const command = parseSlashCommand(text)
193  if (!command) {
194    await $.prompt.submit({ text, asUser: true })
195    return
196  }
197
198  const ran = await $.command.run(command).then(
199    () => true,
200    () => false,
201  )
202  if (ran) {
203    await clearInputSuggestion($)
204  } else {
205    // An unknown command goes to the input to edit.
206    await $.prompt.fill({ text })
207  }
208}
209
210export const register: Register = (on, options) => {
211  const settings = readSettings(options)
212  const generation: Generation = { current: 0 }
213  // The prompt events never say whether the prompt is in shell mode, but the hint line under the empty prompt
214  // does: it reads "! for shell mode" while it is in it. Kept per surface, since each draws its own hint line.
215  const shellModeSurfaces = new Set<string>()
216
217  on('session.start', async ($, e, next) => {
218    // A reload drops a model call in flight, which would leave "more suggestions…" up forever.
219    await update($, buttons, shown => (shown?.isLoading ? { ...shown, isLoading: false } : shown))
220    // A status line stays pinned across a reload. This version pins none, so clear one an older version left.
221    $.ui.status(undefined)
222    await $.command.register({
223      name: 'suggest',
224      description: 'Show suggested replies now (optionally steered: /suggest shorter), or set the mode',
225      argumentHint: '[hint | auto | smart | manual]',
226    })
227
228    return next(e)
229  })
230
231  on('command.run', { command: 'suggest' }, async ($, e) => {
232    const arg = e.args.trim()
233    const mode = arg.toLowerCase()
234    if (mode === 'auto' || mode === 'smart' || mode === 'manual') {
235      const { deny } = await $.config.set({ key: 'suggest.mode', value: mode })
236
237      return { text: deny ? `Couldn't switch mode: ${deny}` : `Suggest: ${mode} mode` }
238    }
239
240    // Nothing goes to the session: the buttons show under the last reply.
241    const isShown = await showButtons($, settings, generation, { hint: arg || undefined })
242
243    return isShown ? {} : { text: 'No reply to suggest for yet.' }
244  })
245
246  on('prompt.submit', async ($, e, next) => {
247    // A sent prompt leaves shell mode, whether or not the hint line redraws before the next click.
248    shellModeSurfaces.clear()
249    // `/suggest` reads the last reply, so its own prompt must not clear it.
250    if (/^\/suggest\b/.test(e.text.trim())) return next(e)
251
252    generation.current += 1
253    // Errors are swallowed: the buttons must never hold up the prompt.
254    if (!NON_PERSON_ORIGINS.has(e.origin.kind) && e.text.trim()) {
255      await rememberMessage($, { role: 'user', text: e.text }, settings.messageChars).catch(() => undefined)
256    }
257    await update($, buttons, () => null).catch(() => undefined)
258    await update($, lastReply, () => null).catch(() => undefined)
259
260    return next(e)
261  })
262
263  on('turn.complete', async ($, e, next) => {
264    const result = await next(e)
265    if (e.agentId !== undefined || e.reason !== 'answer' || !e.answer.trim()) return result
266
267    await rememberMessage($, { role: 'assistant', text: e.answer }, settings.messageChars)
268    await update($, lastReply, () => ({ answer: e.answer }))
269    // Every mode shows the free suggestion. Auto also asks the model every time, smart only when the answer
270    // invites a reply, and manual never.
271    const isFree = settings.mode === 'manual' || (settings.mode === 'smart' && !invitesReply(e.answer))
272    await showButtons($, settings, generation, { isFree })
273
274    return result
275  })
276
277  // Claude Code's own dim suggestion in the input becomes the first button, even after the model's replies show.
278  // It is marked as Claude's, so it reads as such when it moves to the top.
279  on('prompt.suggest', { origin: { kind: 'suggestion' } }, async ($, e, next) => {
280    const text = toBareCommand(normalizeSpace(e.text))
281    if (text) {
282      await update($, lastReply, reply => (reply ? { ...reply, engineSuggestion: text } : reply))
283      await update($, buttons, shown =>
284        shown ? { ...shown, replies: mergeReplies([text], shown.replies, settings.maxReplies) } : shown,
285      )
286    }
287
288    return next(e)
289  })
290
291  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
292    if (e.props.hint.includes('shell mode') && !e.props.isDraft) shellModeSurfaces.add(e.surface)
293    else shellModeSurfaces.delete(e.surface)
294
295    return next(e)
296  })
297
298  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
299    const shown = await read($, buttons)
300    if (!shown || e.props.isSummary || !isAnswerTail(shown.answer, e.props.text)) return next(e)
301    if (!shown.isLoading && shown.replies.length === 0) return next(e)
302
303    const engineSuggestion = (await read($, lastReply))?.engineSuggestion
304    const isEngineSuggestion = (text: string) =>
305      engineSuggestion !== undefined && replyKey(engineSuggestion) === replyKey(text)
306    // Shell mode is read at the press, not the draw: the person may type `!` after the buttons show.
307    const isShellMode = () => shellModeSurfaces.has(e.surface)
308    const onPress = (text: string) => () =>
309      settings.isFillOnClick ? fillInput($, text, isShellMode()) : sendReply($, text, isShellMode())
310
311    const { Box, Button, Text } = $.ui.resolve(e)
312
313    return (
314      <Box flexDirection="column">
315        {await next(e)}
316        <Box flexDirection="column" marginTop={1} gap={1}>
317          {shown.replies.map((text, i) => (
318            <Box key={`row-${i}`} flexDirection="row" gap={1}>
319              {/* Claude Code's own suggestion gets a dim ✻; every other row a blank, so the buttons line up. */}
320              <Text dimColor>{isEngineSuggestion(text) ? '✻' : ' '}</Text>
321              <Button
322                key={`reply-${i}`}
323                label={text}
324                variant={i === 0 ? 'primary' : 'secondary'}
325                onPress={onPress(text)}
326              />
327              {settings.isFillOnClick ? null : (
328                <Button key={`edit-${i}`} label="✎" plain dimColor onPress={() => fillInput($, text, isShellMode())} />
329              )}
330            </Box>
331          ))}
332          {shown.isLoading ? (
333            <Text dimColor>{shown.replies.length ? 'more suggestions…' : 'suggesting replies…'}</Text>
334          ) : null}
335        </Box>
336      </Box>
337    )
338  })
339}
340
hooks/text.ts 115 lines
1import type { Message } from '../types'
2
3// Pure text helpers: the prompt the model reads, parsing its answer, and comparing replies.
4// Nothing here calls Claude Code, so each one can be tested on its own.
5
6// Longest reply the model is asked to write.
7const REPLY_CHARS = 80
8// How much of the end of the answer must match a rendered block for the buttons to go under it.
9const TAIL_MATCH_CHARS = 200
10// How much of the end of an answer is searched for a question or an offer.
11const INVITE_TAIL_CHARS = 600
12
13export const systemPrompt = (count: number) => `You predict what a user will type next to an AI coding assistant.
14Reply with ONLY a JSON array of 0 to ${count} strings, most likely first.
15Each string is a reply the user could send as-is: written as the user, in the user's language, under ${REPLY_CHARS} characters, no quotes or numbering.
16If the assistant asked a question or offered options, each reply answers it or picks one option. If there is an obvious next step, suggest approving it.
17If the assistant told the user to run a slash command, suggest the bare command itself, starting with "/" (like "/reload-plugins"), never "run /reload-plugins".
18If the next reply is not clear, return []. Fewer good replies beat more guesses.`
19
20// Cuts a long message down to its last `max` characters, because a question or a next step is usually at the end.
21export const clip = (text: string, max: number) => (text.length > max ? '…' + text.slice(-max) : text)
22
23// The recent conversation as the model reads it. Messages are taken newest first until `contextChars` runs out,
24// each clipped to `messageChars`, then shown oldest first.
25export const formatTranscript = (messages: readonly Message[], contextChars: number, messageChars: number) => {
26  const picked: string[] = []
27  let charsLeft = contextChars
28  for (const message of [...messages].reverse()) {
29    if (charsLeft <= 0) break
30    const text = clip(message.text.trim(), Math.min(messageChars, charsLeft))
31    picked.unshift(`<${message.role}>\n${text}\n</${message.role}>`)
32    charsLeft -= text.length
33  }
34
35  return picked.join('\n\n')
36}
37
38export const normalizeSpace = (text: string) => text.replace(/\s+/g, ' ').trim()
39
40// Whether a rendered message block is the last block of `answer`. A long answer renders as several blocks,
41// and the buttons belong under the last one.
42export const isAnswerTail = (answer: string, blockText: string) => {
43  const a = normalizeSpace(answer)
44  const block = normalizeSpace(blockText)
45  if (!a || !block) return false
46
47  return a === block || a.endsWith(block) || block.endsWith(a.slice(-TAIL_MATCH_CHARS))
48}
49
50// Turns an instruction to run a slash command into the command itself, so a click runs it:
51// "run /reload-plugins" and "Please try `/compact now`" become "/reload-plugins" and "/compact now".
52export const toBareCommand = (text: string) =>
53  /^(?:please\s+)?(?:run|type|use|try)\s+`?(\/[\w:-]+(?:\s[^`]*)?)`?$/i.exec(text)?.[1]?.trim() ?? text
54
55// A `!` shell command, as typed to run in shell mode. A bare `!` is not one.
56export const isShellCommand = (text: string) => /^!\s*\S/.test(text)
57
58// An empty reply, or a bare number like "2", says nothing on its own.
59export const isSendable = (text: string) => text !== '' && !/^\d+$/.test(text)
60
61// The model's answer as a list of replies. Anything that isn't a JSON array of strings gives none.
62export const parseReplies = (modelText: string): string[] => {
63  const json = modelText.match(/\[[\s\S]*\]/)?.[0]
64  if (!json) return []
65  try {
66    const list: unknown = JSON.parse(json)
67    if (!Array.isArray(list)) return []
68
69    return list
70      .filter((item): item is string => typeof item === 'string')
71      .map(normalizeSpace)
72      .map(toBareCommand)
73      .filter(isSendable)
74  } catch {
75    return []
76  }
77}
78
79// Splits "/name args" into the shape `$.command.run` takes, or gives null when the text is not a slash command.
80export const parseSlashCommand = (text: string) => {
81  const match = /^\/([\w:-]+)\s*([\s\S]*)$/.exec(text)
82  if (!match?.[1]) return null
83
84  return { command: match[1], args: (match[2] ?? '').trim() }
85}
86
87// Replies that differ only in case or punctuation get the same key, so they count as one reply.
88export const replyKey = (text: string) =>
89  text
90    .toLowerCase()
91    .replace(/[^\p{L}\p{N}/]+/gu, ' ')
92    .trim()
93
94// `first` then `rest`, without empty replies or repeats, cut to `max`.
95export const mergeReplies = (first: readonly string[], rest: readonly string[], max: number) => {
96  const seen = new Set<string>()
97
98  return [...first, ...rest]
99    .filter(isSendable)
100    .filter(reply => {
101      const key = replyKey(reply)
102      if (seen.has(key)) return false
103      seen.add(key)
104
105      return true
106    })
107    .slice(0, max)
108}
109
110// Whether the answer ends on a question or an offer, which is where suggested replies help most.
111export const invitesReply = (answer: string) =>
112  /\?|\b(should i|shall i|want me to|would you like|let me know|which (one|option)|or should)\b/i.test(
113    answer.slice(-INVITE_TAIL_CHARS),
114  )
115
types/index.d.ts 20 lines
1// The buttons under the last reply.
2export type Buttons = {
3  // The final text of the turn the buttons belong to, used to find the message block to draw them under.
4  answer: string
5  replies: string[]
6  // The model has been asked for more replies and hasn't answered yet.
7  isLoading: boolean
8}
9
10export type Message = { role: 'user' | 'assistant'; text: string }
11
12// The last main-loop reply, plus Claude Code's own suggestion for it once that arrives.
13export type LastReply = { answer: string; engineSuggestion?: string }
14
15declare module 'claude-code' {
16  interface PluginState {
17    suggest: { buttons: Buttons | null; recent: Message[]; lastReply: LastReply | null }
18  }
19}
20