SLOPSHOPPER

prompt-deck

Learns the short prompts you send often in this project and draws them, with the ones you pin by hand, above the prompt; with the prompt box empty, a digit key…

newbandcommandpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-deck
› 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 › /prompt-deck ⎿ prompt-deck: on · project app ⎿ prompt-deck: 1. fix the failing auth test and add an audit log call (1) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-deck

You type the same short prompts again and again every day: "go on", "commit it", "run the tests". This mod learns the short prompts you send often in this project, and draws them, with the ones you pin by hand, in the band above the prompt. A digit key sends one at once.

What it does

  1. Each prompt you type, or send through Remote Control, is counted: trimmed, one line, 1 to 80 characters, not a slash command. A notification, a peer message, a schedule or another plugin's prompt is not counted.
  2. /prompt-deck add <text> pins a prompt by hand. A pinned prompt is drawn first, in the order it was added, and the counts never push it off the band. It has no length limit and is kept per project, like the counts. Up to 5 prompts are pinned, because the band holds 5. /prompt-deck remove <n> unpins one.
  3. A prompt reaches the band after 3 uses. The band draws the 5 most used, the latest first on a tie, as 1: commitle 2: devam et ..., each label cut to its share of the width.
  4. With the prompt box empty, a digit key sends that prompt at once. A click, or ctrl+x tab and Enter, sends it too. A press counts as one more use.
  5. The band is not drawn while a survey holds it, while a turn runs, or while an agent's transcript is in view.
  6. The counts and the pinned prompts live in the plugin store, one deck per project, shared by every session of it. The project is the session's git top level, else its working directory, and the deck is keyed by that full path, so two checkouts named app keep two decks; the status names the project by its last path part. At most 200 prompts are kept per project; the least used and oldest go first.
  7. The deck of a version before 0.2.0 counted every project into one. The first project that loads 0.2.0 takes those counts, once, and says so in one line; every other project starts empty.
  8. A deck of a version before 0.5.0 was keyed by the project's name alone. The first checkout of that name that loads 0.5.0 takes it to the key of its path, once, and says so in one line; another checkout of the same name starts empty.

A press runs the mod's own markdown command /prompt-deck:send <prompt>, whose body is its arguments alone. The transcript shows that command line, and the model reads the prompt as it is written, as it reads a typed slash command (measured on 2.1.282). A prompt that holds && /<name> would read as a command chain there, so it goes out as a plugin prompt instead, and so does a press whose command the engine refuses, with one line that says so. The model reads a plugin prompt inside a The prompt-deck plugin sent a message: frame.

In the live check a pinned Yalnız tamam kelimesini yaz. appeared as 1: Yalnız tamam kelimesini yaz., the 1 key sent it as /prompt-deck:send Yalnız tamam kelimesini yaz., and the model read the prompt without the frame and answered tamam.

Command

/prompt-deck on or off, the project, and its prompts with their uses /prompt-deck list the same /prompt-deck add <text> pin a prompt of your own, drawn before the counted ones /prompt-deck remove <n> forget the prompt at place n of the list, pinned or counted /prompt-deck clear forget every prompt /prompt-deck on | off on by default; off keeps the counts /prompt-deck:send <prompt> the command a press runs; typed, it sends the prompt as written

/prompt-deck:send is the mod's second command, the one exception to one command per mod, because only a markdown command hands the model a prompt without the plugin frame.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install prompt-deck@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.tsx hooks: session.start, command.run{command=prompt-deck}, prompt.submit, ui.render{component=AbovePrompt} ❯ ./register.tsx calls: $.clock.after (via sendPrompt), $.clock.now (via countUse), $.command.register, $.command.run (via sendPrompt), $.process.run (via resolveRoot), $.prompt.submit (via submitPrompt), $.session.cwd (via resolveRoot), $.store.delete (via adoptLegacy, adoptNamed), $.store.get (via adoptLegacy, adoptNamed, countUse, loadDeck), $.store.set (via saveCounts, savePins, setEnabled), $.ui.invalidate, $.ui.log (via adoptLegacy, adoptNamed, sendPrompt, submitPrompt), $.ui.resolve

