SLOPSHOPPER

snippets

The input bar, smarter: your Linear tickets in the typeahead as you type an id, live highlighting (tickets, paths, @mentions, secrets in red), and ;;snippets…

newbandcommandtoastpromptprocess
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/snippets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 › /snippets ⎿ snippets: ;;cont Continue where you left off. ⎿ snippets: ;;explain Explain how this works, step by step, naming the files and functions involved. ⎿ snippets: ;;mmi Merge origin main into this branch and resolve conflicts. ⎿ snippets: ;;plan Before writing any code, write a short plan: the files you will touch, the approach, and the risks ⎿ snippets: ;;pr Open a pull request for this branch: a clear title, and a summary of what changed and why. ⎿ snippets: ;;review Review the changes on this branch for bugs, edge cases and missing tests. List the findings by sev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

snippets

The input bar, smarter.

Typing a prompt, ticket and file names are highlighted. The text ;;plan expands into a full planning request before it is sent.

  • Ticket typeahead: type a ticket id (ENG-, ENG-34) and your Linear issues show in the typeahead with their titles and status; take one with Tab or Enter. Your open issues, kept fresh every ten minutes.
  • Live highlighting as you type: your tickets bold in blue, other ticket ids in cyan, file paths in green, @mentions in purple, ;;snippets in amber, and anything that looks like a secret (API keys, tokens, private keys, password=…) in red, with a warning, before you send it.
  • Pasted image previews: paste an image and a small thumbnail shows above the input, labeled with its [Image #N] number; delete the tag and it goes, send and the row clears. Claude Code keeps pasted images to itself, so the mod saves a small copy from the clipboard the moment the tag appears, with a tiny helper compiled once from scripts/clipthumb.swift (about 0.15 s a paste; AppleScript fallback without Swift) into ~/Library/Caches/snippets/pasted. Pictures need a terminal that shows them (WezTerm, kitty, Ghostty, iTerm2); elsewhere it shows the tag as text.
  • Snippets: type ;;name and a space, and it expands. ;; alone lists them in the typeahead. Sent without the space, it expands too.
SnippetExpands to
;;mmiMerge origin main into this branch and resolve conflicts.
;;planA short plan first: files, approach, risks; wait for the go.
;;reviewReview this branch for bugs, edge cases and missing tests, by severity.
;;testsRun the tests and fix failures the changes caused.
;;prOpen a PR with a clear title and a summary of what and why.
;;explainExplain how this works, step by step, naming files and functions.
;;tldrSummarize the last answer in three bullets.
;;contContinue where you left off.

/snippets lists them; /snippets add <name> <text> adds or changes one; /snippets remove <name> drops one.

Source 3 files
hooks/register.ts 205 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_SNIPPETS, expandAll, expandAtCursor, paint, secretsIn, suggestions } from './draft'
5import type { Issue } from './draft'
6import type { Thumb } from '../types'
7
8const SNIPPETS_KEY = 'snippets'
9const ISSUES_EVERY_MS = 10 * 60_000
10/** The names a Linear MCP server goes by: the claude.ai connector, or one added with `claude mcp add`. */
11const LINEAR_SERVERS = ['claude.ai Linear', 'linear', 'Linear']
12const OPEN_STATES = ['started', 'unstarted', 'triage', 'backlog']
13
14let issues: Issue[] = []
15let known = new Set<string>()
16let snippets: Record<string, string> = { ...DEFAULT_SNIPPETS }
17let linearServer: string | null = null
18let warnedSecret = ''
19
20// Pasted images: Claude Code puts "[Image #N]" in the draft and keeps the picture to itself, so each one is
21// saved from the clipboard the moment its tag shows up, and drawn small above the input.
22const thumbs = atom({ plugin: 'snippets', key: 'thumbs' } as const, [] as Thumb[])
23const IMAGE_TAG = /\[Image #(\d+)\]/g
24const THUMB_ROWS = 6
25const THUMB_PX = 360
26/** A terminal cell's width over its height. */
27const CELL_ASPECT = 0.46
28const captured = new Map<number, Thumb | null>()
29const capturing = new Set<number>()
30
31function clipScript($: EngineInterface): string {
32  return `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/scripts/clip-image.sh`
33}
34
35/** Saves the pasted image for each new tag in the draft, and shows the thumbnails of the tags still in it. */
36async function syncThumbs($: EngineInterface, text: string) {
37  const numbers = [...new Set([...text.matchAll(IMAGE_TAG)].map(found => Number(found[1])))]
38  for (const number of numbers) {
39    if (captured.has(number) || capturing.has(number)) continue
40    capturing.add(number)
41    const session = (await $.session.id()).replace(/[^\w-]/g, '')
42    const folder = `${await $.env.get('HOME')}/Library/Caches/snippets/pasted`
43    const ran = await $.process
44      .run(['sh', clipScript($), `${folder}/${session}-${number}.png`, String(THUMB_PX)], { timeoutMs: 30_000 })
45      .catch(() => null)
46    const size = ran?.exitCode === 0 ? ran.stdout.trim().split(/\s+/).map(Number) : []
47    captured.set(number, size.length === 2 && size[0]! > 0 ? { number, file: `${folder}/${session}-${number}.png`, width: size[0]!, height: size[1]! } : null)
48    capturing.delete(number)
49  }
50  const shown = numbers.map(number => captured.get(number)).filter((thumb): thumb is Thumb => Boolean(thumb))
51  const before = await read($, thumbs)
52  if (shown.map(thumb => thumb.number).join() !== before.map(thumb => thumb.number).join()) await update($, thumbs, () => shown)
53}
54
55/** Your Linear issues, open ones first, for the ticket typeahead and highlighting; kept fresh in the background. */
56async function loadIssues($: EngineInterface) {
57  const found: Issue[] = []
58  for (const state of OPEN_STATES) {
59    const result = await linearCall($, { assignee: 'me', state, limit: 50, orderBy: 'updatedAt', fields: ['id', 'title', 'status'] })
60    if (result === null) return
61    const text = result.content.map(block => block.text ?? '').join('')
62    if (result.isError) return
63    const data = JSON.parse(text) as { issues?: { id?: string; identifier?: string; title?: string; status?: string }[] }
64    for (const raw of data.issues ?? []) {
65      const id = String(raw.identifier ?? raw.id ?? '')
66      if (id) found.push({ id, title: String(raw.title ?? ''), status: String(raw.status ?? '') })
67    }
68  }
69  issues = found
70  known = new Set(found.map(issue => issue.id))
71}
72
73async function linearCall($: EngineInterface, args: Record<string, unknown>) {
74  const names = [...new Set([linearServer, ...LINEAR_SERVERS].filter((name): name is string => Boolean(name)))]
75  for (const server of names) {
76    try {
77      const result = await $.mcp.call(server, 'list_issues', args)
78      linearServer = server
79      return result
80    } catch {
81      // Not connected under this name: try the next.
82    }
83  }
84  return null
85}
86
87/** Whether this terminal shows pictures (kitty graphics). */
88async function showsPictures($: EngineInterface): Promise<boolean> {
89  if ((await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) === '1') return true
90  if (await $.env.get('KITTY_WINDOW_ID')) return true
91  return ['WezTerm', 'ghostty', 'kitty', 'iTerm.app'].includes((await $.env.get('TERM_PROGRAM')) ?? '')
92}
93
94async function saveSnippets($: EngineInterface) {
95  const own = Object.fromEntries(Object.entries(snippets).filter(([name, text]) => DEFAULT_SNIPPETS[name] !== text))
96  const removed = Object.keys(DEFAULT_SNIPPETS).filter(name => snippets[name] === undefined)
97  await $.store.set(SNIPPETS_KEY, { own, removed })
98}
99
100function snippetList(): string {
101  const rows = Object.entries(snippets)
102    .sort(([a], [b]) => a.localeCompare(b))
103    .map(([name, text]) => `;;${name.padEnd(10)}${text}`)
104  return [...rows, '', 'Type ;;name then space (or Tab from the list) to expand it.', '/snippets add <name> <text> · /snippets remove <name>'].join('\n')
105}
106
107export const register: Register = on => {
108  on('session.start', async ($, e, next) => {
109    // Not awaited: registering makes Claude Code re-describe every command, which would hold startup.
110    // Typed as /snippets in the terminal; the command file is what the desktop app lists in its menu.
111    void $.command.register({ name: 'snippets', description: 'Your ;;snippets: list, add, remove', argumentHint: '[add <name> <text>|remove <name>]' }).catch(() => undefined)
112    const stored = (await $.store.get(SNIPPETS_KEY)) as { own?: Record<string, string>; removed?: string[] } | undefined
113    snippets = { ...DEFAULT_SNIPPETS, ...(stored?.own ?? {}) }
114    for (const name of stored?.removed ?? []) delete snippets[name]
115    void loadIssues($).catch(() => undefined)
116    $.clock.every(ISSUES_EVERY_MS, () => void loadIssues($).catch(() => undefined))
117    // The clipboard helper is compiled once (a few seconds): do it now, so the first paste is quick too.
118    void $.process.run(['sh', clipScript($), '--build'], { timeoutMs: 120_000 }).catch(() => undefined)
119    // A paste may not pass through an edit the mod sees: check the draft for new image tags four times a second.
120    $.clock.every(250, () => {
121      void (async () => {
122        const box = await $.prompt.read()
123        if (box.text.includes('[Image #') || (await read($, thumbs)).length > 0) await syncThumbs($, box.text)
124      })().catch(() => undefined)
125    })
126    return next(e)
127  })
128
129  // Each edit: a ;;snippet expands when you type a space after it, and the draft is painted.
130  on('prompt.edit', async ($, e, next) => {
131    let box = await next(e)
132    if (e.inputText === ' ' || e.inputText === '\t') {
133      const expanded = expandAtCursor(box.text, box.cursor, snippets)
134      if (expanded) box = { ...box, ...expanded }
135    }
136    const secrets = secretsIn(box.text)
137    if (secrets.length > 0) {
138      const first = box.text.slice(secrets[0]!.start, secrets[0]!.end)
139      if (first !== warnedSecret) {
140        warnedSecret = first
141        void $.ui.toast('That looks like a secret (a key or token). Take it out before you send.')
142      }
143    }
144    void syncThumbs($, box.text).catch(() => undefined)
145    return { ...box, decorations: [...(box.decorations ?? []), ...paint(box.text, known, snippets)] }
146  }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
147
148  // The typeahead: your Linear tickets as you type an id, your snippets after ;;.
149  on('prompt.autocomplete', async ($, e, next) => {
150    const result = await next(e)
151    const mine = suggestions(e.token, issues, snippets)
152    return mine.length === 0 ? result : { suggestions: [...mine, ...result.suggestions] }
153  }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
154
155  // A ;;snippet sent with no space after it still expands. Sending clears the image previews.
156  on('prompt.submit', async ($, e, next) => {
157    void update($, thumbs, () => [])
158    const text = expandAll(e.text, snippets)
159    return next(text === e.text ? e : { ...e, text })
160  }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
161
162  // Pasted images, small, in a row above the input, each with its tag's number.
163  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164    const shown = await read($, thumbs)
165    if (shown.length === 0 || e.props.hasSurvey) return next(e)
166    const { Box, Text, Image } = $.ui.resolve(e)
167    const above = await next(e)
168    const pictures = e.surface === 'terminal' && Image !== undefined && (await showsPictures($))
169    const row = h(
170      Box,
171      { flexDirection: 'row', columnGap: 2, flexWrap: 'wrap' },
172      ...shown.map(thumb => {
173        const columns = Math.max(4, Math.min(48, Math.round((THUMB_ROWS * thumb.width) / thumb.height / CELL_ASPECT)))
174        return h(
175          Box,
176          { key: `thumb-${thumb.number}`, flexDirection: 'column' },
177          pictures
178            ? h(Image, { key: `image-${thumb.number}`, source: { file: thumb.file, format: 'png' }, columns, rows: THUMB_ROWS, alt: `Image #${thumb.number}` })
179            : null,
180          h(Text, { dimColor: true }, pictures ? `#${thumb.number}` : `🖼 Image #${thumb.number}`),
181        )
182      }),
183    )
184    return h(Box, { flexDirection: 'column' }, above, row)
185  })
186
187  on('command.run', { command: ['snippets', 'snippets:snippets'] }, async ($, e) => {
188    const [verb = '', name = '', ...rest] = e.args.trim().split(/\s+/)
189    const key = name.replace(/^;;/, '').toLowerCase()
190    if (verb === 'add') {
191      if (!/^[a-z0-9-]+$/.test(key) || rest.length === 0) return { text: 'usage: /snippets add <name> <text>' }
192      snippets[key] = rest.join(' ')
193      await saveSnippets($)
194      return { text: `;;${key} now expands to: ${snippets[key]}` }
195    }
196    if (verb === 'remove') {
197      if (snippets[key] === undefined) return { text: `No snippet ;;${key}.` }
198      delete snippets[key]
199      await saveSnippets($)
200      return { text: `;;${key} removed.` }
201    }
202    return { text: snippetList() }
203  }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
204}
205
hooks/draft.ts 113 lines
1/**
2 * What the snippets mod reads in a draft: ticket ids, paths, @mentions, ;;snippets and things that look like secrets,
3 * as runs to paint; and the snippet expansions. Pure functions, so the hooks stay small and the rules testable.
4 */
5
6export type Issue = { id: string; title: string; status: string }
7
8export type Paint = { start: number; end: number; color: string; bold?: boolean; underline?: boolean }
9
10export const DEFAULT_SNIPPETS: Record<string, string> = {
11  mmi: 'Merge origin main into this branch and resolve conflicts.',
12  plan: 'Before writing any code, write a short plan: the files you will touch, the approach, and the risks. Wait for my go.',
13  review: 'Review the changes on this branch for bugs, edge cases and missing tests. List the findings by severity.',
14  tests: 'Run the tests and fix any failures your changes caused.',
15  pr: 'Open a pull request for this branch: a clear title, and a summary of what changed and why.',
16  explain: 'Explain how this works, step by step, naming the files and functions involved.',
17  tldr: 'Summarize your last answer in three bullet points.',
18  cont: 'Continue where you left off.',
19}
20
21const TICKET = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g
22const MENTION = /(^|\s)(@[\w./~-]+)/g
23const PATH = /(^|[\s(])((?:~|\.{1,2})?\/?(?:[\w.-]+\/)+[\w.-]+)/g
24const SNIPPET = /;;[a-z0-9-]+/gi
25
26/** Shapes of keys and tokens that should not go to a model. */
27const SECRETS: RegExp[] = [
28  /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/g,
29  /\bgh[pousr]_[A-Za-z0-9]{30,}/g,
30  /\bgithub_pat_[A-Za-z0-9_]{40,}/g,
31  /\bAKIA[0-9A-Z]{16}\b/g,
32  /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
33  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
34  /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
35  /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{6,}/gi,
36]
37
38function runs(text: string, pattern: RegExp, group = 0): { start: number; end: number; match: string }[] {
39  const out: { start: number; end: number; match: string }[] = []
40  for (const found of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
41    const match = found[group]!
42    const start = found.index! + found[0].indexOf(match)
43    out.push({ start, end: start + match.length, match })
44  }
45  return out
46}
47
48/** Where a draft holds something that looks like a secret. */
49export function secretsIn(text: string): { start: number; end: number }[] {
50  return SECRETS.flatMap(pattern => runs(text, pattern))
51}
52
53/**
54 * The runs to paint over a draft: known tickets bold in blue, other ticket-shaped ids in cyan, paths, @mentions,
55 * ;;snippets waiting to expand, and secrets in red. Later runs win, so secrets are painted last.
56 */
57export function paint(text: string, known: ReadonlySet<string>, snippets: Record<string, string>): Paint[] {
58  const out: Paint[] = []
59  for (const run of runs(text, PATH, 2)) if (!/^\d/.test(run.match)) out.push({ start: run.start, end: run.end, color: '#7ec699' })
60  for (const run of runs(text, MENTION, 2)) out.push({ start: run.start, end: run.end, color: '#c792ea' })
61  for (const run of runs(text, TICKET)) {
62    out.push(known.has(run.match) ? { start: run.start, end: run.end, color: '#5fb3ff', bold: true } : { start: run.start, end: run.end, color: '#89ddff' })
63  }
64  for (const run of runs(text, SNIPPET)) {
65    const isKnown = snippets[run.match.slice(2).toLowerCase()] !== undefined
66    out.push({ start: run.start, end: run.end, color: isKnown ? '#ffcb6b' : '#8a8f98', bold: isKnown })
67  }
68  for (const run of secretsIn(text)) out.push({ start: run.start, end: run.end, color: '#ff5370', bold: true, underline: true })
69  return out
70}
71
72/**
73 * Expands the ;;snippet that ends right before the cursor, once a space or Tab has been typed after it.
74 * Returns the new draft and cursor, or null when there is nothing to expand.
75 */
76export function expandAtCursor(text: string, cursor: number, snippets: Record<string, string>): { text: string; cursor: number } | null {
77  const before = text.slice(0, cursor)
78  const found = /;;([a-z0-9-]+)(\s)$/i.exec(before)
79  if (!found) return null
80  const expansion = snippets[found[1]!.toLowerCase()]
81  if (expansion === undefined) return null
82  const start = before.length - found[0].length
83  const head = text.slice(0, start) + expansion + found[2]!
84  return { text: head + text.slice(cursor), cursor: head.length }
85}
86
87/** Every known ;;snippet left in a prompt, expanded: for one sent right after the snippet, with no space. */
88export function expandAll(text: string, snippets: Record<string, string>): string {
89  return text.replace(/;;([a-z0-9-]+)/gi, (whole, name: string) => snippets[name.toLowerCase()] ?? whole)
90}
91
92/** The typeahead rows for a token: tickets for an id being typed, snippets after ;;. */
93export function suggestions(
94  token: string,
95  issues: readonly Issue[],
96  snippets: Record<string, string>,
97): { text: string; label: string; description: string }[] {
98  if (token.startsWith(';;')) {
99    const typed = token.slice(2).toLowerCase()
100    return Object.entries(snippets)
101      .filter(([name]) => name.startsWith(typed))
102      .slice(0, 8)
103      .map(([name, expansion]) => ({ text: expansion, label: `;;${name}`, description: expansion }))
104  }
105  const ticket = /^([A-Za-z][A-Za-z0-9]{1,9})-(\d*)$/.exec(token)
106  if (!ticket) return []
107  const prefix = token.toUpperCase()
108  return issues
109    .filter(issue => issue.id.startsWith(prefix))
110    .slice(0, 8)
111    .map(issue => ({ text: issue.id, label: `${issue.id}  ${issue.title}`, description: issue.status }))
112}
113
types/index.d.ts 11 lines
1/** A pasted image's preview: its tag's number and the small copy saved from the clipboard. */
2export type Thumb = { number: number; file: string; width: number; height: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'snippets': {
7      thumbs: Thumb[]
8    }
9  }
10}
11