Keeps every stashed prompt draft in a list you can recall, remove or clear, without sending it

Keeps every prompt draft you stash in Claude Code, so a second subject never pushes out the first. Recall, remove or clear them later, without sending anything to the model.
Install · What it keeps · Band · List · Requirements · Privacy
Claude Code's own stash (ctrl+S) parks the draft in the prompt box and puts it back after your next submit, but it holds one draft. prompt-stash keeps the native gesture and adds the history: every stashed draft stays in a list until you remove it.
/plugin marketplace add heidiks/agent-kit
/plugin install prompt-stash@agent-kit
Restart the session (claude --continue keeps it) to load it.
Every draft that leaves the prompt box without being sent:
⊕ stash draft in the band, which stashes the draft in the box and empties it.Not kept: drafts you send, drafts that run as a slash command (/...) or in bash mode (!...), since running them also empties the box; use ⊕ stash draft in the band to keep one of those. Only the text is kept, not pasted images or other attachments. A draft identical to one in the list moves that one to the top instead of adding a copy, and the list keeps the newest 50.
While the current project has stashed drafts, a line above the prompt shows them:
<img alt="prompt-stash band above the Claude Code prompt: 2 stashed, stash draft and list buttons, list under the pointer" src="assets/band-light.svg">
⊕ stash draft stashes the draft in the box, ≡ list opens the list; click them, or focus the band with ctrl+x tab, Tab to one and press Enter. With nothing stashed, nothing is drawn.
With pr-watch showing pull requests, prompt-stash draws no line of its own: ✎ N sits on pr-watch's header line instead and opens the list.
/stash opens the list, also while Claude is working; so do ≡ list in the band and the pr-watch chip.
<img alt="prompt-stash list: this project filter, three stashed drafts with age and project, a remove mark on each" src="assets/list-light.svg">
| Control | Action |
|---|---|
| A draft | Puts it in the prompt box, not sent; a draft already in the box goes to the list first. The draft stays in the list |
× | Removes that draft |
this project / all projects | Drafts of the current project (the default) or of every project |
clear | Removes the drafts the list shows, after remove N items? yes no |
Esc closes it. Nothing in the list, /stash included, adds anything to the conversation the model reads.
Drafts are stored as plain JSON in the plugin's own store under your Claude Code configuration directory, as Claude Code keeps its prompt history, and never leave your machine: the plugin makes no network call and sends nothing to the model. The list is per machine, not synced.
Built on Claude Code's early access plugin hooks API, which may change between releases.
hooks/register.tsx 145 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StashItem, StashScope } from '../types'
5import { dropHeld, holdCleared, trackDraft } from './capture'
6import { addItem, changeItems, clearItems, itemsIn, loadItems, removeItem } from './store'
7import { renderBand, renderPane } from './view'
8
9const PANE = 'prompt-stash'
10const DEFAULT_WIDTH = 100
11const items = atom({ plugin: 'prompt-stash', key: 'items' } as const, [] as StashItem[])
12const scope = atom({ plugin: 'prompt-stash', key: 'scope' } as const, 'project' as StashScope)
13const isClearing = atom({ plugin: 'prompt-stash', key: 'isClearing' } as const, false)
14const count = atom({ plugin: 'prompt-stash', key: 'count' } as const, 0)
15
16async function publish($: EngineInterface, list: StashItem[]): Promise<void> {
17 const project = await $.session.root()
18 await update($, items, () => list)
19 await update($, count, () => itemsIn(list, project).length)
20}
21
22async function mutate($: EngineInterface, change: (list: StashItem[]) => StashItem[]): Promise<void> {
23 await publish($, await changeItems($, change))
24}
25
26async function record($: EngineInterface, text: string): Promise<void> {
27 const project = await $.session.root()
28 const now = await $.clock.now()
29 await mutate($, list => addItem(list, text, project, now))
30}
31
32export async function stashDraft($: EngineInterface): Promise<void> {
33 const box = await $.prompt.read()
34 if (!box.text.trim()) {
35 $.ui.toast('Nothing to stash')
36 return
37 }
38 await record($, box.text)
39 await $.prompt.fill({ text: '' })
40}
41
42async function scopeProject($: EngineInterface): Promise<string | undefined> {
43 return (await read($, scope)) === 'all' ? undefined : $.session.root()
44}
45
46async function openPane($: EngineInterface): Promise<void> {
47 const opened = await $.ui.open({ id: PANE, title: 'Stashed prompts', focus: true, closeOnEscape: true, rows: 16 }).catch(() => undefined)
48 await update($, isClearing, () => false)
49 if (!opened?.isPlaced) {
50 $.ui.toast('Could not open the stashed prompts here')
51 }
52}
53
54async function recall($: EngineInterface, item: StashItem): Promise<void> {
55 const box = await $.prompt.read()
56 if (box.text.trim()) {
57 await record($, box.text)
58 }
59 await $.prompt.fill({ text: item.text })
60 await $.ui.close({ id: PANE })
61}
62
63async function clear($: EngineInterface): Promise<void> {
64 const project = await scopeProject($)
65 await mutate($, list => clearItems(list, project))
66 await update($, isClearing, () => false)
67}
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({
72 name: 'stash',
73 description: 'Stashed prompt drafts: recall, remove or clear them without sending',
74 immediate: true,
75 })
76 await publish($, await loadItems($))
77 return next(e)
78 })
79
80 on('prompt.edit', async ($, e, next) => {
81 const after = await next(e)
82 trackDraft(after.text)
83 return after
84 })
85
86 on('prompt.change', async ($, e, next) => {
87 if (e.origin.kind === 'clear') {
88 holdCleared($, text => record($, text))
89 } else {
90 trackDraft(e.text)
91 }
92 return next(e)
93 })
94
95 on('prompt.submit', async ($, e, next) => {
96 dropHeld()
97 return next(e)
98 }).catch(($, e, next) => next(e))
99
100 on('ui.press', { plugin: 'pr-watch', element: 'stash' }, async $ => {
101 await openPane($)
102 return { element: 'stash' }
103 })
104
105 on('command.run', { command: 'stash' }, async $ => {
106 await openPane($)
107 return {}
108 })
109
110 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111 const stashed = await read($, count)
112 if (e.props.hasSurvey || stashed === 0) {
113 return next(e)
114 }
115 const below = await next(e)
116 if (below.type !== 'engine') {
117 return below
118 }
119 return renderBand({
120 el: $.ui.resolve(e),
121 count: stashed,
122 actions: { stash: () => void stashDraft($), open: () => void openPane($) },
123 })
124 })
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
127 renderPane({
128 el: $.ui.resolve(e),
129 items: itemsIn(await read($, items), await scopeProject($)),
130 scope: await read($, scope),
131 isClearing: await read($, isClearing),
132 now: await $.clock.now(),
133 width: e.props.bodyColumns || DEFAULT_WIDTH,
134 actions: {
135 recall: item => void recall($, item),
136 remove: id => void mutate($, list => removeItem(list, id)),
137 setScope: next => void update($, scope, () => next).then(() => update($, isClearing, () => false)),
138 askClear: () => void update($, isClearing, () => true),
139 cancelClear: () => void update($, isClearing, () => false),
140 clear: () => void clear($),
141 },
142 }),
143 )
144}
145hooks/capture.ts 33 lines1import type { EngineInterface, Timer } from 'claude-code'
2
3export const SUBMIT_WAIT_MS = 300
4
5let draft = ''
6let held: Timer | undefined
7
8export function isCommand(text: string): boolean {
9 return /^[!/]/.test(text.trimStart())
10}
11
12export function trackDraft(text: string): void {
13 draft = text
14}
15
16export function holdCleared($: EngineInterface, record: (text: string) => Promise<void>): void {
17 const cleared = draft
18 draft = ''
19 if (!cleared.trim() || isCommand(cleared)) {
20 return
21 }
22 held?.cancel()
23 held = $.clock.after(SUBMIT_WAIT_MS, () => {
24 held = undefined
25 void record(cleared)
26 })
27}
28
29export function dropHeld(): void {
30 held?.cancel()
31 held = undefined
32}
33hooks/store.ts 46 lines1import type { EngineInterface } from 'claude-code'
2
3import type { StashItem } from '../types'
4
5export const MAX_ITEMS = 50
6const STORE_KEY = 'items'
7
8function hashOf(text: string): string {
9 let hash = 0
10 for (const char of text) {
11 hash = (hash * 31 + char.charCodeAt(0)) | 0
12 }
13 return (hash >>> 0).toString(36)
14}
15
16export function addItem(items: readonly StashItem[], text: string, project: string, at: number): StashItem[] {
17 const key = text.trim()
18 if (!key) {
19 return [...items]
20 }
21 const rest = items.filter(item => item.text.trim() !== key)
22 return [{ id: `${at.toString(36)}-${hashOf(key)}`, text, project, at }, ...rest].slice(0, MAX_ITEMS)
23}
24
25export function removeItem(items: readonly StashItem[], id: string): StashItem[] {
26 return items.filter(item => item.id !== id)
27}
28
29export function itemsIn(items: readonly StashItem[], project?: string): StashItem[] {
30 return project === undefined ? [...items] : items.filter(item => item.project === project)
31}
32
33export function clearItems(items: readonly StashItem[], project?: string): StashItem[] {
34 return project === undefined ? [] : items.filter(item => item.project !== project)
35}
36
37export async function loadItems($: EngineInterface): Promise<StashItem[]> {
38 return ((await $.store.get(STORE_KEY)) as StashItem[] | undefined) ?? []
39}
40
41export async function changeItems($: EngineInterface, change: (items: StashItem[]) => StashItem[]): Promise<StashItem[]> {
42 const next = change(await loadItems($))
43 await $.store.set(STORE_KEY, next)
44 return next
45}
46hooks/view.tsx 108 lines1import type { EngineInterface, TextHoverProps } from 'claude-code'
2
3import type { StashItem, StashScope } from '../types'
4
5export type PaneActions = {
6 recall: (item: StashItem) => void
7 remove: (id: string) => void
8 setScope: (scope: StashScope) => void
9 askClear: () => void
10 cancelClear: () => void
11 clear: () => void
12}
13
14export type PaneContext = {
15 el: ReturnType<EngineInterface['ui']['resolve']>
16 items: StashItem[]
17 scope: StashScope
18 isClearing: boolean
19 now: number
20 width: number
21 actions: PaneActions
22}
23
24export type BandContext = {
25 el: ReturnType<EngineInterface['ui']['resolve']>
26 count: number
27 actions: { stash: () => void; open: () => void }
28}
29
30const ROW_META_COLUMNS = 28
31
32const hoverOf = (scope: string): TextHoverProps => ({ scope, color: 'claude', dimColor: false, underline: true })
33
34export function ago(ms: number): string {
35 const minutes = Math.floor(Math.max(0, ms) / 60_000)
36 if (minutes < 1) return 'now'
37 if (minutes < 60) return `${minutes}m`
38 const hours = Math.floor(minutes / 60)
39 if (hours < 24) return `${hours}h`
40 return `${Math.floor(hours / 24)}d`
41}
42
43export function projectName(path: string): string {
44 return path.split('/').filter(Boolean).pop() ?? path
45}
46
47export function preview(text: string, width: number): string {
48 const lines = text.trim().split('\n')
49 const first = (lines[0] ?? '').trim()
50 const line = lines.length > 1 ? `${first} …` : first
51 return line.length > width ? `${line.slice(0, Math.max(1, width - 1))}…` : line
52}
53
54export function renderPane(ctx: PaneContext) {
55 const { Box, Button, Text } = ctx.el
56 const textWidth = Math.max(10, ctx.width - ROW_META_COLUMNS)
57 const count = ctx.items.length
58
59 const scopeButton = (scope: StashScope, label: string) =>
60 ctx.scope === scope ? (
61 <Button key={`scope-${scope}`} variant="primary" label={label} onPress={() => ctx.actions.setScope(scope)} />
62 ) : (
63 <Button key={`scope-${scope}`} plain label={label} onPress={() => ctx.actions.setScope(scope)} />
64 )
65
66 const clearControl = ctx.isClearing ? (
67 <Box flexDirection="row" gap={1}>
68 <Text color="error">{`remove ${count} ${count === 1 ? 'item' : 'items'}?`}</Text>
69 <Button key="clear-yes" plain label="yes" onPress={ctx.actions.clear} />
70 <Button key="clear-no" plain label="no" onPress={ctx.actions.cancelClear} />
71 </Box>
72 ) : (
73 count > 0 && <Button key="clear" plain label="clear" onPress={ctx.actions.askClear} />
74 )
75
76 return (
77 <Box flexDirection="column">
78 <Text bold>{`Stashed prompts (${count})`}</Text>
79 <Box flexDirection="row" gap={1}>
80 {scopeButton('project', 'this project')}
81 {scopeButton('all', 'all projects')}
82 {clearControl}
83 </Box>
84 {count === 0 && <Text dimColor>{ctx.scope === 'project' ? 'Nothing stashed in this project.' : 'Nothing stashed.'}</Text>}
85 {ctx.items.map(item => (
86 <Box key={item.id} flexDirection="row" gap={1}>
87 <Button key={`recall-${item.id}`} plain onPress={() => ctx.actions.recall(item)}>
88 {preview(item.text, textWidth)} <Text dimColor>{`${ago(ctx.now - item.at)} · ${projectName(item.project)}`}</Text>
89 </Button>
90 <Button key={`rm-${item.id}`} plain label="×" onPress={() => ctx.actions.remove(item.id)} />
91 </Box>
92 ))}
93 </Box>
94 )
95}
96
97export function renderBand(ctx: BandContext) {
98 const { Box, Button, Text } = ctx.el
99
100 return (
101 <Box flexDirection="row" gap={2}>
102 <Text dimColor>{`✎ ${ctx.count} stashed`}</Text>
103 <Button key="stash" plain dimColor hover={hoverOf('btn-stash')} label="⊕ stash draft" onPress={ctx.actions.stash} />
104 <Button key="list" plain dimColor hover={hoverOf('btn-list')} label="≡ list" onPress={ctx.actions.open} />
105 </Box>
106 )
107}
108types/index.d.ts 15 lines1export type StashItem = { id: string; text: string; project: string; at: number }
2
3export type StashScope = 'project' | 'all'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'prompt-stash': {
8 items: StashItem[]
9 scope: StashScope
10 isClearing: boolean
11 count: number
12 }
13 }
14}
15