Reach L2, runs git and drives Claude: a press submits a prompt.

  1. Reads: the text and origin of each submitted prompt; the session's working directory
  2. Runs: git rev-parse --show-toplevel, once per session, to find the project's root
  3. Sends: a stored prompt as a user turn through /prompt-deck:send, only on the person's press; nothing leaves the machine
  4. Persists: in $.store, per project, up to 200 short prompts with their use counts and last use time, up to 5 pinned prompts, and the on/off setting
  5. Hostile input: only prompts from the composer or Remote Control are counted, and only a typed /prompt-deck add pins one, so a notification, a peer or a plugin cannot put a prompt on the band

Limits

  • A prompt longer than 80 characters or over several lines is never counted. /prompt-deck add takes any length on one line.
  • The band is drawn on the terminal only, because the engine raises AbovePrompt there only.
  • Two prompts that differ only in case or punctuation count apart.
  • The on/off setting is one setting for every project.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.tsx 219 lines
1import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
2import { BAND_SIZE, band, countsKey, fit, listText, mergeCounts, normalize, normalizePin, pinsKey, projectName, readsAsChain, record, removeAt, type Counts } from './deck.ts'
3
4/** The key the mod used before the counts were split per project. */
5const LEGACY_KEY = 'counts'
6const ENABLED_KEY = 'enabled'
7
8/** The mod's markdown command (`commands/send.md`), whose body is its arguments alone. */
9const SEND_COMMAND = 'prompt-deck:send'
10
11const USAGE = 'expects nothing (the band), list, add <text>, remove <n>, clear, on or off'
12
13type Elements = ReturnType<EngineInterface['ui']['resolve']>
14
15/**
16 * The deck of this project as last read or written, so a band draw reads no store; the project's name
17 * the person reads, and its root path, which keys the store, so two checkouts of one name keep two decks.
18 */
19type State = { counts: Counts; pins: string[]; enabled: boolean; project: string; root: string }
20
21/** The person's own prompts: typed at the terminal or sent from a phone. */
22function isPersons(origin: PromptOrigin): boolean {
23  return origin.kind === 'composer' || origin.kind === 'bridge'
24}
25
26/** The root of the project the session works in: its git top level, else its directory. */
27async function resolveRoot($: EngineInterface): Promise<string> {
28  const cwd = await $.session.cwd()
29  try {
30    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 10_000 })
31    return r.exitCode === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : cwd
32  } catch {
33    // git is missing, or the command did not run: the session's directory is the project.
34    return cwd
35  }
36}
37
38/**
39 * Reads the deck and the on/off setting from the store, which every window shares, so a change made in
40 * another window applies here at the next hook that acts on it. The band's draw reads the copy this keeps.
41 */
42async function loadDeck($: EngineInterface, state: State): Promise<void> {
43  state.counts = ((await $.store.get(countsKey(state.root))) as Counts | undefined) ?? {}
44  state.pins = ((await $.store.get(pinsKey(state.root))) as string[] | undefined) ?? []
45  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
46}
47
48async function saveCounts($: EngineInterface, state: State, counts: Counts): Promise<void> {
49  await $.store.set(countsKey(state.root), counts)
50  state.counts = counts
51  $.ui.invalidate('ui.render')
52}
53
54async function savePins($: EngineInterface, state: State, pins: string[]): Promise<void> {
55  await $.store.set(pinsKey(state.root), pins)
56  state.pins = pins
57  $.ui.invalidate('ui.render')
58}
59
60/** `/prompt-deck add <text>`: pins a prompt of the person's own, drawn before the counted ones. */
61async function addCommand($: EngineInterface, state: State, text: string): Promise<string> {
62  const pin = normalizePin(text)
63  if (pin === undefined) return 'add expects one line of text that does not start with /'
64  if (state.pins.includes(pin)) return 'that prompt is already pinned'
65  if (state.pins.length >= BAND_SIZE) return `the band holds ${BAND_SIZE} prompts and all of them are pinned; remove one first`
66  await savePins($, state, [...state.pins, pin])
67  return `pinned at ${state.pins.length} of ${BAND_SIZE}`
68}
69
70/**
71 * Moves the counts of the one shared deck into this project, once. The first project that loads the mod
72 * after the update takes them, because the mod cannot tell where each of them was typed.
73 */
74async function adoptLegacy($: EngineInterface, state: State): Promise<void> {
75  const legacy = (await $.store.get(LEGACY_KEY)) as Counts | undefined
76  if (legacy === undefined) return
77  await saveCounts($, state, mergeCounts(state.counts, legacy))
78  await $.store.delete(LEGACY_KEY)
79  $.ui.log(`the shared deck of ${Object.keys(legacy).length} prompt(s) is now this project's (${state.project}); each project counts its own prompts from here on`)
80}
81
82/**
83 * Moves the deck kept under the project's name alone (up to 0.4) to the key of its root, once. The first
84 * checkout of that name that loads the mod takes it, because the mod cannot tell which checkout it came
85 * from; counts already under the root key are joined with it, and pins already there stay.
86 */
87async function adoptNamed($: EngineInterface, state: State): Promise<void> {
88  const counts = (await $.store.get(countsKey(state.project))) as Counts | undefined
89  const pins = (await $.store.get(pinsKey(state.project))) as string[] | undefined
90  if (counts === undefined && pins === undefined) return
91  if (counts !== undefined) await saveCounts($, state, mergeCounts(state.counts, counts))
92  if (pins !== undefined && state.pins.length === 0) await savePins($, state, pins)
93  await $.store.delete(countsKey(state.project))
94  await $.store.delete(pinsKey(state.project))
95  $.ui.log(`the deck of ${state.project} is now kept for ${state.root}; another checkout named ${state.project} starts its own`)
96}
97
98/** Counts one use, from the stored counts, so another session of the same project is kept. */
99async function countUse($: EngineInterface, state: State, text: string): Promise<void> {
100  const stored = ((await $.store.get(countsKey(state.root))) as Counts | undefined) ?? {}
101  await saveCounts($, state, record(stored, text, await $.clock.now()))
102}
103
104/** A prompt the model reads inside a `The prompt-deck plugin sent a message:` frame. */
105function submitPrompt($: EngineInterface, text: string): void {
106  $.prompt.submit({ text }).catch((err: unknown) => $.ui.log(`the prompt was not sent: ${String(err)}`))
107}
108
109/**
110 * Sends a prompt through the mod's own `send` command, so the model reads the text alone, as the person
111 * would type it. The command runs from a timer, because the engine refuses `$.command.run` inside a hook
112 * the turn waits on. A prompt that reads as a command chain, and a run the engine refuses, go out as a
113 * plugin prompt instead.
114 */
115function sendPrompt($: EngineInterface, text: string): void {
116  if (readsAsChain(text)) return submitPrompt($, text)
117  $.clock.after(0, () => {
118    $.command.run({ command: SEND_COMMAND, args: text }).catch((err: unknown) => {
119      $.ui.log(`the send command did not run, the prompt goes out as a plugin prompt: ${String(err)}`)
120      submitPrompt($, text)
121    })
122  })
123}
124
125/**
126 * Sends a pressed prompt and counts the press here, because the engine passes the plugin's own
127 * `$.command.run` through every hook but this plugin's (measured on 2.1.282). A mod turned off in another
128 * window since the band was drawn counts nothing, and the band goes.
129 */
130async function sendPressed($: EngineInterface, state: State, text: string): Promise<void> {
131  await loadDeck($, state)
132  if (state.enabled) await countUse($, state, text)
133  else $.ui.invalidate('ui.render')
134  sendPrompt($, text)
135}
136
137async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
138  await $.store.set(ENABLED_KEY, on)
139  state.enabled = on
140  $.ui.invalidate('ui.render')
141  return on ? 'on: prompts are counted and the band shows the top 5' : 'off: nothing is counted or drawn; the counts stay'
142}
143
144async function removeCommand($: EngineInterface, state: State, arg: string): Promise<string> {
145  const rest = removeAt(state.counts, state.pins, Number(arg))
146  if (!/^\d+$/.test(arg) || rest === undefined) return `no prompt at ${arg || '?'}; /prompt-deck list names the numbers`
147  if (rest.pins.length !== state.pins.length) await savePins($, state, rest.pins)
148  await saveCounts($, state, rest.counts)
149  return `removed; ${rest.pins.length + Object.keys(rest.counts).length} prompt(s) left`
150}
151
152async function clearCommand($: EngineInterface, state: State): Promise<string> {
153  await savePins($, state, [])
154  await saveCounts($, state, {})
155  return 'cleared: no prompt is pinned or counted'
156}
157
158/** The `/prompt-deck` and `/prompt-deck list` answer: the setting, the project and every prompt of it. */
159function statusText(state: State): string {
160  return `${state.enabled ? 'on' : 'off'} · project ${state.project}\n${listText(state.counts, state.pins)}`
161}
162
163async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
164  await loadDeck($, state)
165  const trimmed = args.trim()
166  const [word = '', arg = ''] = trimmed.split(/\s+/)
167  if (word === 'add') return addCommand($, state, trimmed.slice(word.length))
168  if (word === 'remove') return removeCommand($, state, arg)
169  if (word === 'clear') return clearCommand($, state)
170  if (word === 'on' || word === 'off') return setEnabled($, state, word === 'on')
171  return word === '' || word === 'list' ? statusText(state) : USAGE
172}
173
174function bandTree(els: Elements, prompts: readonly string[], columns: number, onPick: (text: string) => void) {
175  const { Box, Button } = els
176  return (
177    <Box flexDirection="row" gap={2} marginTop={1}>
178      {prompts.map((text, i) => (
179        <Button key={`deck:${i + 1}`} plain dimColor hotkey={String(i + 1)} label={fit(text, prompts.length, columns)} onPress={() => onPick(text)} />
180      ))}
181    </Box>
182  )
183}
184
185export const register: Register = on => {
186  const state: State = { counts: {}, pins: [], enabled: true, project: '', root: '' }
187
188  on('session.start', async ($, e, next) => {
189    const r = await next(e)
190    await $.command.register({ name: 'prompt-deck', description: 'Prompts you send often in this project, on the keys 1-5: list, add <text>, remove <n>, clear, on, off (prompt-deck)', argumentHint: '[list | add <text> | remove <n> | clear | on | off]' })
191    state.root = await resolveRoot($)
192    state.project = projectName(state.root)
193    await loadDeck($, state)
194    await adoptNamed($, state)
195    await adoptLegacy($, state)
196    $.ui.invalidate('ui.render')
197    return r
198  })
199
200  // The engine prints the plugin name in front of command text, so the texts do not repeat it.
201  on('command.run', { command: 'prompt-deck' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
202
203  // Each prompt of the person reads the deck first, so the band after this turn draws what the store holds.
204  on('prompt.submit', async ($, e, next) => {
205    if (!isPersons(e.origin)) return next(e)
206    await loadDeck($, state)
207    const text = normalize(e.text)
208    if (state.enabled && text !== undefined) await countUse($, state, text)
209    return next(e)
210  })
211
212  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213    const prompts = state.enabled ? band(state.counts, state.pins) : []
214    const p = e.props
215    if (prompts.length === 0 || p.hasSurvey || p.isWorking || p.view.agentId !== undefined) return next(e)
216    return bandTree($.ui.resolve(e), prompts, p.bodyColumns, text => void sendPressed($, state, text))
217  })
218}
219
hooks/deck.ts 118 lines
1/** Which prompts the person sends often, counted from what they type. */
2
3/** Uses and the time of the last use, per prompt text. */
4export type Counts = Record<string, { n: number; last: number }>
5
6/** A prompt longer than this is not a one-press prompt. */
7export const MAX_CHARS = 80
8
9/** The deck keeps at most this many prompts; the least used and oldest go first. */
10export const MAX_KEPT = 200
11
12/** A prompt reaches the band from this many uses. */
13export const MIN_USES = 3
14
15/** The band draws at most this many prompts, on the hotkeys 1 to 5. */
16export const BAND_SIZE = 5
17
18/** The prompt as it is counted, or undefined for a prompt the deck does not keep. */
19export function normalize(text: string): string | undefined {
20  const t = text.trim()
21  if (t === '' || t.length > MAX_CHARS || t.includes('\n') || t.startsWith('/')) return undefined
22  return t
23}
24
25/**
26 * The prompt as `/prompt-deck add` keeps it, or undefined for one the deck cannot draw. A pinned prompt has no
27 * length limit, because the person typed it themselves; the band cuts its label to the width.
28 */
29export function normalizePin(text: string): string | undefined {
30  const t = text.trim()
31  return t === '' || t.includes('\n') || t.startsWith('/') ? undefined : t
32}
33
34/**
35 * Whether a prompt holds `&& /<name>` and so reads as a chain of slash commands once it is a command's
36 * arguments, the pattern the slash-chain mod runs as a second step.
37 */
38export function readsAsChain(text: string): boolean {
39  return /(?:^|\s)&&\s*\/[A-Za-z0-9_:.-]+(?=\s|$)/.test(text)
40}
41
42const byUse =([, a]: [string, { n: number; last: number }], [, b]: [string, { n: number; last: number }]): number => b.n - a.n || b.last - a.last
43
44/** Every kept prompt, the most used first, the latest first on a tie. */
45export function ranked(counts: Counts): string[] {
46  return Object.entries(counts).sort(byUse).map(([text]) => text)
47}
48
49/** The store key of one project's counts, by its root path; each project counts its own prompts. */
50export function countsKey(root: string): string {
51  return `counts:${root}`
52}
53
54/** The last part of a path, without a trailing slash: the project name. */
55export function projectName(path: string): string {
56  return path.replace(/\/+$/, '').split('/').at(-1) ?? path
57}
58
59/** Both sets of counts in one: a prompt in both keeps the higher count and the later use. */
60export function mergeCounts(a: Counts, b: Counts): Counts {
61  const out: Counts = { ...a }
62  for (const [text, use] of Object.entries(b)) {
63    const had = out[text]
64    out[text] = had === undefined ? use : { n: Math.max(had.n, use.n), last: Math.max(had.last, use.last) }
65  }
66  return Object.fromEntries(Object.entries(out).sort(byUse).slice(0, MAX_KEPT))
67}
68
69/** The counts after one more use of `text`, trimmed to MAX_KEPT. */
70export function record(counts: Counts, text: string, now: number): Counts {
71  const next: Counts = { ...counts, [text]: { n: (counts[text]?.n ?? 0) + 1, last: now } }
72  const kept = Object.entries(next).sort(byUse).slice(0, MAX_KEPT)
73  return Object.fromEntries(kept)
74}
75
76/** The store key of one project's pinned prompts, by its root path. */
77export function pinsKey(root: string): string {
78  return `pins:${root}`
79}
80
81/**
82 * The prompts the band draws: the pinned ones in the order they were added, then the most used, up to
83 * five. A pinned prompt keeps its place whatever the counts say.
84 */
85export function band(counts: Counts, pins: readonly string[] = []): string[] {
86  const kept = pins.slice(0, BAND_SIZE)
87  const counted = ranked(counts).filter(t => (counts[t]?.n ?? 0) >= MIN_USES && !kept.includes(t))
88  return [...kept, ...counted].slice(0, BAND_SIZE)
89}
90
91/** One band label cut to fit `count` buttons across `columns` cells; `N: ` and a gap take 5 cells each. */
92export function fit(text: string, count: number, columns: number): string {
93  const width = Math.max(8, Math.floor(columns / Math.max(count, 1)) - 5)
94  return text.length <= width ? text : `${text.slice(0, width - 1).trimEnd()}…`
95}
96
97/** The prompts of the `/prompt-deck` list in order: the pinned ones, then the counted ones. */
98function listed(counts: Counts, pins: readonly string[]): string[] {
99  return [...pins, ...ranked(counts).filter(t => !pins.includes(t))]
100}
101
102/** The `/prompt-deck` list: every pinned and counted prompt, each with the number `/prompt-deck remove` takes. */
103export function listText(counts: Counts, pins: readonly string[] = []): string {
104  const all = listed(counts, pins)
105  if (all.length === 0) return `no prompt counted yet; a prompt reaches the band after ${MIN_USES} uses, or add one with /prompt-deck add <text>`
106  return all.map((t, i) => `${i + 1}. ${t} ${pins.includes(t) ? '(pinned)' : `(${counts[t]?.n ?? 0})`}`).join('\n')
107}
108
109/** The deck without the prompt at 1-based place `place` of the `/prompt-deck` list, or undefined for no such place. */
110export function removeAt(counts: Counts, pins: readonly string[], place: number): { counts: Counts; pins: string[] } | undefined {
111  const text = listed(counts, pins)[place - 1]
112  if (text === undefined) return undefined
113  return {
114    counts: Object.fromEntries(Object.entries(counts).filter(([t]) => t !== text)),
115    pins: pins.filter(p => p !== text),
116  }
117}
118