SLOPSHOPPER

quick-replies

Clickable quick-reply buttons above the prompt (Continue, Ask me questions, Try again, and your own), configured from a local JSON file, plus a skill that…

newbandcommandtoastprompttimer
v0.1.0MITupdated 2026-10-13jmegan/claude-mods/quick-replies
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-replies
› fix the failing auth test and add an audit log call ⏺ 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 › /qr ⎿ quick-replies: Quick replies (/Users/dev/.claude/quick-replies.json): ⎿ quick-replies: 1. Continue · key 1 · continue · success ⎿ quick-replies: 2. Ask me · key 2 · ask-me · permission ⎿ quick-replies: 3. Yes · key 3 · yes · success ⎿ quick-replies: 4. No [when asked] · key 4 · no · error ⎿ quick-replies: 5. Try again · key 5 · try-again · suggestion ⟨Claude Code's own drawing⟩ 1: qr-continue 2: qr-ask-me 3: qr-yes 5: qr-try-again 6: qr-your-call 7: qr-whats-next 8: qr-status 9: qr-verify s: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 1: qr-continue 2: qr-ask-me 3: qr-yes 5: qr-try-again 6: qr-your-call 7: qr-whats-next 8: qr-status
README

quick-replies

Clickable reply buttons above the Claude Code prompt. One click sends the text you type most often (Continue, Ask me questions, Try again, Commit), as your own words.

Install

/plugin install quick-replies --marketplace jmegan/claude-mods

Answer y to add the marketplace, then pick a scope. Each person gets their own config file and their own history-based suggestions.

Use

  • Click a button, or press ctrl+x, Tab to focus the band, then its hotkey.
  • more N › expands the less-used buttons.
  • A button marked send: false adds its text to your draft instead, so you can finish the sentence.
  • While Claude works, a click queues the reply for after the turn.
  • No appears only while Claude's last message ends in a question, and goes once you reply. Any button can do the same with "when": "asking".
  • /qr shows how often you have used each button, so you can prune the ones you never press. The row never re-sorts by use, because a button that moves under your pointer is worse than an unsorted row.

Commands

CommandWhat it does
/qrList the buttons with numbers, ids and hotkeys
/qr <number or id>Send that reply now
/qr add <label> :: <text>Add a button to your config file
/qr remove <id>Remove one of yours, or hide a default
/qr reloadRe-read the config (it also reloads after every turn)
/qr hide / /qr showHide or show the band (remembered across sessions)
/qr suggestAsk Claude to mine your history for new buttons

Configure

Your buttons live in a local file, quick-replies.json in your Claude config folder ($CLAUDE_CONFIG_DIR, else ~/.claude). Set the Config file option to use another path. See quick-replies.example.json. Every field is optional:

FieldMeaning
modemerge (default) adds to the built-in buttons; replace uses only yours
orderyours-first (default) or defaults-first
hotkeysauto (default) gives each button a hotkey; off assigns none (an explicit key still counts)
smartColorstrue (default). false turns off inferred colors and state-based emphasis
icons"on" (default) or "off" to hide the glyph before each label
hideIds of built-in buttons to remove
patchOverrides for a built-in by id: { "stop": { "more": true } }
repliesYour buttons. Required: label, text. Optional: id, send, more, when (always/asking), style (solid/outline), color, icon, key

Project buttons (opt in)

Set the Project buttons option to on to also read .claude/quick-replies.json in the project folder. It is off by default because a cloned repository could ship a button that sends text as you. A project file can only add buttons: it cannot replace, patch, hide or reorder yours, and a button that reuses one of your ids is dropped. Its buttons carry a ◆ and /qr marks them [project]. They are visually inert: no color, style, icon or hotkey of their own, and a label that copies one of yours is dropped.

Colors

Set color to a role, a theme key, or a raw color. Roles and theme keys follow your terminal theme (light, dark, color-blind), and a solid chip picks readable text on its own, so prefer them to hex.

RoleTheme keyUse for
gosuccessContinue, Yes
askpermissionQuestions for you
infosuggestionStatus, Try again, docs
cautionwarningVerify, Review
stoperrorStop, destructive
gitmergedCommit, push, merge
planplanModePlan first
agentclaudeSubagents, your call
neutralsubtleEverything else (the default)

Smart colors and icons

  • No color needed. A button you add with no color gets a role inferred from its label and text ("Push the branch" becomes git). Name a color to override it.
  • The color follows the state. While Claude asks a question, Yes, No and Continue turn solid. While a turn runs, every button dims, because a press queues the reply. After about 30 uses in total, a button you never press fades. Labels and padding never change, so nothing moves under your pointer.
  • An icon shows each button's kind, so color is never the only signal (a color-blind theme, a dim terminal). Each role has a glyph: ✓ go, ? ask, ⓘ info, ! caution, ■ stop, ± git, ☰ plan, ✦ agent. Set icon (up to two characters) on a button to choose your own. Glyphs are single-cell and not emoji, so rows stay aligned.

Works with other mods

The band draws the bands of other mods too and stacks its row beneath them, so a mod that also hooks the area above the prompt (model-dial, a status band) stays visible. Element keys start with qr-. Turn automatic hotkeys off if another band's hotkeys clash.

A bad file never breaks the band: you keep the built-in buttons and get a toast naming the problem.

Suggestions from your own history

The suggest-quick-replies skill (run /qr suggest) scans your local history.jsonl for messages you repeat across sessions, proposes buttons with the evidence, and writes the ones you approve. It reads local files only. skills/suggest-quick-replies/scripts/mine-history.mjs does the counting and runs on its own: node mine-history.mjs --format text.

