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…

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.
/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.1: commitle 2: devam et ..., each label cut to its share of the width.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.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.
/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.
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.
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.
/prompt-deck add takes any length on one line.AbovePrompt there only.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
hooks/register.tsx 219 lines1import 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}
219hooks/deck.ts 118 lines1/** 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