SLOPSHOPPER

prompt-snippets

Save a prompt once, insert it anywhere with /snip, and quote your selection with /quote.

newrowscommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-snippets
› 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 › /snip ⎿ prompt-snippets: - `/snip save <name>` saves your last prompt ⎿ prompt-snippets: - `/snip <name>` inserts a snippet into the prompt ⎿ prompt-snippets: - `/snip list` shows your snippets ⎿ prompt-snippets: - `/snip rm <name>` removes one ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-snippets

Save a prompt once, insert it anywhere with /snip, and quote your selection with /quote.

What it draws

Each command answers in one row under the command:

> /snip save review
  Saved snippet review with your last prompt. /snip review inserts it.

> /snip list
  | Name       | Snippet                                                       |
  | ---------- | ------------------------------------------------------------- |
  | commit-msg | Write a conventional commit message for the staged changes.… |
  | explain    | Explain what this function does, step by step.                |
  | review     | Review the staged diff for bugs, missing tests and unclear n… |

> /snip commit-msg
  Inserted snippet commit-msg.

> /quote
  Quoted the selection into the prompt.
  • The /snip list answer is drawn as Markdown, so the table renders as a table in the terminal and in the desktop app. Every other answer is Claude Code's own row.
  • Snippets are kept across sessions in the mod's own store.
  • Where the session has no prompt box a mod can fill (the desktop app, claude -p), /snip <name> and /quote leave the box alone and answer the text to copy:
> /snip commit-msg
  This session has no prompt box a mod can fill (the desktop app, claude -p). Copy it from here:

    Write a conventional commit message for the staged changes. Keep the subject under 72 characters.

Commands

CommandWhat it does
/snip save <name>Saves the last prompt you sent. Saving under an existing name replaces it.
/snip <name>Inserts the snippet into the terminal's prompt box at the cursor.
/snip listAnswers a table of your snippets: each name and its first 60 characters.
/snip rm <name>Removes a snippet.
/quotePuts your current mouse selection into the prompt as > lines, followed by a blank line.
  • /snip save takes the last prompt you sent from the terminal, the desktop app or Remote Control. Slash commands, reminders and messages from other sessions, agents or channels do not count.
  • In a session resumed before you sent anything, /snip save reads the transcript instead: the last user message that is not a command line, a tool result, a reminder, the summary /compact leaves, or a teammate or channel message.
  • Names are 1 to 32 characters of a-z, 0-9 and -. save, list, rm and help are taken by the subcommands.
  • Both commands run at once, even while Claude is answering.
  • Errors answer plainly, for example No snippet named x. /snip list shows yours.

Install

  • Terminal: /plugin install prompt-snippets --marketplace KROU4/claude-code-mod-showroom
  • Shell: claude plugin install prompt-snippets --marketplace KROU4/claude-code-mod-showroom
  • Desktop app: + next to the prompt box → Plugins → Add marketplace KROU4/claude-code-mod-showroom → Add plugin

Limits

  • Only the terminal's prompt box takes text from a mod. The desktop app draws its own, and claude -p has none.
  • /quote reads the mouse selection in the desktop app and in the terminal's fullscreen layout. The terminal's main screen never shows a mod its selection, and /quote says so there.
  • All snippets together hold at most 4 MiB.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptFillResult, PromptOrigin, Register } from 'claude-code'
