Save, search and reuse your best prompts

Save, search and reuse your best prompts.

You write the same good prompts again and again: review my diff, write tests for this file, explain this repo. This mod keeps them one command away.
/prompts, type to search, press Send. Most used prompts come first.{{input}} asks for the file or topic before it sends.| Command | What |
|---|---|
/prompts | Open the library: search, list by most used, preview, Send / Copy / Delete |
/prompts save <name> | Save the last prompt you sent |
/prompts save <name> <text> | Save given text |
/prompts delete <name> | Delete a prompt |
/prompts export | Write prompts.json to the current folder (never overwrites) |
/prompts import <file> | Merge prompts from a JSON file (max 512 KB) |
Put {{input}} in a prompt and Send asks you for the value first. Comes with 8 starter prompts (review diff, write tests, explain repo, …).
/plugin marketplace add Ron-Nefyodov/prompt-library-mod /plugin install prompt-library@prompt-library
Or try it locally: claude --plugin-dir .
npm test # claude plugin test . claude plugin validate .
hooks/register.js 135 lines1import { COMMAND, runPromptsCommand } from './commands.js';
2import {
3 LAST_PROMPT_KEY, LIBRARY_KEY,
4 deletePrompt, fillPlaceholder, hasPlaceholder, loadLibrary, recordUse, visiblePrompts,
5} from './library.js';
6import { DEFAULT_VIEW, PANE_ID, drawPromptsPane } from './pane.js';
7
8function isOwnPromptText(text) {
9 return typeof text === 'string' && text.trim() !== '' && !text.trimStart().startsWith('/');
10}
11
12export function register(on) {
13 on('session.start', async ($, e, next) => {
14 await $.command.register(COMMAND);
15 return next(e);
16 });
17
18 on('prompt.submit', async ($, e, next) => {
19 const result = await next(e);
20 if (e.origin.kind === 'composer') {
21 if (isOwnPromptText(e.text)) {
22 try {
23 await $.store.set(LAST_PROMPT_KEY, e.text);
24 } catch {
25 }
26 }
27 }
28 return result;
29 }).catch(($, e, next) => next(e));
30
31 on('turn.complete', async ($, e, next) => {
32 const result = await next(e);
33 return result;
34 }).catch(($, e, next) => next(e));
35
36 on('command.run', { command: 'prompts' }, async ($, e, next) => {
37 const refresh = async () => {
38 const { value } = await $.state.get({ plugin: 'prompt-library', key: 'view' });
39 await $.state.set({ plugin: 'prompt-library', key: 'view' }, { ...DEFAULT_VIEW, ...(value ?? {}), rev: (value?.rev ?? 0) + 1 });
40 };
41 return runPromptsCommand(e.args, {
42 get: (key) => $.store.get(key),
43 set: (key, value) => $.store.set(key, value),
44 now: () => $.clock.now(),
45 cwd: () => $.session.cwd(),
46 read: (path) => $.fs.read(path),
47 write: (path, text) => $.fs.write(path, text),
48 stat: (path) => $.fs.stat(path),
49 refresh,
50 open: async () => {
51 await $.state.set({ plugin: 'prompt-library', key: 'view' }, { ...DEFAULT_VIEW });
52 await $.ui.open({ id: PANE_ID, title: 'Prompts', focus: true, closeOnEscape: true, rows: 20 });
53 },
54 });
55 }).catch(() => ({ text: '/prompts hit an error. Try again.' }));
56
57 on('ui.render', { component: 'Pane', requestId: 'prompts' }, async ($, e, next) => {
58 if (e.surface !== 'terminal') return next(e);
59 try {
60 const storeIO = { get: (key) => $.store.get(key), set: (key, value) => $.store.set(key, value) };
61 const { value: held } = await $.state.get({ plugin: 'prompt-library', key: 'view' });
62 const view = { ...DEFAULT_VIEW, ...(held ?? {}) };
63 const list = await loadLibrary(storeIO);
64 const items = visiblePrompts(list, view.query);
65 const selected = items.find((p) => p.name === view.selected) ?? items[0] ?? null;
66
67 const patch = async (changes) => {
68 const { value } = await $.state.get({ plugin: 'prompt-library', key: 'view' });
69 await $.state.set({ plugin: 'prompt-library', key: 'view' }, { ...DEFAULT_VIEW, ...(value ?? {}), ...changes, rev: (value?.rev ?? 0) + 1 });
70 };
71 const countUse = async (name) => {
72 const fresh = await loadLibrary(storeIO);
73 await $.store.set(LIBRARY_KEY, recordUse(fresh, name, await $.clock.now()));
74 };
75 const send = async (text, name) => {
76 await countUse(name);
77 await patch({ ...DEFAULT_VIEW });
78 await $.ui.close({ id: PANE_ID });
79 await $.prompt.submit({ text, asUser: true });
80 };
81
82 const act = {
83 search: (query) => patch({ query, mode: 'browse', message: '' }),
84 pick: async (query) => {
85 const first = visiblePrompts(await loadLibrary(storeIO), query)[0];
86 await patch({ query, mode: 'browse', message: '', ...(first && { selected: first.name }) });
87 if (first) await $.ui.focus({ requestId: PANE_ID, key: 'send' }).catch(() => undefined);
88 },
89 select: (name) => patch({ selected: name, mode: 'browse', message: '' }),
90 send: async () => {
91 if (hasPlaceholder(selected.text)) {
92 await patch({ selected: selected.name, mode: 'fill', message: '' });
93 await $.ui.focus({ requestId: PANE_ID, key: 'fill' }).catch(() => undefined);
94 return;
95 }
96 await send(selected.text, selected.name);
97 },
98 fill: (value) => send(fillPlaceholder(selected.text, value), selected.name),
99 copy: async (press) => {
100 const copied = await $.ui.copy({ text: selected.text, surface: press.surface });
101 if (copied.isCopied) await countUse(selected.name);
102 await patch({
103 selected: selected.name,
104 message: copied.isCopied ? `Copied "${selected.name}" to the clipboard.` : `Could not copy: ${copied.reason ?? 'unknown reason'}`,
105 });
106 },
107 askDelete: () => patch({ selected: selected.name, mode: 'confirm', message: '' }),
108 confirmDelete: async () => {
109 const fresh = await loadLibrary(storeIO);
110 await $.store.set(LIBRARY_KEY, deletePrompt(fresh, selected.name).list);
111 await patch({ selected: null, mode: 'browse', message: `Deleted "${selected.name}".` });
112 },
113 cancel: () => patch({ mode: 'browse', message: '' }),
114 };
115
116 return drawPromptsPane($.ui.resolve(e), {
117 items,
118 selected,
119 total: list.length,
120 query: view.query,
121 mode: selected ? view.mode : 'browse',
122 message: view.message,
123 messageColor: view.message.startsWith('Could not') ? 'error' : 'success',
124 columns: e.props.bodyColumns ?? 80,
125 rows: e.props.scroll?.bodyRows ?? 20,
126 now: await $.clock.now(),
127 act,
128 });
129 } catch {
130 return next(e);
131 }
132 });
133
134}
135hooks/commands.js 91 lines1// /prompts subcommands. Engine access comes in as plain lambdas.
2import {
3 EXPORT_FILE, LAST_PROMPT_KEY, LIBRARY_KEY, MAX_IMPORT_CHARS, USAGE,
4 deletePrompt, exportJson, findPrompt, loadLibrary, mergeImport, parseCommand, parseImport, savePrompt,
5} from './library.js';
6
7export const COMMAND = { name: 'prompts', description: 'Open your prompt library, or save / delete / import / export prompts', argumentHint: '[save|delete|export|import] …' };
8
9function joinPath(cwd, path) {
10 if (path.startsWith('/')) return path;
11 return `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`;
12}
13
14function plural(count, word) {
15 return `${count} ${word}${count === 1 ? '' : 's'}`;
16}
17
18// io: { get, set, now, cwd, read, write, stat, open, refresh }
19export async function runPromptsCommand(args, io) {
20 const command = parseCommand(args);
21 const storeIO = { get: io.get, set: io.set };
22
23 if (command.action === 'open') {
24 await io.open();
25 return {};
26 }
27 if (command.action === 'help') return { text: USAGE };
28
29 const list = await loadLibrary(storeIO);
30
31 if (command.action === 'save') {
32 if (!command.name) return { text: 'Give the prompt a name: /prompts save <name> [text]' };
33 let text = command.text;
34 if (!text) {
35 text = await io.get(LAST_PROMPT_KEY);
36 if (typeof text !== 'string' || !text.trim()) return { text: 'No prompt sent yet in this session. Use /prompts save <name> <text>.' };
37 }
38 const existed = Boolean(findPrompt(list, command.name));
39 try {
40 await io.set(LIBRARY_KEY, savePrompt(list, command.name, text, await io.now()));
41 } catch (error) {
42 return { text: error.message };
43 }
44 await io.refresh();
45 return { text: `${existed ? 'Updated' : 'Saved'} prompt "${command.name}".` };
46 }
47
48 if (command.action === 'delete') {
49 if (!command.name) return { text: 'Which one? /prompts delete <name>' };
50 const { list: next, removed } = deletePrompt(list, command.name);
51 if (!removed) return { text: `No prompt named "${command.name}".` };
52 await io.set(LIBRARY_KEY, next);
53 await io.refresh();
54 return { text: `Deleted prompt "${command.name}".` };
55 }
56
57 if (command.action === 'export') {
58 const path = joinPath(await io.cwd(), EXPORT_FILE);
59 const isTaken = await io.stat(path).then(() => true, () => false);
60 if (isTaken) return { text: `${path} already exists. Move or rename it, then export again.` };
61 try {
62 await io.write(path, exportJson(list));
63 } catch (error) {
64 return { text: `Could not write ${path}: ${error?.message ?? error}` };
65 }
66 return { text: `Exported ${plural(list.length, 'prompt')} to ${path}` };
67 }
68
69 if (command.action === 'import') {
70 if (!command.path) return { text: 'Give a file path: /prompts import <path>' };
71 const path = joinPath(await io.cwd(), command.path);
72 let text;
73 try {
74 const stat = await io.stat(path);
75 if (stat.size > MAX_IMPORT_CHARS) return { text: `File too large (max ${MAX_IMPORT_CHARS / 1024} KB): ${path}` };
76 text = await io.read(path);
77 } catch (error) {
78 return { text: `Could not read ${path}: ${error?.message ?? error}` };
79 }
80 const parsed = parseImport(text);
81 if (parsed.error) return { text: `Import failed: ${parsed.error}` };
82 const merged = mergeImport(list, parsed.entries, await io.now());
83 await io.set(LIBRARY_KEY, merged.list);
84 await io.refresh();
85 const dropped = merged.dropped ? `, ${merged.dropped} dropped (library full)` : '';
86 return { text: `Imported from ${path}: ${merged.added} added, ${merged.updated} updated, ${parsed.skipped} skipped${dropped}.` };
87 }
88
89 return { text: USAGE };
90}
91hooks/library.js 193 lines1// Pure prompt-library logic. No engine calls here.
2export const LIBRARY_KEY = 'prompt-library';
3export const LAST_PROMPT_KEY = 'last-prompt';
4export const MAX_PROMPTS = 500;
5export const MAX_TEXT_CHARS = 10000;
6export const MAX_IMPORT_CHARS = 512 * 1024;
7export const EXPORT_FILE = 'prompts.json';
8
9const NAME_PATTERN = /^[A-Za-z0-9][\w.-]{0,39}$/;
10const PLACEHOLDER = /\{\{\s*input\s*\}\}/g;
11
12export const STARTERS = [
13 { name: 'review-diff', text: 'Review my uncommitted changes (git diff). List real bugs, risky edits and missing tests, most important first. Skip style nits.' },
14 { name: 'write-tests', text: 'Write tests for {{input}}. Cover the main path, edge cases and error cases. Follow the test style already used in this repo, then run them.' },
15 { name: 'explain-repo', text: 'Explain this repository: what it does, how it is laid out, the main entry points, and how to build and test it. Keep it short.' },
16 { name: 'find-bugs', text: 'Look for bugs in {{input}}. For each one, give the file and line, why it is wrong, and a fix. Only report issues you are confident about.' },
17 { name: 'commit-message', text: 'Write a commit message for my staged changes (git diff --staged): a short summary line, a blank line, then why the change was made.' },
18 { name: 'simplify-file', text: 'Simplify {{input}} without changing its behavior: remove dead code and duplication, and make names clearer. Show the diff and run the tests.' },
19 { name: 'add-logging', text: 'Add useful logging to {{input}}: entry and exit of key operations and error paths, using the logger this project already has. Never log secrets.' },
20 { name: 'document-module', text: 'Document {{input}}: a short module overview, plus doc comments on its public functions covering inputs, outputs and errors.' },
21];
22
23export function isValidName(name) {
24 return typeof name === 'string' && NAME_PATTERN.test(name);
25}
26
27// Text elements refuse control characters other than tab and newline.
28function cleanText(text) {
29 return text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
30}
31
32function isValidText(text) {
33 return typeof text === 'string' && cleanText(text).trim().length > 0 && text.length <= MAX_TEXT_CHARS;
34}
35
36function normalize(row) {
37 if (!row || typeof row !== 'object' || !isValidName(row.name) || !isValidText(row.text)) return null;
38 return {
39 name: row.name,
40 text: cleanText(row.text),
41 uses: Number.isInteger(row.uses) && row.uses > 0 ? row.uses : 0,
42 lastUsed: typeof row.lastUsed === 'number' ? row.lastUsed : null,
43 createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0,
44 };
45}
46
47export async function loadLibrary({ get, set }) {
48 const stored = await get(LIBRARY_KEY);
49 if (stored === undefined || stored === null) {
50 const seeded = STARTERS.map((p) => ({ name: p.name, text: p.text, uses: 0, lastUsed: null, createdAt: 0 }));
51 await set(LIBRARY_KEY, seeded);
52 return seeded;
53 }
54 if (!Array.isArray(stored)) return [];
55 return stored.map(normalize).filter(Boolean);
56}
57
58export function findPrompt(list, name) {
59 return list.find((p) => p.name === name);
60}
61
62export function savePrompt(list, name, text, now) {
63 if (!isValidName(name)) throw new Error(`Bad name "${name}". Use letters, digits, - _ . (max 40, no spaces).`);
64 const trimmed = typeof text === 'string' ? cleanText(text).trim() : '';
65 if (!trimmed) throw new Error('Nothing to save: the prompt text is empty.');
66 if (trimmed.length > MAX_TEXT_CHARS) throw new Error(`Prompt too long (max ${MAX_TEXT_CHARS} characters).`);
67 const existing = findPrompt(list, name);
68 if (existing) return list.map((p) => (p.name === name ? { ...p, text: trimmed } : p));
69 if (list.length >= MAX_PROMPTS) throw new Error(`Library full (${MAX_PROMPTS} prompts). Delete some first.`);
70 return [...list, { name, text: trimmed, uses: 0, lastUsed: null, createdAt: now }];
71}
72
73export function deletePrompt(list, name) {
74 const next = list.filter((p) => p.name !== name);
75 return { list: next, removed: next.length !== list.length };
76}
77
78export function recordUse(list, name, now) {
79 return list.map((p) => (p.name === name ? { ...p, uses: p.uses + 1, lastUsed: now } : p));
80}
81
82function byUse(a, b) {
83 return (b.uses - a.uses) || ((b.lastUsed ?? 0) - (a.lastUsed ?? 0)) || a.name.localeCompare(b.name);
84}
85
86export function visiblePrompts(list, query) {
87 const words = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
88 const matches = words.length === 0
89 ? list
90 : list.filter((p) => {
91 const haystack = `${p.name}\n${p.text}`.toLowerCase();
92 return words.every((word) => haystack.includes(word));
93 });
94 return [...matches].sort(byUse);
95}
96
97export function hasPlaceholder(text) {
98 return new RegExp(PLACEHOLDER.source).test(text);
99}
100
101export function fillPlaceholder(text, value) {
102 return text.replace(new RegExp(PLACEHOLDER.source, 'g'), () => value);
103}
104
105export function exportJson(list) {
106 const prompts = list.map((p) => ({ name: p.name, text: p.text, uses: p.uses }));
107 return `${JSON.stringify({ version: 1, prompts }, null, 2)}\n`;
108}
109
110// Reads untrusted JSON. Keeps only name + text strings. Never evaluates anything.
111export function parseImport(text) {
112 if (typeof text !== 'string') return { error: 'File is not text.' };
113 if (text.length > MAX_IMPORT_CHARS) return { error: `File too large (max ${MAX_IMPORT_CHARS / 1024} KB).` };
114 let data;
115 try {
116 data = JSON.parse(text);
117 } catch {
118 return { error: 'File is not valid JSON.' };
119 }
120 const rows = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.prompts) ? data.prompts : null);
121 if (!rows) return { error: 'Expected a list of prompts, or an object with a "prompts" list.' };
122 const byName = new Map();
123 let skipped = 0;
124 for (const row of rows) {
125 const valid = row && typeof row === 'object' && isValidName(row.name) && isValidText(row.text);
126 if (!valid) {
127 skipped += 1;
128 continue;
129 }
130 if (byName.has(row.name)) skipped += 1;
131 byName.delete(row.name);
132 byName.set(row.name, { name: row.name, text: cleanText(row.text).trim() });
133 }
134 return { entries: [...byName.values()], skipped };
135}
136
137export function mergeImport(list, entries, now) {
138 let next = list;
139 let added = 0;
140 let updated = 0;
141 let dropped = 0;
142 for (const entry of entries) {
143 if (findPrompt(next, entry.name)) {
144 next = next.map((p) => (p.name === entry.name ? { ...p, text: entry.text } : p));
145 updated += 1;
146 } else if (next.length >= MAX_PROMPTS) {
147 dropped += 1;
148 } else {
149 next = [...next, { name: entry.name, text: entry.text, uses: 0, lastUsed: null, createdAt: now }];
150 added += 1;
151 }
152 }
153 return { list: next, added, updated, dropped };
154}
155
156export function parseCommand(args) {
157 const trimmed = (args ?? '').trim();
158 if (!trimmed) return { action: 'open' };
159 const match = /^(\S+)\s*([\s\S]*)$/.exec(trimmed);
160 const verb = match[1].toLowerCase();
161 const rest = match[2];
162 if (verb === 'save') {
163 const [, name = '', text = ''] = /^(\S*)\s*([\s\S]*)$/.exec(rest);
164 return { action: 'save', name, text: text.trim() };
165 }
166 if (verb === 'delete') return { action: 'delete', name: rest.trim() };
167 if (verb === 'export') return { action: 'export' };
168 if (verb === 'import') return { action: 'import', path: rest.trim() };
169 return { action: 'help' };
170}
171
172export const USAGE = [
173 'Usage:',
174 ' /prompts open the library',
175 ' /prompts save <name> save the last prompt you sent',
176 ' /prompts save <name> <text> save the given text',
177 ' /prompts delete <name> delete a prompt',
178 ` /prompts export write ${EXPORT_FILE} to the current folder`,
179 ' /prompts import <path> add prompts from a JSON file',
180 'Use {{input}} in a prompt to be asked for a value before it is sent.',
181].join('\n');
182
183export function timeAgo(then, now) {
184 if (typeof then !== 'number') return 'never';
185 const seconds = Math.max(0, Math.floor((now - then) / 1000));
186 if (seconds < 60) return 'just now';
187 const minutes = Math.floor(seconds / 60);
188 if (minutes < 60) return `${minutes}m ago`;
189 const hours = Math.floor(minutes / 60);
190 if (hours < 24) return `${hours}h ago`;
191 return `${Math.floor(hours / 24)}d ago`;
192}
193hooks/pane.js 218 lines1// Draws the /prompts pane. Pure: elements and actions come in, a tree goes out.
2import { hasPlaceholder, timeAgo } from './library.js';
3
4export const PANE_ID = 'prompts';
5export const DEFAULT_VIEW = { query: '', selected: null, mode: 'browse', message: '', rev: 0 };
6
7const NARROW_COLUMNS = 60;
8
9function clamp(value, low, high) {
10 return Math.max(low, Math.min(high, value));
11}
12
13function shorten(text, width) {
14 if (width <= 1) return '';
15 return text.length > width ? `${text.slice(0, width - 1)}…` : text;
16}
17
18function previewText(text, width, lines) {
19 const budget = Math.max(20, width * lines);
20 const kept = text.split('\n').slice(0, lines).join('\n');
21 return kept.length > budget || kept.length < text.length ? `${kept.slice(0, budget - 1).trimEnd()}…` : kept;
22}
23
24function usesLabel(uses) {
25 return uses === 1 ? '1 use' : `${uses} uses`;
26}
27
28function windowAround(items, selectedIndex, size) {
29 if (items.length <= size) return { start: 0, end: items.length };
30 const start = clamp(selectedIndex - Math.floor(size / 2), 0, items.length - size);
31 return { start, end: start + size };
32}
33
34function listRows(el, model, width, maxRows) {
35 const { Box, Text, Button } = el;
36 const { items, selected } = model;
37 const selectedIndex = Math.max(0, items.indexOf(selected));
38 const room = Math.max(1, maxRows);
39 const { start, end } = windowAround(items, selectedIndex, room);
40 const rows = [];
41 if (start > 0) rows.push(Text({ dimColor: true, children: [` ↑ ${start} more`] }));
42 for (const item of items.slice(start, end)) {
43 const isSelected = item === selected;
44 const count = `${item.uses}×`;
45 rows.push(Box({
46 flexDirection: 'row',
47 justifyContent: 'space-between',
48 children: [
49 Box({
50 flexDirection: 'row',
51 children: [
52 Text(isSelected ? { color: 'claude', bold: true, children: ['▸ '] } : { children: [' '] }),
53 Button({
54 key: `row:${item.name}`,
55 label: shorten(item.name, width - count.length - 4),
56 plain: true,
57 dimColor: !isSelected,
58 onPress: () => model.act.select(item.name),
59 }),
60 ],
61 }),
62 Text({ dimColor: true, children: [count] }),
63 ],
64 }));
65 }
66 if (end < items.length) rows.push(Text({ dimColor: true, children: [` ↓ ${items.length - end} more`] }));
67 return Box({ flexDirection: 'column', width, flexShrink: 0, children: rows });
68}
69
70function previewBox(el, model, width, lines) {
71 const { Box, Text } = el;
72 const { selected, now } = model;
73 const meta = [usesLabel(selected.uses), `last used ${timeAgo(selected.lastUsed, now)}`];
74 if (hasPlaceholder(selected.text)) meta.push('asks for {{input}}');
75 return Box({
76 flexDirection: 'column',
77 flexGrow: 1,
78 borderStyle: 'round',
79 borderColor: 'subtle',
80 paddingX: 1,
81 children: [
82 Text({ bold: true, color: 'claude', wrap: 'truncate-end', children: [selected.name] }),
83 Box({ key: 'meta', children: [Text({ dimColor: true, wrap: 'truncate-end', children: [meta.join(' · ')] })] }),
84 Box({ key: 'preview', marginTop: 1, children: [Text({ wrap: 'wrap', children: [previewText(selected.text, Math.max(10, width - 4), lines)] })] }),
85 ],
86 });
87}
88
89function actionRow(el, model) {
90 const { Box, Text, Button, Input } = el;
91 const { selected, mode, act } = model;
92 if (mode === 'fill') {
93 return Box({
94 flexDirection: 'column',
95 children: [
96 Box({
97 flexDirection: 'row',
98 children: [
99 Text({ dimColor: true, children: ['Value for '] }),
100 Text({ color: 'suggestion', children: ['{{input}}'] }),
101 Text({ dimColor: true, children: [` in ${selected.name}`] }),
102 ],
103 }),
104 Box({
105 flexDirection: 'row',
106 gap: 2,
107 children: [
108 Box({ flexGrow: 1, children: [Input({ key: 'fill', label: '› ', placeholder: 'e.g. src/app.ts', submitLabel: 'send', autoFocus: true, onSubmit: (value) => act.fill(value) })] }),
109 Button({ key: 'cancel', label: 'Cancel', dimColor: true, onPress: () => act.cancel() }),
110 ],
111 }),
112 ],
113 });
114 }
115 if (mode === 'confirm') {
116 return Box({
117 flexDirection: 'row',
118 gap: 2,
119 children: [
120 Text({ color: 'warning', children: [`Delete "${selected.name}"?`] }),
121 Button({ key: 'confirm-delete', label: 'Delete', variant: 'primary', autoFocus: true, onPress: () => act.confirmDelete() }),
122 Button({ key: 'cancel', label: 'Keep', onPress: () => act.cancel() }),
123 ],
124 });
125 }
126 return Box({
127 flexDirection: 'row',
128 gap: 2,
129 children: [
130 Button({ key: 'send', label: hasPlaceholder(selected.text) ? 'Send…' : 'Send', variant: 'primary', onPress: () => act.send() }),
131 Button({ key: 'copy', label: 'Copy', onPress: (press) => act.copy(press) }),
132 Button({ key: 'delete', label: 'Delete', dimColor: true, onPress: () => act.askDelete() }),
133 ],
134 });
135}
136
137function footer(el, model) {
138 const { Box, Text } = el;
139 if (model.message) {
140 return Box({ key: 'message', children: [Text({ color: model.messageColor ?? 'success', children: [model.message] })] });
141 }
142 return Text({ dimColor: true, wrap: 'truncate-end', children: ['tab move · enter choose · esc close'] });
143}
144
145// model: { items, selected, total, query, mode, message, columns, rows, now, act }
146export function drawPromptsPane(el, model) {
147 const { Box, Text, Input } = el;
148 const columns = Math.max(20, model.columns - 2);
149 const rows = Math.max(6, model.rows);
150 const isNarrow = columns < NARROW_COLUMNS;
151 const counter = model.query ? `${model.items.length}/${model.total}` : `${model.total}`;
152
153 const header = Box({
154 flexDirection: 'row',
155 justifyContent: 'space-between',
156 gap: 1,
157 children: [
158 Box({
159 flexGrow: 1,
160 children: [Input({
161 key: 'search',
162 label: '⌕ ',
163 placeholder: 'Search prompts',
164 value: model.query,
165 submitLabel: 'pick',
166 ...(model.mode === 'browse' && { autoFocus: true }),
167 onInput: (value) => model.act.search(value),
168 onSubmit: (value) => model.act.pick(value),
169 })],
170 }),
171 Text({ dimColor: true, children: [counter] }),
172 ],
173 });
174
175 if (!model.selected) {
176 const hint = model.total === 0
177 ? 'Library is empty. Add one with /prompts save <name> <text>.'
178 : `No prompts match "${model.query}".`;
179 return Box({
180 flexDirection: 'column',
181 paddingX: 1,
182 gap: 1,
183 children: [header, Box({ key: 'empty', children: [Text({ dimColor: true, children: [hint] })] }), footer(el, model)],
184 });
185 }
186
187 const actionRows = model.mode === 'fill' ? 2 : 1;
188 const chromeRows = 1 + 1 + actionRows + 1 + 2;
189 let body;
190 if (isNarrow) {
191 const previewLines = clamp(Math.floor((rows - chromeRows) / 3), 1, 6);
192 const listRoom = Math.max(2, rows - chromeRows - previewLines - 5);
193 body = Box({
194 flexDirection: 'column',
195 children: [listRows(el, model, columns, listRoom), previewBox(el, model, columns, previewLines)],
196 });
197 } else {
198 const listWidth = clamp(Math.floor(columns * 0.36), 22, 34);
199 const room = Math.max(3, rows - chromeRows);
200 body = Box({
201 flexDirection: 'row',
202 gap: 1,
203 children: [listRows(el, model, listWidth, room), previewBox(el, model, columns - listWidth - 1, Math.max(2, room - 5))],
204 });
205 }
206
207 return Box({
208 flexDirection: 'column',
209 paddingX: 1,
210 children: [
211 header,
212 Box({ marginTop: 1, children: [body] }),
213 Box({ marginTop: 1, children: [actionRow(el, model)] }),
214 footer(el, model),
215 ],
216 });
217}
218types/index.d.ts 14 lines1export type PromptLibraryView = {
2 query: string;
3 selected: string | null;
4 mode: 'browse' | 'fill' | 'confirm';
5 message: string;
6 rev: number;
7};
8
9declare module 'claude-code' {
10 interface PluginState {
11 'prompt-library': { view: PromptLibraryView };
12 }
13}
14