Develop

claude plugin validate .
claude plugin test .

Share

This mod is listed in the repository's .claude-plugin/marketplace.json, so the install line above is all anyone needs. To publish a copy from a repository of its own, add a marketplace file that lists it with "source": "./". The repository README shows the format.

Source 4 files
hooks/register.tsx 342 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Board, Reply } from '../types'
5import { DEFAULT_REPLIES } from './defaults'
6import { addProjectReplies, asksQuestion, findReply, iconOf, idOf, inkOn, lookOf, mergeReplies, parseConfig, rawEntryId } from './replies'
7import type { LookContext } from './replies'
8import type { UserConfig } from './replies'
9
10const board = atom({ plugin: 'quick-replies', key: 'board' } as const, {
11  replies: [],
12  configPath: '',
13  problems: [],
14  isSmart: true,
15  hasIcons: true,
16} satisfies Board)
17const isHidden = atom({ plugin: 'quick-replies', key: 'isHidden' } as const, false)
18const isMoreOpen = atom({ plugin: 'quick-replies', key: 'isMoreOpen' } as const, false)
19const isAsking = atom({ plugin: 'quick-replies', key: 'isAsking' } as const, false)
20const usesAtom = atom({ plugin: 'quick-replies', key: 'uses' } as const, {} as Record<string, number>)
21
22const FILE_NAME = 'quick-replies.json'
23const USES_KEY = 'uses'
24
25type El = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
26
27/** True for a tree that draws nothing: the engine's own innermost link answers an empty Text. */
28const isBlank = (tree: unknown): boolean => {
29  if (tree === null || tree === undefined) return true
30  if (typeof tree === 'string') return tree.trim() === ''
31  if (typeof tree !== 'object') return false
32  const { type, children } = tree as { type?: string; children?: unknown[] }
33  return (type === 'Text' || type === 'Box') && (children ?? []).every(isBlank)
34}
35
36/** Where the config file lives: the option if set, else the Claude config folder, else ~/.claude. */
37const resolveConfigPath = async ($: EngineInterface, option: unknown): Promise<string> => {
38  const home = (await $.env.get('HOME')) ?? ''
39  const given = typeof option === 'string' ? option.trim() : ''
40  if (given !== '') return given.startsWith('~/') ? `${home}/${given.slice(2)}` : given
41  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
42  return `${configDir}/${FILE_NAME}`
43}
44
45const readRaw = async ($: EngineInterface, path: string): Promise<string | null> => {
46  try {
47    return await $.fs.read(path)
48  } catch {
49    return null
50  }
51}
52
53/**
54 * Re-reads the config file and rebuilds the board. A missing file is fine; a broken one keeps the defaults.
55 * With a project path, that file is layered on top; it only ever adds to what the person's own file made.
56 */
57const load = async ($: EngineInterface, path: string, projectPath: string | null): Promise<Board> => {
58  const text = await readRaw($, path)
59  let config: UserConfig | null = null
60  let problems: string[] = []
61  if (text !== null) {
62    const parsed = parseConfig(text)
63    config = parsed.config
64    problems = parsed.problems
65  }
66  let replies = mergeReplies(DEFAULT_REPLIES, config)
67  if (projectPath !== null) {
68    const projectText = await readRaw($, projectPath)
69    if (projectText !== null) {
70      const parsed = parseConfig(projectText)
71      problems = [...problems, ...parsed.problems.map(p => `project file: ${p}`)]
72      if (parsed.config !== null) replies = addProjectReplies(replies, parsed.config)
73    }
74  }
75  const next: Board = { replies, configPath: path, problems, isSmart: config?.isSmart !== false, hasIcons: config?.hasIcons !== false }
76  await update($, board, () => next)
77  return next
78}
79
80/** How many times each button has been used, by id, across sessions. */
81const readUses = async ($: EngineInterface): Promise<Record<string, number>> => {
82  const raw = await $.store.get(USES_KEY)
83  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
84  return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, number] => typeof entry[1] === 'number'))
85}
86
87const bumpUse = async ($: EngineInterface, id: string): Promise<void> => {
88  const uses = await readUses($)
89  const next = { ...uses, [id]: (uses[id] ?? 0) + 1 }
90  await $.store.set(USES_KEY, next)
91  await update($, usesAtom, () => next)
92}
93
94/** Sends the reply as the person's own words, or adds it to the draft for the person to finish. */
95const fire = async ($: EngineInterface, reply: Reply): Promise<void> => {
96  try {
97    await bumpUse($, reply.id).catch(() => undefined)
98    // The question this answers is handled; the next turn decides again.
99    await update($, isAsking, () => false)
100    if (reply.send) {
101      await $.prompt.submit({ text: reply.text, asUser: true })
102      return
103    }
104    const draft = await $.prompt.read()
105    const gap = draft.text !== '' && !/\s$/.test(draft.text) ? ' ' : ''
106    await $.prompt.fill({ text: `${gap}${reply.text}`, mode: 'append' })
107  } catch (err) {
108    $.ui.toast(`quick-replies: could not send "${reply.label}": ${err instanceof Error ? err.message : String(err)}`)
109  }
110}
111
112const Chip = (
113  { Button, Text }: El,
114  reply: Reply,
115  look: { style: Reply['style']; isDim: boolean },
116  hasIcons: boolean,
117  onPress: () => void,
118) => {
119  // A button from the project's file is marked, so it is never mistaken for one of the person's own.
120  const icon = hasIcons ? iconOf(reply) : ''
121  const mark = reply.source === 'project' ? '◆ ' : icon === '' ? '' : `${icon} `
122  const label = ` ${mark}${reply.label} `
123  if (look.style === 'solid') {
124    return (
125      <Button key={`qr-${reply.id}`} plain hotkey={reply.key || undefined} onPress={onPress}>
126        <Text backgroundColor={reply.color} color={inkOn(reply.color)} bold dimColor={look.isDim}>
127          {label}
128        </Text>
129      </Button>
130    )
131  }
132  return (
133    <Button key={`qr-${reply.id}`} plain hotkey={reply.key || undefined} onPress={onPress}>
134      <Text color={reply.color} dimColor={look.isDim} hover={{ scope: `qr-${reply.id}`, bold: true, underline: true, dimColor: false }}>
135        {label}
136      </Text>
137    </Button>
138  )
139}
140
141/**
142 * Reads the config file as a JSON object, applies `mutate`, and writes it back. A missing file starts
143 * empty; one that exists but cannot be read, or is not a JSON object, is left untouched and reported.
144 */
145const writeConfig = async ($: EngineInterface, path: string, mutate: (raw: Record<string, unknown>) => void): Promise<void> => {
146  const text = await readRaw($, path)
147  let raw: Record<string, unknown> = {}
148  if (text === null) {
149    const exists = await $.fs.stat(path).then(() => true, () => false)
150    if (exists) throw new Error(`${path} exists but could not be read, so I left it alone`)
151  } else {
152    let parsed: unknown
153    try {
154      parsed = JSON.parse(text)
155    } catch {
156      throw new Error(`${path} is not valid JSON, so I left it alone. Fix it first.`)
157    }
158    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
159      throw new Error(`${path} must hold a JSON object, so I left it alone. Fix it first.`)
160    }
161    raw = parsed as Record<string, unknown>
162  }
163  mutate(raw)
164  await $.fs.write(path, `${JSON.stringify(raw, null, 2)}\n`)
165}
166
167const HELP = [
168  '/qr                      list the buttons with their numbers',
169  '/qr <number|id>          send that reply now',
170  '/qr add <label> :: <text>   add a button to your config file',
171  '/qr remove <id>          remove one of yours, or hide a default',
172  '/qr reload               re-read the config file',
173  '/qr hide | show          hide or show the band',
174  '/qr suggest              mine your history for new button ideas (runs the skill)',
175  '/qr path                 show the config file location',
176].join('\n')
177
178/** The resolved config path and the session's folder, held for this load of the module. */
179let configPath = ''
180let cwd = ''
181
182/** Re-reads the config; the path is resolved once per load. The project file is read only when the option is on. */
183const refresh = async ($: EngineInterface, options: PluginOptions): Promise<Board> => {
184  if (configPath === '') configPath = await resolveConfigPath($, options.configPath)
185  const projectPath = options.projectConfig === 'on' && cwd !== '' ? `${cwd}/.claude/${FILE_NAME}` : null
186  return load($, configPath, projectPath)
187}
188
189export const register: Register = (on, options) => {
190  on('session.start', async ($, e, next) => {
191    cwd = e.cwd
192    await $.command.register({
193      name: 'qr',
194      description: 'Quick replies: list, send, add or edit the buttons above the prompt',
195      argumentHint: '[number|id | add | remove | reload | hide | show | suggest | path]',
196    })
197    const loaded = await refresh($, options)
198    await update($, usesAtom, () => ({}))
199    await readUses($).then(uses => update($, usesAtom, () => uses)).catch(() => undefined)
200    const stored = await $.store.get('isHidden')
201    await update($, isHidden, () => stored === true)
202    if (loaded.problems.length > 0) $.ui.toast(`quick-replies: ${loaded.problems[0]} in ${loaded.configPath}`)
203    return next(e)
204  })
205
206  // Pick up edits made to the config file between turns, with no reload command.
207  on('turn.complete', async ($, e, next) => {
208    await refresh($, options).catch(() => undefined)
209    // Buttons marked `when: "asking"` show only while Claude's last message asks a question.
210    const messages = await $.session.messages().catch(() => [])
211    const last = messages.at(-1)
212    await update($, isAsking, () => last !== undefined && last.role === 'assistant' && asksQuestion(last.text))
213    return next(e)
214  }).catch(($, e, next) => next(e))
215
216  // A prompt the person types answers the question, so those buttons go away until the next turn decides.
217  on('prompt.submit', async ($, e, next) => {
218    await update($, isAsking, () => false)
219    return next(e)
220  }).catch(($, e, next) => next(e))
221
222  on('command.run', { command: 'qr' }, async ($, e) => {
223    const args = e.args.trim()
224    const [verb = '', ...rest] = args.split(/\s+/)
225    const current = await read($, board)
226
227    if (args === '') {
228      const uses = await readUses($)
229      const lines = current.replies.map((r, i) => {
230        const where = r.more ? ' (more)' : ''
231        const how = r.send ? '' : ' [inserts]'
232        const ask = r.when === 'asking' ? ' [when asked]' : ''
233        const from = r.source === 'project' ? ' [project]' : ''
234        const used = uses[r.id] ? `  ·  used ${uses[r.id]}×` : ''
235        return `${String(i + 1).padStart(2)}. ${r.label}${where}${how}${ask}${from}  ·  key ${r.key || '-'}  ·  ${r.id}  ·  ${r.color}${used}`
236      })
237      const problems = current.problems.map(p => `⚠ ${p}`)
238      return { text: [`Quick replies (${current.configPath}):`, ...lines, ...problems, '', HELP].join('\n') }
239    }
240
241    if (verb === 'reload') {
242      const loaded = await refresh($, options)
243      return { text: `Reloaded ${loaded.replies.length} buttons from ${loaded.configPath}.${loaded.problems.length ? ` Problem: ${loaded.problems[0]}` : ''}` }
244    }
245    if (verb === 'path') return { text: current.configPath }
246    if (verb === 'hide' || verb === 'show') {
247      await update($, isHidden, () => verb === 'hide')
248      await $.store.set('isHidden', verb === 'hide')
249      return { text: verb === 'hide' ? 'Quick replies hidden. /qr show brings them back.' : 'Quick replies shown.' }
250    }
251    if (verb === 'suggest') {
252      // A command.run hook holds the turn, so a submit from here waits on itself; send it just after.
253      $.clock.after(0, () => {
254        void $.prompt
255          .submit({
256            text: 'Run the suggest-quick-replies skill: mine my local prompt history and propose new quick-reply buttons for my config.',
257            asUser: true,
258          })
259          .catch(() => undefined)
260      })
261      return { text: 'Asked Claude to run the suggest-quick-replies skill.' }
262    }
263    if (verb === 'add') {
264      const body = args.slice(3).trim()
265      const split = body.indexOf('::')
266      const label = split === -1 ? '' : body.slice(0, split).trim()
267      const text = split === -1 ? '' : body.slice(split + 2).trim()
268      if (label === '' || text === '') return { text: 'Usage: /qr add <label> :: <text to send>' }
269      const id = idOf(label, null)
270      try {
271        await writeConfig($, current.configPath, raw => {
272          const list = Array.isArray(raw.replies) ? raw.replies : []
273          raw.replies = [...list.filter(r => rawEntryId(r) !== id), { id, label, text }]
274        })
275      } catch (err) {
276        return { text: err instanceof Error ? err.message : String(err) }
277      }
278      const loaded = await refresh($, options)
279      return { text: `Added "${label}" (${loaded.replies.length} buttons). Edit ${loaded.configPath} to change its color or hotkey.` }
280    }
281    if (verb === 'remove') {
282      const target = findReply(current.replies, rest.join(' '))
283      if (target === null) return { text: `No button matches "${rest.join(' ')}". /qr lists them.` }
284      if (target.source === 'project') return { text: `"${target.label}" comes from this project's .claude/${FILE_NAME}. Edit that file to remove it.` }
285      try {
286        await writeConfig($, current.configPath, raw => {
287          const list = Array.isArray(raw.replies) ? raw.replies : []
288          raw.replies = list.filter(r => rawEntryId(r) !== target.id)
289          if (target.source === 'default') {
290            const hide = Array.isArray(raw.hide) ? raw.hide : []
291            raw.hide = [...new Set([...hide, target.id])]
292          }
293        })
294      } catch (err) {
295        return { text: err instanceof Error ? err.message : String(err) }
296      }
297      await refresh($, options)
298      return { text: `Removed "${target.label}".` }
299    }
300
301    const target = findReply(current.replies, args)
302    if (target === null) return { text: `No button matches "${args}".\n\n${HELP}` }
303    // Same reason as /qr suggest: send once the command has returned.
304    $.clock.after(0, () => void fire($, target))
305    return { text: `Sent "${target.label}".` }
306  })
307
308  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309    if (e.props.hasSurvey) return next(e)
310    if (await read($, isHidden)) return next(e)
311    const current = await read($, board)
312    if (current.replies.length === 0) return next(e)
313
314    const el = $.ui.resolve(e)
315    const { Box, Text, Button } = el
316    const isOpen = await read($, isMoreOpen)
317    const asking = await read($, isAsking)
318    const uses = await read($, usesAtom)
319    const context: LookContext = { isSmart: current.isSmart, isAsking: asking, isWorking: e.props.isWorking, uses }
320    const visible = current.replies.filter(r => r.when === 'always' || asking)
321    const primary = visible.filter(r => !r.more)
322    const extra = visible.filter(r => r.more)
323    const shown = isOpen ? [...primary, ...extra] : primary
324
325    // Other mods' bands render beneath this hook; dropping next() would hide them, and a mod
326    // above us that does the same would hide this one. Draw the chain, then stack on it.
327    const beneath = await next(e)
328    const mine = (
329      <Box key="quick-replies" flexDirection="row" flexWrap="wrap" columnGap={1}>
330        {shown.map(r => Chip(el, r, lookOf(r, context), current.hasIcons, () => void fire($, r)))}
331        {extra.length > 0 && (
332          <Button key="qr-toggle-more" plain onPress={() => update($, isMoreOpen, open => !open)}>
333            <Text dimColor>{isOpen ? ' ‹ less ' : ` more ${extra.length} › `}</Text>
334          </Button>
335        )}
336        {e.props.isWorking && <Text dimColor italic>sends after this turn</Text>}
337      </Box>
338    )
339    return isBlank(beneath) ? mine : <Box flexDirection="column">{beneath}{mine}</Box>
340  })
341}
342
hooks/defaults.ts 131 lines
1/** The buttons every install starts with. A user's config file adds to, edits or hides these. */
2export type DefaultReply = {
3  id: string
4  label: string
5  text: string
6  send?: boolean
7  more?: boolean
8  style?: 'solid' | 'outline'
9  /** `asking` shows the button only while Claude's last message asks a question. Default `always`. */
10  when?: 'always' | 'asking'
11  /** A glyph drawn before the label. Left out, the color's role picks one. */
12  icon?: string
13  /** A role (go, ask, info, caution, stop, git, plan, agent, neutral), a theme key, or a raw color. */
14  color?: string
15}
16
17export const DEFAULT_REPLIES: readonly DefaultReply[] = [
18  {
19    id: 'continue',
20    icon: '»',
21    label: 'Continue',
22    text: "Yes, go ahead with everything you just proposed and keep going. Don't pause to ask again unless you're blocked or the next step needs a decision only I can make.",
23    style: 'solid',
24    color: 'go',
25  },
26  {
27    id: 'ask-me',
28    label: 'Ask me',
29    text: 'Use your question tool (AskUserQuestion) to ask me every question you need answered in this session, including any open questions or decisions you have been holding back. Ask them all in one go.',
30    style: 'solid',
31    color: 'ask',
32  },
33  { id: 'yes', label: 'Yes', text: 'Yes.', color: 'go' },
34  {
35    id: 'no',
36    label: 'No',
37    text: "No, don't do that. Tell me what else you would suggest.",
38    when: 'asking',
39    color: 'stop',
40  },
41  {
42    id: 'try-again',
43    icon: '↻',
44    label: 'Try again',
45    text: "That didn't work. Try again, and tell me what you are changing this time.",
46    color: 'info',
47  },
48  {
49    id: 'your-call',
50    label: 'Your call',
51    text: 'Use your best judgment and pick the option you recommend. Tell me what you picked and why in one line.',
52    color: 'agent',
53  },
54  {
55    id: 'whats-next',
56    icon: '→',
57    label: "What's next?",
58    text: "What's next? List the open items and your recommended order.",
59    color: 'info',
60  },
61  {
62    id: 'status',
63    label: 'Check in',
64    text: 'Check in: where are you, what is done, what is left, and what is blocking you?',
65    color: 'info',
66  },
67  {
68    id: 'verify',
69    label: 'Verify',
70    text: "Verify it. Run the relevant tests, typecheck and lint, and show me the results before you say it's done.",
71    color: 'caution',
72  },
73  {
74    id: 'stop',
75    label: 'Stop',
76    text: "Stop. Don't make any more changes. Tell me what you have done so far and what state things are in.",
77    color: 'stop',
78  },
79  {
80    id: 'commit',
81    icon: '±',
82    label: 'Commit',
83    text: 'Commit just the changes you made in this session, with a clear message.',
84    more: true,
85    color: 'git',
86  },
87  {
88    id: 'review',
89    label: 'Review',
90    text: 'Review your own changes for bugs and quality, then fix anything you find.',
91    more: true,
92    color: 'caution',
93  },
94  {
95    id: 'subagents',
96    label: 'Subagents',
97    text: 'Use subagents for the independent parts of this, and keep the main thread for coordination.',
98    more: true,
99    color: 'agent',
100  },
101  {
102    id: 'check-docs',
103    label: 'Check docs',
104    text: 'Look up the current official docs (Context7 or the web) to verify this before you go on.',
105    more: true,
106    color: 'info',
107  },
108  {
109    id: 'plan-first',
110    label: 'Plan first',
111    text: "Don't change anything yet. Show me your plan first and wait for my go-ahead.",
112    more: true,
113    color: 'plan',
114  },
115  {
116    id: 'summarize',
117    label: 'Summarize',
118    text: 'Summarize where we are in a few bullets: what changed, what works, and what is still open.',
119    more: true,
120    color: 'neutral',
121  },
122  {
123    id: 'explain',
124    label: 'Explain',
125    text: 'Explain that more simply, with a concrete example. ',
126    send: false,
127    more: true,
128    color: 'neutral',
129  },
130]
131
hooks/replies.ts 371 lines
1import type { Reply } from '../types'
2import type { DefaultReply } from './defaults'
3
4/**
5 * Color roles. Each names what a button means and maps to a theme key, so the buttons follow the
6 * person's theme (light, dark, color-blind) instead of fixed hex values. A theme key or a raw
7 * color passes through unchanged.
8 */
9export const COLOR_ROLES: Readonly<Record<string, string>> = {
10  go: 'success',
11  ask: 'permission',
12  info: 'suggestion',
13  caution: 'warning',
14  stop: 'error',
15  git: 'merged',
16  plan: 'planMode',
17  agent: 'claude',
18  neutral: 'subtle',
19}
20
21/**
22 * What a button means, read from its words, so a button the person adds without a color still gets a
23 * fitting one. The first rule that matches the label wins; if none does, the first 80 characters of the
24 * text are tried the same way. Roles are the ones in COLOR_ROLES.
25 */
26const ROLE_RULES: readonly (readonly [role: string, pattern: RegExp])[] = [
27  ['go', /^(yes|continue|go|ok|okay|proceed|approve|do it|ship)\b/],
28  ['stop', /^(no|stop|cancel|abort|revert|undo|don't|dont)\b/],
29  ['git', /\b(commit|push|merge|pull request|pr|branch|rebase|git)\b/],
30  ['plan', /\b(plan|design|outline)\b/],
31  ['agent', /\b(agents?|subagents?|delegate|parallel|your call|best judg(e)?ment)\b/],
32  ['caution', /\b(verify|test|tests|check|review|lint|audit|simplify|fix|typecheck)\b/],
33  ['ask', /\b(ask|question|clarify|decide)\b/],
34  ['info', /\b(status|next|summari[sz]e|explain|docs|look up|research|try again|retry|why)\b/],
35]
36
37export const inferRole = (label: string, text: string): string => {
38  for (const source of [label.toLowerCase(), text.toLowerCase().slice(0, 80)]) {
39    for (const [role, pattern] of ROLE_RULES) if (pattern.test(source)) return role
40  }
41  return 'neutral'
42}
43
44/**
45 * The glyph each color role draws before its label, so a button's kind shows without relying on color
46 * alone. Every glyph is a single cell wide and has no emoji presentation, so rows keep their alignment.
47 */
48export const ROLE_ICONS: Readonly<Record<string, string>> = {
49  success: '✓',
50  permission: '?',
51  suggestion: 'ⓘ',
52  warning: '!',
53  error: '■',
54  merged: '±',
55  planMode: '☰',
56  claude: '✦',
57  subtle: '·',
58}
59
60/** A reply's glyph: its own `icon`, else the one for its color role, else none (a raw color has no role). */
61export const iconOf = (reply: Pick<Reply, 'icon' | 'color'>): string => reply.icon || (ROLE_ICONS[reply.color] ?? '')
62
63/** Keeps a glyph to two characters with no control characters; anything else is no glyph. */
64const cleanIcon = (value: unknown): string | undefined => {
65  const text = typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim() : ''
66  const glyph = [...text].slice(0, 2).join('')
67  return glyph === '' ? undefined : glyph
68}
69
70/** The color of a reply that names none, when smart colors are off. */
71export const FALLBACK_COLOR = COLOR_ROLES.neutral ?? 'subtle'
72
73/** Turns a role into its theme key; anything else (a theme key, a name, hex) is kept as written. */
74export const resolveColor = (color: string): string => COLOR_ROLES[color.toLowerCase()] ?? color
75
76/**
77 * The text color that stays readable on a solid chip of this color. A theme key gets the theme's own
78 * `inverseText`; a hex color gets near-black or white by its luminance.
79 */
80export const inkOn = (background: string): string => {
81  const hex = background.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]
82  if (hex === undefined) return 'inverseText'
83  const full = hex.length === 3 ? [...hex].map(c => c + c).join('') : hex
84  const [r = 0, g = 0, b = 0] = [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16))
85  const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
86  return luminance > 0.55 ? '#111111' : '#FFFFFF'
87}
88
89/** A reply as the config file spells it: a default's fields plus an optional hotkey. */
90export type UserReply = DefaultReply & { key?: string }
91
92/** What a merge starts from: the built-in defaults, or the replies a lower layer already made. */
93export type Seed = UserReply & { source?: Reply['source'] }
94
95/**
96 * True when a message ends by asking the person something: its last line ends in a question mark,
97 * ignoring closing quotes, brackets, emphasis marks and a trailing code fence.
98 */
99export const asksQuestion = (text: string): boolean => {
100  const lines = text.split('\n').map(l => l.trim()).filter(l => l !== '' && !/^```/.test(l))
101  const last = lines.at(-1) ?? ''
102  return /\?[\s)\]"'\u201d\u2019*_`]*$/.test(last)
103}
104
105/** What the config file may hold. Every field is optional; unknown fields are ignored. */
106export type UserConfig = {
107  mode: 'merge' | 'replace'
108  order: 'yours-first' | 'defaults-first'
109  /** `false` turns off inferred colors and state-based emphasis. Default true. */
110  isSmart: boolean
111  /** `false` hides the glyph before each label. Default true. */
112  hasIcons: boolean
113  /** `off` assigns no automatic hotkeys; a reply's own `key` still counts. */
114  hotkeys: 'auto' | 'off'
115  hide: string[]
116  /** Field overrides for a default, by id: `{ "stop": { "more": true } }`. */
117  patch: Record<string, Partial<UserReply>>
118  replies: UserReply[]
119}
120
121const isRecord = (value: unknown): value is Record<string, unknown> =>
122  typeof value === 'object' && value !== null && !Array.isArray(value)
123
124const str = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value : null)
125
126export const slug = (label: string): string =>
127  label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
128
129const hash = (text: string): string => {
130  let h = 5381
131  for (const ch of text) h = ((h << 5) + h + ch.codePointAt(0)!) >>> 0
132  return h.toString(36).slice(0, 6)
133}
134
135/**
136 * The id of a reply: its own `id` if set, else a slug of its label, else a short hash of the label
137 * (a label with no ASCII letters, like “继续”, slugs to nothing). Control characters go and the length is
138 * capped, because ids become element keys and hover scopes, which the engine bounds.
139 */
140export const idOf = (label: string, explicit: string | null): string => {
141  const clean = (explicit ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 40)
142  return clean || slug(label).slice(0, 40) || `reply-${hash(label)}`
143}
144
145/** The id a raw config entry resolves to, derived exactly as the parser does; null for a non-entry. */
146export const rawEntryId = (raw: unknown): string | null => {
147  if (!isRecord(raw)) return null
148  const label = str(raw.label)
149  const explicit = str(raw.id)
150  return label === null && explicit === null ? null : idOf(label ?? '', explicit)
151}
152
153/** Reads one entry of `replies`; a bad entry is reported and skipped, never fatal. */
154const readEntry = (raw: unknown, index: number, problems: string[]): UserReply | null => {
155  if (!isRecord(raw)) {
156    problems.push(`replies[${index}] is not an object`)
157    return null
158  }
159  const label = str(raw.label)
160  const text = str(raw.text)
161  if (label === null || text === null) {
162    problems.push(`replies[${index}] needs a "label" and a "text"`)
163    return null
164  }
165  const key = str(raw.key)
166  return {
167    id: idOf(label, str(raw.id)),
168    label,
169    text,
170    send: raw.send === false ? false : undefined,
171    more: raw.more === true ? true : undefined,
172    style: raw.style === 'solid' || raw.style === 'outline' ? raw.style : undefined,
173    color: str(raw.color) ?? undefined,
174    icon: cleanIcon(raw.icon),
175    when: raw.when === 'asking' ? 'asking' : undefined,
176    key: key !== null && /^[a-z0-9]$/.test(key) ? key : undefined,
177  }
178}
179
180/** Parses the config file's text. Invalid JSON gives no config and one problem, so the defaults still show. */
181export const parseConfig = (text: string): { config: UserConfig | null; problems: string[] } => {
182  const problems: string[] = []
183  let raw: unknown
184  try {
185    raw = JSON.parse(text)
186  } catch (err) {
187    return { config: null, problems: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`] }
188  }
189  if (!isRecord(raw)) return { config: null, problems: ['the file must hold a JSON object'] }
190
191  const entries = Array.isArray(raw.replies) ? raw.replies : []
192  const replies = entries
193    .map((entry, i) => readEntry(entry, i, problems))
194    .filter((entry): entry is UserReply => entry !== null)
195  const hide = Array.isArray(raw.hide) ? raw.hide.filter((id): id is string => typeof id === 'string') : []
196  const patch: Record<string, Partial<UserReply>> = {}
197  if (isRecord(raw.patch)) {
198    for (const [id, fields] of Object.entries(raw.patch)) {
199      if (!isRecord(fields)) {
200        problems.push(`patch.${id} is not an object`)
201        continue
202      }
203      const key = str(fields.key)
204      patch[id] = {
205        label: str(fields.label) ?? undefined,
206        text: str(fields.text) ?? undefined,
207        send: typeof fields.send === 'boolean' ? fields.send : undefined,
208        more: typeof fields.more === 'boolean' ? fields.more : undefined,
209        style: fields.style === 'solid' || fields.style === 'outline' ? fields.style : undefined,
210        color: str(fields.color) ?? undefined,
211        icon: cleanIcon(fields.icon),
212        when: fields.when === 'asking' || fields.when === 'always' ? fields.when : undefined,
213        key: key !== null && /^[a-z0-9]$/.test(key) ? key : undefined,
214      }
215    }
216  }
217
218  return {
219    config: {
220      mode: raw.mode === 'replace' ? 'replace' : 'merge',
221      order: raw.order === 'defaults-first' ? 'defaults-first' : 'yours-first',
222      hotkeys: raw.hotkeys === 'off' ? 'off' : 'auto',
223      isSmart: raw.smartColors !== false,
224      hasIcons: raw.icons !== 'off',
225      hide,
226      patch,
227      replies,
228    },
229    problems,
230  }
231}
232
233const toReply = (entry: Seed, fallback: Reply['source'], isSmart = true): Reply => ({
234  id: entry.id,
235  label: entry.label,
236  text: entry.text,
237  send: entry.send !== false,
238  more: entry.more === true,
239  style: entry.style ?? 'outline',
240  color: resolveColor(entry.color ?? (isSmart ? inferRole(entry.label, entry.text) : FALLBACK_COLOR)),
241  icon: entry.icon ?? '',
242  when: entry.when === 'asking' ? 'asking' : 'always',
243  key: entry.key ?? '',
244  source: entry.source ?? fallback,
245})
246
247/** Lays a patch's defined fields over a reply; an undefined field leaves the original. */
248const withDefined = (reply: Seed, patch: Partial<UserReply> | undefined): Seed => {
249  if (patch === undefined) return reply
250  const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined))
251  return { ...reply, ...defined }
252}
253
254/**
255 * Merges the defaults with the user's config.
256 *
257 * A user reply with a default's id replaces that default; `hide` removes ids; `mode: "replace"`
258 * drops the defaults altogether. Ids stay unique, and the first of a duplicate wins.
259 */
260export const mergeReplies = (defaults: readonly Seed[], config: UserConfig | null): Reply[] => {
261  if (config === null) return assignKeys(defaults.map(d => toReply(d, 'default')))
262  const base = defaults.map(d => toReply(withDefined(d, config.patch[d.id]), 'default', config.isSmart))
263
264  const mine = config.replies.map(r => toReply(r, 'user', config.isSmart))
265  const hidden = new Set(config.hide)
266  const keep = (r: Reply): boolean => !hidden.has(r.id)
267
268  let merged: Reply[]
269  if (config.mode === 'replace') {
270    merged = mine
271  } else if (config.order === 'defaults-first') {
272    const override = new Map(mine.map(r => [r.id, r]))
273    const known = new Set(base.map(r => r.id))
274    merged = [...base.map(r => override.get(r.id) ?? r), ...mine.filter(r => !known.has(r.id))]
275  } else {
276    const taken = new Set(mine.map(r => r.id))
277    merged = [...mine, ...base.filter(r => !taken.has(r.id))]
278  }
279
280  const seen = new Set<string>()
281  const unique = merged.filter(keep).filter(r => (seen.has(r.id) ? false : (seen.add(r.id), true)))
282  return assignKeys(unique, config.hotkeys === 'auto')
283}
284
285/** Gives each reply a unique one-character hotkey: explicit ones first, then digits, then label letters. */
286export const assignKeys = (replies: Reply[], isAuto = true): Reply[] => {
287  const used = new Set<string>()
288  const explicit = replies.map(r => {
289    if (r.key !== '' && !used.has(r.key)) {
290      used.add(r.key)
291      return r.key
292    }
293    return ''
294  })
295  const digits = '123456789'.split('')
296  return replies.map((r, i) => {
297    const chosen = explicit[i] ?? ''
298    if (chosen !== '') return { ...r, key: chosen }
299    if (!isAuto) return { ...r, key: '' }
300    const pool = [...digits, ...[...r.label.toLowerCase()].filter(c => /[a-z]/.test(c))]
301    const pick = pool.find(c => !used.has(c)) ?? ''
302    if (pick !== '') used.add(pick)
303    return { ...r, key: pick }
304  })
305}
306
307/** Finds a reply by 1-based number, id, or label (case-insensitive). */
308export const findReply = (replies: readonly Reply[], token: string): Reply | null => {
309  const t = token.trim().toLowerCase()
310  if (t === '') return null
311  if (/^\d+$/.test(t)) return replies[Number(t) - 1] ?? null
312  return replies.find(r => r.id.toLowerCase() === t || r.label.toLowerCase() === t) ?? null
313}
314
315/** A project label with control characters and the project marker removed, cut to 24 characters. */
316const cleanProjectLabel = (label: string): string =>
317  label.replace(/[\u0000-\u001f\u007f\u25c6]/g, '').replace(/\s+/g, ' ').trim().slice(0, 24)
318
319/**
320 * Adds a project file's buttons to the person's own, and nothing more. The file is not trusted the way
321 * the person's own config is, so its buttons are visually inert:
322 *
323 * - it can never replace, patch, hide or reorder an existing button, and one reusing an existing id or
324 *   label is dropped, so it cannot pose as one of the person's own;
325 * - it cannot choose a color, style, icon or hotkey: its buttons draw in the neutral outline style with
326 *   no glyph, behind the project marker the band adds.
327 */
328export const addProjectReplies = (base: readonly Reply[], config: UserConfig): Reply[] => {
329  // Ids compare without case, and the band's own `more` chip key is reserved, so a project id can
330  // neither shadow one of the person's buttons nor collide with the toggle.
331  const ids = new Set([...base.map(r => r.id.toLowerCase()), 'toggle-more'])
332  const labels = new Set(base.map(r => r.label.toLowerCase()))
333  const added: Reply[] = []
334  for (const entry of config.replies) {
335    const label = cleanProjectLabel(entry.label)
336    if (label === '' || ids.has(entry.id.toLowerCase()) || labels.has(label.toLowerCase())) continue
337    ids.add(entry.id.toLowerCase())
338    labels.add(label.toLowerCase())
339    const plain = toReply({ id: entry.id, label, text: entry.text, send: entry.send, more: entry.more, when: entry.when }, 'project', false)
340    added.push({ ...plain, source: 'project', style: 'outline', color: FALLBACK_COLOR, icon: '', key: '' })
341  }
342  return assignKeys([...base, ...added])
343}
344/** Uses in all before a never-used button counts as stale: too few and "never used" means nothing yet. */
345const STALE_AFTER_USES = 30
346
347export type LookContext = {
348  isSmart: boolean
349  /** Claude's last message asks a question. */
350  isAsking: boolean
351  /** A turn is running, so a press queues the reply. */
352  isWorking: boolean
353  uses: Readonly<Record<string, number>>
354}
355
356/**
357 * How one button draws right now. Only the style and the dimming change, never the label or its
358 * padding, so no button moves under the pointer.
359 *
360 * - While Claude asks, the yes and no family (and any \`when: "asking"\` button) turns solid.
361 * - While a turn runs, every button dims: a press queues the reply for after the turn.
362 * - Once there is enough history, a button that was never pressed dims.
363 */
364export const lookOf = (reply: Reply, context: LookContext): { style: Reply['style']; isDim: boolean } => {
365  if (!context.isSmart) return { style: reply.style, isDim: false }
366  const isPrompted = context.isAsking && (reply.when === 'asking' || reply.color === 'success' || reply.color === 'error')
367  const total = Object.values(context.uses).reduce((sum, n) => sum + n, 0)
368  const isStale = !isPrompted && reply.when === 'always' && reply.source !== 'project' && total >= STALE_AFTER_USES && (context.uses[reply.id] ?? 0) === 0
369  return { style: isPrompted ? 'solid' : reply.style, isDim: context.isWorking || isStale }
370}
371
types/index.d.ts 48 lines
1/** One button, after the config file and the defaults are merged and normalized. */
2export type Reply = {
3  id: string
4  label: string
5  /** What the button sends, or inserts into the prompt box when `send` is false. */
6  text: string
7  /** True sends the text as a prompt; false adds it to the draft so the person can finish it. */
8  send: boolean
9  /** True tucks the button behind the `more` chip. */
10  more: boolean
11  /** `solid` is a filled pill; `outline` is colored text. */
12  style: 'solid' | 'outline'
13  color: string
14  /** A one- or two-character glyph drawn before the label ('' for none). */
15  icon: string
16  /** The hotkey (one digit or lowercase letter), explicit or assigned. */
17  key: string
18  /** `always` shows the button all the time; `asking` only while Claude's last message asks a question. */
19  when: 'always' | 'asking'
20  /** Where it came from: the built-ins, the user's file, or this project's file. */
21  source: 'default' | 'user' | 'project'
22}
23
24/** What the band draws from: the merged buttons and anything wrong with the config file. */
25export type Board = {
26  replies: Reply[]
27  configPath: string
28  problems: string[]
29  /** False when the config sets `smartColors: false`: no inferred colors, no state-based emphasis. */
30  isSmart: boolean
31  /** False when the config sets `icons: "off"`. */
32  hasIcons: boolean
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'quick-replies': {
38      board: Board
39      isHidden: boolean
40      isMoreOpen: boolean
41      /** True while Claude's last message ends in a question. */
42      isAsking: boolean
43      /** How often each button was used, by id, across sessions. */
44      uses: Record<string, number>
45    }
46  }
47}
48