SLOPSHOPPER

prompt-buttons

Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

newpanecommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-buttons
│ ┃ Prompt buttons ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Prompt buttons ● ready │ ┃ ⏺ Read(src/auth.ts) │ ┃ No buttons yet. Add one below: a label and ⎿ Read 6 lines │ ┃ the instruction it sends. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Label: e.g. Run tests and fix ⏎ next ⏺ Bash(bun test) │ ┃ Instruction: What Claude should do, then En ⎿ 3 pass, 1 fail │ ┃ │ ┃ 1-9 send · × delete · Tab moves · /buttons ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ n from anywhere │ ✻ Worked for 42s · done 4:20 PM │ │ › /buttons │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt buttons
◆ Prompt buttons ● ready No buttons yet. Add one below: a label and the instruction it sends. Label: e.g. Run tests and fix ⏎ next Instruction: What Claude should do, then Enter to add ⏎ add 1-9 send · × delete · Tab moves · /buttons n from anywhere
README

prompt-buttons

Leia em português

A panel of buttons you create, each one sending a request you use often to Claude with a single key.

What is a mod?

Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.

What it does

Some requests you type again and again: "run the tests and fix what fails", "review the last change", "write the commit message". This mod lets you save each one as a button with a short name. Pressing the button (or its number) sends the full request to Claude, as if you had typed it. Your buttons are kept for the next sessions.

What you see

  • A panel titled "Prompt buttons" with your buttons numbered 1 to 9, each with a × to delete it.
  • A status line: "● ready", or "◌ Claude is working · new presses queue".
  • Two fields at the bottom to create a button: Label (the name) and Instruction (the text it sends).

How to use it

  1. Type /buttons and press Enter. The panel opens.
  2. In Label, write a short name (for example "Run tests and fix") and press Enter.
  3. In Instruction, write the full request and press Enter. The button appears with a number.
  4. Press that number to send the request. If Claude is busy, it waits in line and goes as soon as Claude finishes.
  5. Outside the panel, /buttons 2 sends button 2 directly.

Keyboard shortcuts

KeyWhat it does
1 to 9Send that button's request
×Delete that button
TabMove between buttons and fields
EnterGo to the next field, or create the button

Commands

TypeWhat it does
/buttonsOpen the panel (or list the buttons where no panel can be drawn)
/buttons 2Send button 2 right away

Settings

Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.

SettingWhat it meansDefault
language (Language)Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese).en

Good to know

  • Up to 9 buttons, one per number key. Names up to 40 characters, instructions up to 2000.
  • Buttons can be created in the terminal and in the desktop app.

Install

You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.

  1. Copy this folder anywhere on your computer.
  2. Inside the copy, rename the folder _claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.
  3. Start Claude Code with the mod:
claude --plugin-dir path/to/prompt-buttons

To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").

Check it

From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 7 automated tests of this mod:

node scripts/check-mod.mjs examples/prompt-buttons

The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.

For developers: the examples table lists the hooks and API calls this mod demonstrates.

