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…

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.
/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.
ctrl+x, Tab to focus the band, then its hotkey.more N › expands the less-used buttons.send: false adds its text to your draft instead, so you can finish the sentence."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.| Command | What it does |
|---|---|
/qr | List 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 reload | Re-read the config (it also reloads after every turn) |
/qr hide / /qr show | Hide or show the band (remembered across sessions) |
/qr suggest | Ask Claude to mine your history for new buttons |
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:
| Field | Meaning |
|---|---|
mode | merge (default) adds to the built-in buttons; replace uses only yours |
order | yours-first (default) or defaults-first |
hotkeys | auto (default) gives each button a hotkey; off assigns none (an explicit key still counts) |
smartColors | true (default). false turns off inferred colors and state-based emphasis |
icons | "on" (default) or "off" to hide the glyph before each label |
hide | Ids of built-in buttons to remove |
patch | Overrides for a built-in by id: { "stop": { "more": true } } |
replies | Your buttons. Required: label, text. Optional: id, send, more, when (always/asking), style (solid/outline), color, icon, key |
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.
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.
| Role | Theme key | Use for |
|---|---|---|
go | success | Continue, Yes |
ask | permission | Questions for you |
info | suggestion | Status, Try again, docs |
caution | warning | Verify, Review |
stop | error | Stop, destructive |
git | merged | Commit, push, merge |
plan | planMode | Plan first |
agent | claude | Subagents, your call |
neutral | subtle | Everything else (the default) |
color gets a role inferred from its label and text ("Push the branch" becomes git). Name a color to override it.icon (up to two characters) on a button to choose your own. Glyphs are single-cell and not emoji, so rows stay aligned.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.
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.
claude plugin validate .
claude plugin test .
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.
hooks/register.tsx 342 lines1import { 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}
342hooks/defaults.ts 131 lines1/** 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]
131hooks/replies.ts 371 lines1import 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}
371types/index.d.ts 48 lines1/** 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