3
4const NAME = /^[a-z0-9-]{1,32}$/
5const VERBS = new Set(['save', 'list', 'rm', 'help'])
6const PREVIEW = 60
7// Where a prompt the person wrote arrives from: the terminal, the desktop app or another SDK host, Remote Control.
8const BY_PERSON = new Set<PromptOrigin['kind']>(['composer', 'sdk', 'bridge'])
9// User-role rows the person did not type: a command's record and output, reminders and
10// notices, the summary /compact leaves, messages from teammates and channels.
11const NOT_TYPED =
12  /^(<(command-|local-command-|system-reminder|bash-|task-notification|teammate-message|channel)|\[Request interrupted|This session is being continued from a previous conversation)/
13
14const typed = atom({ plugin: 'prompt-snippets', key: 'typed' } as const, null)
15
16const USAGE = [
17  '- `/snip save <name>` saves your last prompt',
18  '- `/snip <name>` inserts a snippet into the prompt',
19  '- `/snip list` shows your snippets',
20  '- `/snip rm <name>` removes one',
21].join('\n')
22
23// The answer is drawn as Markdown: keep its punctuation from acting.
24const escape = (text: string) => text.replace(/[\\`*_~<>[\]|&]/g, '\\$&')
25
26const missing = (name: string) => `No snippet named ${name}. \`/snip list\` shows yours.`
27const badName = (name: string) =>
28  `${escape(name)} is not a snippet name: use 1-32 characters of \`a-z\`, \`0-9\` and \`-\`.`
29const failed = (command: string) => (error: unknown) =>
30  `\`${command}\` did not finish: ${error instanceof Error ? error.message : String(error)}`
31
32// A fence longer than any backtick run in the text, so the text shows as it is.
33function fence(text: string): string {
34  const runs = text.match(/`+/g) ?? []
35  const bar = '`'.repeat(Math.max(3, ...runs.map(run => run.length + 1)))
36
37  return `${bar}\n${text.trimEnd()}\n${bar}`
38}
39
40// Puts text in at the cursor and answers `done`, or why it could not.
41async function insert($: EngineInterface, text: string, done: string): Promise<string> {
42  // Only the terminal's prompt box takes a mod's text: the desktop app draws its own, -p has none.
43  const hasBox = (await $.session.surfaces()).includes('terminal')
44  const filled: PromptFillResult = hasBox
45    ? await $.prompt.fill({ text, mode: 'insert' })
46    : { isFilled: false, refusal: 'no_composer' }
47
48  if (filled.isFilled) return done
49  if (filled.refusal === 'no_composer') {
50    return `This session has no prompt box a mod can fill (the desktop app, \`claude -p\`). Copy it from here:\n\n${fence(text)}`
51  }
52  if (filled.refusal === 'dialog') return 'A dialog holds the keys. Close it, then run the command again.'
53
54  return 'Another plugin kept the text out of the prompt box.'
55}
56
57async function lastPrompt($: EngineInterface): Promise<string | undefined> {
58  const sent = await read($, typed)
59  if (sent !== null) return sent
60
61  // Nothing sent since the mod loaded (a resumed session): the transcript's last row that reads as typed.
62  const rows = await $.session.messages()
63
64  return rows.findLast(
65    row =>
66      row.role === 'user' &&
67      !row.toolResults?.length &&
68      row.text.trim() !== '' &&
69      !NOT_TYPED.test(row.text.trimStart()),
70  )?.text
71}
72
73function preview(text: string): string {
74  const flat = text.replace(/\s+/g, ' ').trim()
75
76  return escape(flat.length > PREVIEW ? `${flat.slice(0, PREVIEW).trimEnd()}…` : flat)
77}
78
79async function table($: EngineInterface): Promise<string> {
80  const names = (await $.store.keys()).sort()
81  if (names.length === 0) {
82    return 'No snippets yet. Send a prompt, then `/snip save <name>` keeps it.'
83  }
84  const rows = await Promise.all(
85    names.map(async name => `| ${name} | ${preview(String(await $.store.get(name)))} |`),
86  )
87
88  return ['| Name | Snippet |', '| --- | --- |', ...rows].join('\n')
89}
90
91async function snip($: EngineInterface, args: string): Promise<string> {
92  const [verb = '', name = '', ...rest] = args.trim().split(/\s+/)
93
94  if (verb === '' || verb === 'help') return USAGE
95  if (verb === 'list') return name === '' ? table($) : `\`/snip list\` takes no name.\n\n${USAGE}`
96
97  if (verb === 'save' || verb === 'rm') {
98    if (name === '') return `Name the snippet: \`/snip ${verb} <name>\`.`
99    if (!NAME.test(name) || rest.length > 0) return badName([name, ...rest].join(' '))
100    if (VERBS.has(name)) return `${name} is a \`/snip\` command, so it cannot name a snippet.`
101    const held = await $.store.get(name)
102
103    if (verb === 'rm') {
104      if (held === undefined) return missing(name)
105      await $.store.delete(name)
106      return `Removed snippet ${name}.`
107    }
108
109    const prompt = await lastPrompt($)
110    if (prompt === undefined) return 'No prompt to save yet. Send one, then `/snip save <name>`.'
111    try {
112      await $.store.set(name, prompt)
113    } catch {
114      return 'The snippet store refused it: all snippets together hold at most 4 MiB. `/snip rm <name>` frees room.'
115    }
116    return `${held === undefined ? 'Saved' : 'Replaced'} snippet ${name} with your last prompt. \`/snip ${name}\` inserts it.`
117  }
118
119  if (!NAME.test(verb) || name !== '') return `${badName(args.trim())}\n\n${USAGE}`
120  const text = await $.store.get(verb)
121  if (typeof text !== 'string') return missing(verb)
122
123  return insert($, text, `Inserted snippet ${verb}.`)
124}
125
126async function quote($: EngineInterface, isFullscreen: boolean): Promise<string> {
127  const lines = (await $.ui.selection())?.text.replace(/\s+$/, '')
128  if (!lines) {
129    // The main-screen terminal never shows a mod its selection.
130    return isFullscreen
131      ? 'Nothing is selected. Select text with the mouse, then `/quote`.'
132      : "Nothing is selected that `/quote` can read: it reads the mouse selection in the desktop app and in the terminal's fullscreen layout."
133  }
134  const quoted = `${lines
135    .split(/\r?\n/)
136    .map(line => `> ${line}`)
137    .join('\n')}\n\n`
138
139  return insert($, quoted, 'Quoted the selection into the prompt.')
140}
141
142export const register: Register = on => {
143  on('session.start', async ($, e, next) => {
144    await $.command.register({
145      name: 'snip',
146      description: 'Save, list and insert reusable prompt snippets',
147      argumentHint: '<name> | save <name> | list | rm <name>',
148      immediate: true,
149    })
150    await $.command.register({
151      name: 'quote',
152      description: 'Quote your mouse selection into the prompt',
153      immediate: true,
154    })
155
156    return next(e)
157  })
158
159  // Only notes the prompt down: a failure lets it through.
160  on('prompt.submit', async ($, e, next) => {
161    if (BY_PERSON.has(e.origin.kind) && !e.isMeta && !e.command && e.text.trim() !== '') {
162      await update($, typed, () => e.text)
163    }
164
165    return next(e)
166  }).catch(($, e, next) => next(e))
167
168  on('command.run', { command: 'snip' }, async ($, e) => ({
169    text: await snip($, e.args).catch(failed('/snip')),
170  }))
171
172  on('command.run', { command: 'quote' }, async ($, e) => ({
173    text: await quote($, e.presentation.isFullscreen).catch(failed('/quote')),
174  }))
175
176  // The list is a Markdown table: draw it as one rather than as the row's plain text.
177  on('ui.render', { component: 'CommandOutput', props: { command: 'snip' } }, ($, e, next) => {
178    if (e.props.isErrored || e.props.args.trim() !== 'list') return next(e)
179    const { Markdown } = $.ui.resolve(e)
180
181    return <Markdown text={e.props.text} />
182  })
183}
184
types/index.d.ts 9 lines
1// The last prompt the person sent this session; null until they send one.
2export type LastPrompt = string | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    'prompt-snippets': { typed: LastPrompt }
7  }
8}
9