Source 4 files
hooks/register.tsx 161 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PromptButton } from '../types'
5import { KEY_PREFIX, byId, parseIndex, validate } from './buttons'
6import { strings } from './i18n'
7import type { Strings } from './i18n'
8
9const PANE = 'prompt-buttons'
10const buttons = atom({ plugin: 'prompt-buttons', key: 'buttons' } as const, [])
11const isBusy = atom({ plugin: 'prompt-buttons', key: 'isBusy' } as const, false)
12const draftLabel = atom({ plugin: 'prompt-buttons', key: 'draftLabel' } as const, '')
13const error = atom({ plugin: 'prompt-buttons', key: 'error' } as const, '')
14
15// Buttons live in $.store, one key each, so they survive sessions and /clear.
16async function load($: EngineInterface) {
17  const keys = (await $.store.keys()).filter(k => k.startsWith(KEY_PREFIX))
18  const list: PromptButton[] = []
19  for (const key of keys) {
20    const value = await $.store.get(key)
21    if (value !== null && typeof value === 'object' && 'label' in value && 'text' in value) list.push(value as PromptButton)
22  }
23  await update($, buttons, () => list.sort(byId))
24}
25
26// Sends the saved instruction. Claude reads it framed as sent by this mod (no asUser).
27// $.prompt.submit waits until Claude is idle, so it is never awaited inside a handler, and it is
28// refused when called from a command.run hook (it would wait on the turn that hook holds):
29// the command path schedules it with $.clock.after(0, …) instead.
30async function send($: EngineInterface, button: PromptButton, t: Strings): Promise<string> {
31  const busy = await read($, isBusy)
32  $.prompt.submit({ text: button.text }).catch(() => $.ui.toast(t.couldNotSend(button.label)))
33  const note = busy ? t.queued(button.label) : t.sent(button.label)
34  $.ui.toast(note)
35  return note
36}
37
38export const register: Register = (on, options) => {
39  const t = strings(options.language)
40
41  on('session.start', async ($, e, next) => {
42    await load($)
43    try {
44      await $.command.register({ name: 'buttons', description: t.commandDescription, argumentHint: '[n]', immediate: true })
45    } catch {
46      // name taken: the pane is still reachable when opened from session.start below
47    }
48    void $.ui.open({ id: PANE, title: t.paneTitle })
49    return next(e)
50  })
51
52  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
53    await load($)
54    return next(e)
55  })
56
57  on('turn.start', async ($, e, next) => {
58    await update($, isBusy, () => true)
59    return next(e)
60  })
61
62  on('turn.complete', async ($, e, next) => {
63    if (e.agentId === undefined) await update($, isBusy, () => false)
64    return next(e)
65  })
66
67  on('command.run', { command: 'buttons' }, async ($, e) => {
68    const list = await read($, buttons)
69    const index = parseIndex(e.args, list.length, t)
70    if (index === undefined) {
71      const opened = await $.ui.open({ id: PANE, title: t.paneTitle, focus: true })
72      if (opened.isPlaced) return {}
73      // Nothing draws here (VS Code chat, claude -p): the same buttons as text.
74      return { text: list.length === 0 ? t.noButtons : list.map((b, i) => `${i + 1}. ${b.label}`).join('\n') }
75    }
76    if (typeof index === 'object') return { text: index.error }
77    const button = list[index]
78    if (button === undefined) return { text: t.noSuchButton }
79    const busy = await read($, isBusy)
80    $.clock.after(0, () => send($, button, t)) // outside this hook, where submit is allowed
81    return { text: busy ? t.queued(button.label) : t.sending(button.label) }
82  })
83
84  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
85    const ui = $.ui.resolve(e)
86    const { Box, Text, Button } = ui
87    const list = await read($, buttons)
88    const busy = await read($, isBusy)
89    const label = await read($, draftLabel)
90    const problem = await read($, error)
91
92    return (
93      <Box flexDirection="column" paddingX={1} gap={1}>
94        <Box justifyContent="space-between">
95          <Text bold color="claude">{t.heading}</Text>
96          <Text color={busy ? 'warning' : 'success'}>{busy ? t.working : t.ready}</Text>
97        </Box>
98
99        <Box flexDirection="column">
100          {list.length === 0 && <Text dimColor>{t.emptyPane}</Text>}
101          {list.map((b, i) => (
102            <Box key={`row-${b.id}`} gap={1}>
103              <Button key={`run-${b.id}`} label={b.label} hotkey={String(i + 1)} plain onPress={() => send($, b, t)} />
104              <Box flexGrow={1}><Text dimColor wrap="truncate-end">{b.text}</Text></Box>
105              <Button
106                key={`del-${b.id}`}
107                label="×"
108                plain
109                dimColor
110                onPress={async () => {
111                  await $.store.delete(`${KEY_PREFIX}${b.id}`)
112                  await update($, buttons, l => l.filter(x => x.id !== b.id))
113                }}
114              />
115            </Box>
116          ))}
117        </Box>
118
119        {e.surface !== 'mobile' && 'Input' in ui ? (
120          <Box flexDirection="column">
121            <ui.Input
122              key="new-label"
123              label={t.labelField}
124              placeholder={t.labelPlaceholder}
125              value={label}
126              submitLabel={t.labelSubmit}
127              onInput={value => update($, draftLabel, () => value)}
128              onSubmit={value => update($, draftLabel, () => value)}
129            />
130            <ui.Input
131              key="new-text"
132              label={t.textField}
133              placeholder={t.textPlaceholder}
134              value=""
135              submitLabel={t.textSubmit}
136              onSubmit={async text => {
137                const checked = validate(await read($, draftLabel), text, (await read($, buttons)).length, t)
138                if ('error' in checked) {
139                  await update($, error, () => checked.error)
140                  return
141                }
142                const id = String(await $.clock.now())
143                const button: PromptButton = { id, ...checked }
144                await $.store.set(`${KEY_PREFIX}${id}`, button)
145                await update($, buttons, l => [...l, button].sort(byId))
146                await update($, draftLabel, () => '')
147                await update($, error, () => '')
148              }}
149            />
150            {problem !== '' && <Text color="error">✗ {problem}</Text>}
151          </Box>
152        ) : (
153          <Text dimColor>{t.addElsewhere}</Text>
154        )}
155
156        <Text dimColor>{t.footer}</Text>
157      </Box>
158    )
159  })
160}
161
hooks/buttons.ts 35 lines
1// Pure rules for prompt-buttons. No $ here. Messages come from the i18n table (English by default).
2
3import { strings } from './i18n'
4import type { Strings } from './i18n'
5
6const EN = strings('en')
7
8export const MAX_BUTTONS = 9 // one digit hotkey each
9export const MAX_LABEL = 40
10export const MAX_TEXT = 2000
11export const KEY_PREFIX = 'btn:' // one $.store key per button: sessions never overwrite each other's list
12
13export function validate(label: string, text: string, count: number, s: Strings = EN): { label: string; text: string } | { error: string } {
14  const l = label.trim().replace(/\s+/g, ' ')
15  const t = text.trim()
16  if (count >= MAX_BUTTONS) return { error: s.tooMany(MAX_BUTTONS) }
17  if (l === '') return { error: s.needLabel }
18  if (t === '') return { error: s.needText }
19  return { label: l.slice(0, MAX_LABEL), text: t.slice(0, MAX_TEXT) }
20}
21
22// "/buttons 2" → index 1; anything else is an error message.
23export function parseIndex(args: string, count: number, s: Strings = EN): number | { error: string } | undefined {
24  const raw = args.trim()
25  if (raw === '') return undefined
26  if (!/^\d$/.test(raw)) return { error: s.usage(MAX_BUTTONS) }
27  const n = Number(raw)
28  if (n < 1 || n > count) return { error: count === 0 ? s.noButtonsAddInPane : s.pick(count) }
29  return n - 1
30}
31
32export function byId(a: { id: string }, b: { id: string }): number {
33  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
34}
35
hooks/i18n.ts 72 lines
1// The text this mod shows, per language. Pure: no $ calls, so tests can cover it directly.
2// Anything other than a known language falls back to English.
3
4export type Language = 'en' | 'pt-BR'
5
6const en = {
7  // buttons.ts (validation and /buttons n)
8  tooMany: (max: number) => `At most ${max} buttons (one per digit hotkey). Delete one first.`,
9  needLabel: 'Give the button a label first.',
10  needText: 'Type the instruction the button should send.',
11  usage: (max: number) => `Usage: /buttons [1-${max}]`,
12  noButtonsAddInPane: 'No buttons yet. Add one in the pane.',
13  pick: (count: number) => `Pick 1 to ${count}.`,
14  // register.tsx
15  couldNotSend: (label: string) => `Could not send: ${label}`,
16  queued: (label: string) => `Queued: ${label} (runs when Claude is done)`,
17  sent: (label: string) => `Sent: ${label}`,
18  sending: (label: string) => `Sending: ${label}`,
19  commandDescription: 'Open your prompt buttons, or run one: /buttons 2',
20  paneTitle: 'Prompt buttons',
21  noButtons: 'No buttons yet.',
22  noSuchButton: 'No such button.',
23  heading: '◆ Prompt buttons',
24  working: '◌ Claude is working · new presses queue',
25  ready: '● ready',
26  emptyPane: 'No buttons yet. Add one below: a label and the instruction it sends.',
27  labelField: 'Label',
28  labelPlaceholder: 'e.g. Run tests and fix',
29  labelSubmit: 'next',
30  textField: 'Instruction',
31  textPlaceholder: 'What Claude should do, then Enter to add',
32  textSubmit: 'add',
33  addElsewhere: 'Add buttons from the terminal or the desktop app.',
34  footer: '1-9 send · × delete · Tab moves · /buttons n from anywhere',
35}
36
37const ptBR: typeof en = {
38  tooMany: (max: number) => `No máximo ${max} botões (um por tecla numérica). Apague um antes.`,
39  needLabel: 'Dê um nome ao botão primeiro.',
40  needText: 'Digite a instrução que o botão deve enviar.',
41  usage: (max: number) => `Uso: /buttons [1-${max}]`,
42  noButtonsAddInPane: 'Nenhum botão ainda. Crie um no painel.',
43  pick: (count: number) => `Escolha de 1 a ${count}.`,
44  couldNotSend: (label: string) => `Não foi possível enviar: ${label}`,
45  queued: (label: string) => `Na fila: ${label} (roda quando o Claude terminar)`,
46  sent: (label: string) => `Enviado: ${label}`,
47  sending: (label: string) => `Enviando: ${label}`,
48  commandDescription: 'Abre seus botões de prompt, ou roda um: /buttons 2',
49  paneTitle: 'Botões de prompt',
50  noButtons: 'Nenhum botão ainda.',
51  noSuchButton: 'Esse botão não existe.',
52  heading: '◆ Botões de prompt',
53  working: '◌ Claude trabalhando · novos cliques vão para a fila',
54  ready: '● pronto',
55  emptyPane: 'Nenhum botão ainda. Crie um abaixo: um nome e a instrução que ele envia.',
56  labelField: 'Nome',
57  labelPlaceholder: 'ex.: Rodar testes e corrigir',
58  labelSubmit: 'próximo',
59  textField: 'Instrução',
60  textPlaceholder: 'O que o Claude deve fazer; Enter para criar',
61  textSubmit: 'criar',
62  addElsewhere: 'Crie botões pelo terminal ou pelo app desktop.',
63  footer: '1-9 envia · × apaga · Tab navega · /buttons n de qualquer lugar',
64}
65
66export type Strings = typeof en
67const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
68
69export function strings(language: unknown): Strings {
70  return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
71}
72
types/index.d.ts 8 lines
1export type PromptButton = { id: string; label: string; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'prompt-buttons': { buttons: PromptButton[]; isBusy: boolean; draftLabel: string; error: string }
6  }
7}
8