Keep prompts aside as drafts of the session. End a prompt with ;; to save it instead of sending it, then /drafts to put one back in the prompt box.

A Claude Code mod that keeps prompts aside as drafts and puts them back in the prompt box when you need them.
End a prompt with ;; and press Enter. It is saved, not sent. Type /drafts and press a number to bring it back.

Requires Claude Code 2.1.287 or later. Mods are on by default.
claude plugin marketplace add vynnlee/mods
claude plugin install prompt-drafts@vynnlee
Or in a Claude Code session:
/plugin install prompt-drafts --marketplace vynnlee/mods
To try it for one session without installing:
git clone https://github.com/vynnlee/mods
claude --plugin-dir ./mods/prompt-drafts
| To | Do |
|---|---|
| Save the prompt you are writing | End it with ;; and press Enter. It is not sent. |
| Save some text | /draft <text> |
| Put a draft back in the prompt box | /drafts, then press its number. Or /drafts <n> |
| Save what is in the prompt box | Save prompt box in the sidebar |
| Delete a draft | × in the sidebar, or /drafts rm <n> |
| Bring back the draft you just deleted | Undo in the sidebar, or /drafts undo |
/drafts shows0 to close. You can also click a draft in the sidebar./plugin configure prompt-drafts@vynnlee
| Setting | Default | Values |
|---|---|---|
language | en | en, ko (Korean) |
claude plugin update prompt-drafts@vynnlee
Third party marketplaces do not update on their own unless you turn on auto update in /plugin under Marketplaces.
claude plugin validate . reports every hook and call. In short:
| Hooks | session.start, prompt.submit, command.run (draft, drafts), ui.close, ui.render (AbovePrompt, Pane) |
| Prompt box | $.prompt.read, $.prompt.fill |
| Storage | $.store.get, $.store.set, $.store.delete, $.store.keys (the plugin's own store on your machine) |
| Session | $.session.id (which session a draft belongs to), $.session.surface |
| Interface | $.command.register, $.ui.ask, $.ui.open, $.ui.close, $.ui.panes, $.ui.toast, $.ui.resolve, $.state.get, $.state.set, $.clock.now |
;;. Prompts submitted by other plugins or sessions pass through unchanged.cd prompt-drafts
claude plugin validate . --strict
claude plugin test .
claude --plugin-dir .
The demo GIF is recorded with VHS from demo/prompt-drafts.tape. demo/run.sh starts Claude Code in a throwaway config with only this mod loaded, so no login or model call is needed.
The pure logic (titles, previews, paging, messages) is in hooks/drafts.ts and tested in hooks/drafts.test.ts. The hooks are in hooks/register.tsx. Every release raises version in .claude-plugin/plugin.json, since installed copies only update when it changes. Changes are listed in CHANGELOG.md.
hooks/register.tsx 288 lines1// prompt-drafts: keep prompts aside as drafts of this session and bring them back later.
2// Save: end a prompt with ";;" and press Enter: it is saved, not sent. Or "/draft <text>".
3// Use: "/drafts". On a wide terminal, a sidebar (click a draft, × deletes, Save prompt box) and a row of
4// numbered buttons above the prompt box: a bare digit in an empty prompt box presses one, 0 closes.
5// On a narrow one, Claude Code's own question dialog, a page at a time.
6// "/drafts <n>" puts draft n in the prompt box, "/drafts rm <n>" deletes it, "/drafts undo" brings it back.
7// Each session has its own drafts (resuming the session brings them back); other sessions never see them.
8// They live in this plugin's store, newest first, 50 per session; a session idle for 30 days is dropped.
9// Putting a draft in a prompt box that already holds text saves that text as a draft first, so nothing is lost.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12import type { Deleted, Draft } from '../types'
13import { add, ago, askPage, KEEP_MS, listText, MARKER, marked, preview, restore, text, title, type Lang } from './drafts'
14
15const PANE = 'prompt-drafts'
16const SHOWN = 9
17// Below this many columns there is no room for the sidebar: drafts are picked in Claude Code's own question dialog.
18const WIDE = 110
19const drafts = atom({ plugin: 'prompt-drafts', key: 'drafts' } as const, [])
20const isPicking = atom({ plugin: 'prompt-drafts', key: 'isPicking' } as const, false)
21const deleted = atom({ plugin: 'prompt-drafts', key: 'deleted' } as const, null)
22
23let lang: Lang = 'en'
24let storeKey = ''
25
26async function key($: EngineInterface) {
27 if (!storeKey) storeKey = `session:${await $.session.id()}`
28 return storeKey
29}
30
31async function load($: EngineInterface) {
32 const list = ((await $.store.get(await key($))) as Draft[] | undefined) ?? []
33 await update($, drafts, () => list)
34 return list
35}
36
37async function store($: EngineInterface, list: Draft[]) {
38 if (list.length) await $.store.set(await key($), list)
39 else await $.store.delete(await key($))
40 await update($, drafts, () => list)
41}
42
43async function save($: EngineInterface, body: string) {
44 const now = await $.clock.now()
45 const list = add(await load($), { id: String(now), title: title(body), text: body, savedAt: now })
46 await store($, list)
47 return list
48}
49
50async function remove($: EngineInterface, d: Draft) {
51 const list = await load($)
52 const index = list.findIndex(x => x.id === d.id)
53 if (index < 0) return
54 await store($, list.filter(x => x.id !== d.id))
55 await update($, deleted, (): Deleted => ({ draft: d, index }))
56}
57
58async function undo($: EngineInterface) {
59 const last = await read($, deleted)
60 if (!last) return null
61 await store($, restore(await load($), last.draft, last.index))
62 await update($, deleted, () => null)
63 return last.draft
64}
65
66// Drop the drafts of sessions idle for 30 days.
67async function tidy($: EngineInterface) {
68 const now = await $.clock.now()
69 const mine = await key($)
70 for (const k of await $.store.keys()) {
71 if (!k.startsWith('session:') || k === mine) continue
72 const list = ((await $.store.get(k)) as Draft[] | undefined) ?? []
73 if (!list.some(d => now - d.savedAt < KEEP_MS)) await $.store.delete(k)
74 }
75}
76
77// Put a draft in the prompt box, saving any other text that was there as a draft first.
78async function use($: EngineInterface, d: Draft) {
79 const box = await $.prompt.read()
80 if (box.text.trim() && box.text.trim() !== d.text.trim() && marked(box.text) === null) {
81 await save($, box.text)
82 $.ui.toast(text[lang].stashed)
83 }
84 const done = await $.prompt.fill({ text: d.text, mode: 'replace' })
85 return done.isFilled
86}
87
88// Picking puts the draft in the prompt box and closes both the sidebar and the number row.
89async function pick($: EngineInterface, d: Draft) {
90 await closePicker($)
91 await use($, d)
92}
93
94async function closePicker($: EngineInterface) {
95 await update($, isPicking, () => false)
96 await update($, deleted, () => null)
97 if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
98}
99
100async function saveBox($: EngineInterface) {
101 const { text: body } = await $.prompt.read()
102 if (!body.trim()) return $.ui.toast(text[lang].empty)
103 const list = await save($, body)
104 await $.prompt.fill({ text: '', mode: 'replace' })
105 $.ui.toast(text[lang].saved(list.length, title(body)))
106}
107
108async function undoFromPane($: EngineInterface) {
109 const d = await undo($)
110 if (d) $.ui.toast(text[lang].restored(d.title))
111}
112
113// Narrow terminals: Claude Code's own question dialog, a page at a time (it takes 2-4 options).
114async function askPick($: EngineInterface, list: Draft[]) {
115 const t = text[lang]
116 let start = 0
117 for (;;) {
118 const { page, labels, hasMore, next } = askPage(list, start)
119 const more = t.more(Math.min(3, list.length - next))
120 let answer: string
121 try {
122 answer = await $.ui.ask(t.ask, { header: t.picker, options: hasMore ? [...labels, more] : labels.length > 1 ? labels : [...labels, t.close] })
123 } catch {
124 return null // dismissed
125 }
126 if (hasMore && answer === more) { start = next; continue }
127 const i = labels.indexOf(answer)
128 return i >= 0 ? page[i]! : null
129 }
130}
131
132// The sidebar shows when it has room; the number row above the prompt is what the keys reach either way.
133async function paneShown($: EngineInterface) {
134 return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
135}
136
137export const register: Register = (on, options) => {
138 lang = options.language === 'ko' ? 'ko' : 'en'
139 const t = text[lang]
140
141 on('session.start', async ($, e, next) => {
142 storeKey = ''
143 await $.command.register({ name: 'draft', description: t.cmdDraft, argumentHint: '<text>' })
144 await $.command.register({ name: 'drafts', description: t.cmdDrafts, argumentHint: '[n | rm n | undo]' })
145 await load($)
146 void tidy($)
147 return next(e)
148 })
149
150 // A typed prompt ending with ";;" is saved and not sent. Any prompt also closes the list.
151 on('prompt.submit', async ($, e, next) => {
152 if (e.origin && e.origin.kind !== 'composer') return next(e)
153 await update($, isPicking, () => false)
154 const body = marked(e.text)
155 if (body === null) return next(e)
156 if (!body) return { drop: t.empty }
157 const list = await save($, body)
158 return { drop: t.saved(list.length, title(body)) }
159 }).catch(($, e, next) => (next.called ? next(e) : { drop: t.failed }))
160
161 on('command.run', { command: 'draft' }, async ($, e) => {
162 const body = e.args.trim()
163 if (!body) return { text: t.draftUsage }
164 const list = await save($, body)
165 return { text: t.saved(list.length, title(body)) }
166 })
167
168 on('command.run', { command: 'drafts' }, async ($, e) => {
169 const list = await load($)
170 const args = e.args.trim()
171 const nth = (s: string) => list[Number(s) - 1]
172 if (!args) {
173 const drawn = (await $.session.surface()) !== null
174 if (!list.length || !drawn) return { text: listText(list, await $.clock.now(), lang) }
175 if (e.presentation.columns < WIDE) {
176 const d = await askPick($, list)
177 if (!d) return { text: t.closed }
178 return { text: (await use($, d)) ? t.loaded(d.title) : t.noBox }
179 }
180 await $.ui.open({ id: PANE, title: t.picker })
181 await update($, isPicking, () => true)
182 return { text: (await paneShown($)) ? t.openedWide : t.openedNarrow }
183 }
184 if (args === 'undo') {
185 const d = await undo($)
186 return { text: d ? t.restored(d.title) : t.noUndo }
187 }
188 const rm = args.match(/^rm\s+(\d+)$/)
189 if (rm) {
190 const d = nth(rm[1]!)
191 if (!d) return { text: t.missing(rm[1]!) }
192 await remove($, d)
193 return { text: t.removed(d.title) }
194 }
195 if (/^\d+$/.test(args)) {
196 const d = nth(args)
197 if (!d) return { text: t.missing(args) }
198 return { text: (await use($, d)) ? t.loaded(d.title) : t.noBox }
199 }
200 return { text: t.usage }
201 })
202
203 // Closing the sidebar (its ×, or Escape) closes the number row too.
204 on('ui.close', { id: PANE }, async ($, e, next) => {
205 await update($, isPicking, () => false)
206 await update($, deleted, () => null)
207 return next(e)
208 })
209
210 // Above the prompt: a bare digit in an empty prompt box presses one of these buttons. With the sidebar shown it is
211 // one row of numbers; without it it is the whole list.
212 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213 if (e.props.hasSurvey || !(await read($, isPicking))) return next(e)
214 const list = (await read($, drafts)).slice(0, SHOWN)
215 if (!list.length) return next(e)
216 const { Box, Text, Button } = $.ui.resolve(e)
217 if (await paneShown($)) {
218 return (
219 <Box>
220 <Text bold>{t.picker}</Text>
221 <Text dimColor> {t.keysHint} </Text>
222 {list.map((d, i) => (
223 <Button key={`use-${d.id}`} label={`${d.title.slice(0, 12)}${d.title.length > 12 ? '…' : ''} `} hotkey={String(i + 1)} plain onPress={() => pick($, d)} />
224 ))}
225 <Button key="close" label={t.close} hotkey="0" plain onPress={() => closePicker($)} />
226 </Box>
227 )
228 }
229 const now = await $.clock.now()
230 return (
231 <Box flexDirection="column">
232 <Box>
233 <Text bold>{t.picker}</Text>
234 <Text dimColor> {t.pickHint}</Text>
235 </Box>
236 {list.map((d, i) => (
237 <Box key={d.id}>
238 <Button key={`use-${d.id}`} label={d.title} hotkey={String(i + 1)} plain onPress={() => pick($, d)} />
239 <Text dimColor> {t.meta(ago(now, d.savedAt, lang), d.text.length)}</Text>
240 </Box>
241 ))}
242 <Button key="close" label={t.close} hotkey="0" plain onPress={() => closePicker($)} />
243 </Box>
244 )
245 })
246
247 // The sidebar: this session's drafts with up to three lines each, click to use, × to delete, undo the last delete.
248 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249 const { Box, Text, Button } = $.ui.resolve(e)
250 const list = await read($, drafts)
251 const gone = await read($, deleted)
252 const now = await $.clock.now()
253 const width = Math.max(20, (e.viewport?.columns ?? 60) - 6)
254 let rows = Math.max(4, (e.viewport?.rows ?? 24) - (gone ? 6 : 4))
255 const shown: { d: Draft; i: number; more: string[] }[] = []
256 for (const [i, d] of list.entries()) {
257 const more = preview(d.text, width)
258 if (rows < more.length + 2) break
259 rows -= more.length + 2
260 shown.push({ d, i, more })
261 }
262 return (
263 <Box flexDirection="column">
264 <Button key="save" label={t.saveButton} onPress={() => saveBox($)} />
265 <Text dimColor>{t.paneHint}</Text>
266 {gone && (
267 <Box>
268 <Text dimColor>{t.deletedLine(gone.draft.title.slice(0, 24))} </Text>
269 <Button key="undo" label={t.undoButton} plain onPress={() => undoFromPane($)} />
270 </Box>
271 )}
272 <Text> </Text>
273 {list.length === 0 && <Text dimColor>{t.none}</Text>}
274 {shown.map(({ d, i, more }) => (
275 <Box flexDirection="column" key={d.id}>
276 <Button key={`pane-${d.id}`} label={`${i + 1} ${d.title}`} plain onPress={() => pick($, d)} />
277 {more.map(line => <Text dimColor> {line}</Text>)}
278 <Box>
279 <Text dimColor> {t.meta(ago(now, d.savedAt, lang), d.text.length)} </Text>
280 <Button key={`rm-${d.id}`} label="×" plain onPress={() => remove($, d)} />
281 </Box>
282 </Box>
283 ))}
284 </Box>
285 )
286 })
287}
288hooks/drafts.ts 136 lines1// The pure part of prompt-drafts: the save marker, titles and previews, the list, and every message in two languages.
2import type { Draft } from '../types'
3
4export const MAX = 50
5// A prompt that ends with this is saved as a draft instead of sent.
6export const MARKER = ';;'
7// A session's drafts are dropped after this long without a new one.
8export const KEEP_MS = 30 * 24 * 3600 * 1000
9export type Lang = 'en' | 'ko'
10
11// The text before the marker when the prompt ends with it, else null.
12export function marked(text: string) {
13 const t = text.trimEnd()
14 if (!t.endsWith(MARKER)) return null
15 return t.slice(0, -MARKER.length).trimEnd()
16}
17
18const lines = (text: string) => text.split('\n').map(l => l.trim()).filter(Boolean)
19const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
20
21export function title(text: string) {
22 return cut(lines(text)[0] ?? '', 48)
23}
24
25// Up to two more lines under the title, the second ending in … when the draft goes on.
26export function preview(text: string, width = 60) {
27 const rest = lines(text).slice(1)
28 const shown = rest.slice(0, 2).map(l => cut(l, width))
29 if (rest.length > 2 && shown.length) shown[shown.length - 1] = `${shown[shown.length - 1]!.replace(/…$/, '')}…`
30 return shown
31}
32
33// Newest first; saving the same text again moves it to the top instead of keeping two.
34export function add(list: readonly Draft[], d: Draft) {
35 return [d, ...list.filter(x => x.text !== d.text)].slice(0, MAX)
36}
37
38// Put a deleted draft back where it was.
39export function restore(list: readonly Draft[], d: Draft, index: number) {
40 const out = list.filter(x => x.id !== d.id)
41 out.splice(Math.min(index, out.length), 0, d)
42 return out.slice(0, MAX)
43}
44
45export function ago(now: number, at: number, lang: Lang) {
46 const m = Math.max(0, Math.round((now - at) / 60000))
47 const [h, d] = [Math.floor(m / 60), Math.floor(m / 1440)]
48 if (lang === 'ko') return m < 1 ? '방금' : m < 60 ? `${m}분 전` : h < 24 ? `${h}시간 전` : `${d}일 전`
49 return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : h < 24 ? `${h}h ago` : `${d}d ago`
50}
51
52export const text = {
53 en: {
54 saved: (n: number, t: string) => `✓ Saved as a draft (${n} in this session): ${t}`,
55 empty: 'Nothing to save: the prompt is empty.',
56 failed: 'The draft could not be saved, so the prompt was not sent either.',
57 loaded: (t: string) => `In the prompt box: ${t}`,
58 noBox: 'There is no prompt box here to put the draft in.',
59 stashed: 'What was in the prompt box was saved as a draft first.',
60 removed: (t: string) => `Deleted: ${t}. /drafts undo brings it back.`,
61 restored: (t: string) => `Back: ${t}`,
62 noUndo: 'Nothing to bring back.',
63 missing: (n: string) => `There is no draft ${n}.`,
64 none: `No drafts in this session. End a prompt with ${MARKER} and press Enter to keep it aside.`,
65 usage: 'Usage: /drafts, /drafts <n>, /drafts rm <n>, /drafts undo',
66 draftUsage: `Usage: /draft <text>. Or end the prompt you are writing with ${MARKER} and press Enter.`,
67 picker: 'Drafts',
68 pickHint: 'press a number to use one, 0 to close',
69 keysHint: 'number keys',
70 paneHint: 'Click a draft or press its number to put it in the prompt box.',
71 saveButton: 'Save prompt box',
72 undoButton: 'Undo',
73 deletedLine: (t: string) => `Deleted: ${t}`,
74 close: 'close',
75 ask: 'Which draft?',
76 closed: 'Closed.',
77 more: (n: number) => `Next ${n} ▸`,
78 openedWide: 'Pick a draft in the sidebar, or press its number. 0 closes.',
79 openedNarrow: 'Press a number to put a draft in the prompt box. 0 closes.',
80 meta: (a: string, n: number) => `${a}, ${n} chars`,
81 cmdDraft: `Save a prompt as a draft of this session: /draft <text>, or end any prompt with ${MARKER}`,
82 cmdDrafts: "This session's drafts: pick one, /drafts <n>, /drafts rm <n>, /drafts undo",
83 },
84 ko: {
85 saved: (n: number, t: string) => `✓ 임시저장했습니다 (이 세션 ${n}개): ${t}`,
86 empty: '입력창이 비어 있어 저장하지 않았습니다.',
87 failed: '임시저장에 실패해서 보내지도 않았습니다.',
88 loaded: (t: string) => `입력창에 꺼냈습니다: ${t}`,
89 noBox: '여기에는 꺼낸 프롬프트를 넣을 입력창이 없습니다.',
90 stashed: '입력창에 있던 내용은 먼저 임시저장했습니다.',
91 removed: (t: string) => `지웠습니다: ${t}. 되돌리려면 /drafts undo`,
92 restored: (t: string) => `되돌렸습니다: ${t}`,
93 noUndo: '되돌릴 것이 없습니다.',
94 missing: (n: string) => `${n}번 임시저장이 없습니다.`,
95 none: `이 세션에 임시저장한 프롬프트가 없습니다. 프롬프트 끝에 ${MARKER} 를 붙이고 Enter 하면 저장됩니다.`,
96 usage: '사용법: /drafts, /drafts <번호>, /drafts rm <번호>, /drafts undo',
97 draftUsage: `사용법: /draft <내용>. 또는 쓰던 프롬프트 끝에 ${MARKER} 를 붙이고 Enter.`,
98 picker: '임시저장',
99 pickHint: '번호를 누르면 꺼내고, 0은 닫기',
100 keysHint: '번호 키',
101 paneHint: '클릭하거나 번호를 누르면 입력창에 꺼냅니다.',
102 saveButton: '입력창 저장',
103 undoButton: '되돌리기',
104 deletedLine: (t: string) => `지웠습니다: ${t}`,
105 close: '닫기',
106 ask: '어떤 임시저장을 꺼낼까요?',
107 closed: '닫았습니다.',
108 more: (n: number) => `다음 ${n}개 ▸`,
109 openedWide: '사이드바에서 고르거나, 번호를 눌러 바로 꺼내세요. 0은 닫기.',
110 openedNarrow: '번호를 누르면 입력창에 꺼냅니다. 0은 닫기.',
111 meta: (a: string, n: number) => `${a}, ${n}자`,
112 cmdDraft: `이 세션에 프롬프트 임시저장: /draft <내용>, 또는 프롬프트 끝에 ${MARKER}`,
113 cmdDrafts: '이 세션의 임시저장: 골라서 꺼내기, /drafts <번호>, /drafts rm <번호>, /drafts undo',
114 },
115} satisfies Record<Lang, Record<string, unknown>>
116
117// One page of the native question dialog (2-4 options): up to 4 drafts, or 3 and a "next" option.
118// Labels must differ, so a repeated title gets its number.
119export function askPage(list: readonly Draft[], start: number) {
120 const rest = list.slice(start)
121 const page = rest.length <= 4 ? rest : rest.slice(0, 3)
122 const seen = new Set<string>()
123 const labels = page.map((d, i) => {
124 const label = seen.has(d.title) || !d.title ? `${start + i + 1}. ${d.title}` : d.title
125 seen.add(label)
126 return label
127 })
128 return { page, labels, hasMore: rest.length > page.length, next: start + page.length }
129}
130
131export function listText(list: readonly Draft[], now: number, lang: Lang) {
132 const t = text[lang]
133 if (!list.length) return t.none
134 return list.map((d, i) => `${String(i + 1).padStart(2)} ${d.title} (${t.meta(ago(now, d.savedAt, lang), d.text.length)})`).join('\n')
135}
136types/index.d.ts 9 lines1export type Draft = { id: string; title: string; text: string; savedAt: number }
2export type Deleted = { draft: Draft; index: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'prompt-drafts': { drafts: Draft[]; isPicking: boolean; deleted: Deleted | null }
7 }
8}
9