git stash for your prompt: every draft you wipe without sending is kept, /stash pop brings it back

git stash for your prompt. Wipe a draft without sending it (Ctrl+U, select-all and delete, clearing the box) and it is kept on a stack. /stash pop puts it back.
/stash pop
Or end a message with /stash to stash it instead of sending it:
refactor the auth middleware to use the new token cache /stash
Only the last word counts, so a message that mentions /stash is sent as written.
A toast confirms each stash. Short drafts (under 15 characters) and drafts you send are not kept.
| Command | Does |
|---|---|
/stash or /stash list | Open the stack in a pane, with Pop and Drop per entry |
/stash pop [N] | Put draft N (default 0, the newest) back in the prompt box and remove it from the stack |
/stash apply [N] | Same, but keep it on the stack |
/stash drop [N] | Delete draft N |
/stash clear | Delete every stashed draft |
N can also be written stash@{N}.
/plugin install stash-stack --marketplace yyosifov/prompt-mods
| Setting | Default | Does |
|---|---|---|
showStatus | on | Shows stash: N · /stash in the status line while the stash holds anything |
Change it from the plugin's config menu in /plugin.
~/.claude/plugins/store/.hooks/register.tsx 174 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3
4import type { Stashed } from '../types';
5import { parseArgs } from './args';
6import { push, stashRequest, vanished, wiped } from './capture';
7
8const PANE = 'stash-stack';
9const stack = atom({ plugin: 'stash-stack', key: 'stack' } as const, []);
10
11// The stack is kept per project, across sessions, like git's.
12const storeKey = async ($: EngineInterface): Promise<string> => {
13 const root = await $.session.root();
14 return `stack:${root}`;
15};
16
17// Set from the `showStatus` option each time the module loads.
18let isStatusShown = true;
19
20const showCount = ($: EngineInterface, count: number): void => {
21 const isVisible = isStatusShown && count > 0;
22 $.ui.status(isVisible ? `stash: ${count} · /stash` : undefined);
23};
24
25const save = async ($: EngineInterface, next: Stashed[]): Promise<void> => {
26 await update($, stack, () => next);
27 showCount($, next.length);
28 const key = await storeKey($);
29 await $.store.set(key, next);
30};
31
32const drop = async ($: EngineInterface, index: number): Promise<void> => {
33 const list = await read($, stack);
34 await save(
35 $,
36 list.filter((_, i) => i !== index),
37 );
38};
39
40const preview = (text: string, width: number): string => {
41 const line = text.replace(/\s+/g, ' ').trim();
42 return line.length > width ? `${line.slice(0, width - 1)}…` : line;
43};
44
45// What the box held after the last edit we saw; a reload starts it over.
46let last = '';
47
48const keep = async ($: EngineInterface, text: string): Promise<void> => {
49 const at = await $.clock.now();
50 const list = await read($, stack);
51 await save($, push(list, { text, at }));
52 $.ui.toast(`Stashed: ${preview(text, 40)} · /stash pop`);
53};
54
55const fill = async ($: EngineInterface, entry: Stashed, index: number, isKept: boolean): Promise<void> => {
56 const { text: draft } = await $.prompt.read();
57 await $.prompt.fill({ text: entry.text, mode: draft.trim() === '' ? 'replace' : 'append' });
58 last = entry.text;
59 if (!isKept) {
60 await drop($, index);
61 }
62};
63
64const restore = async ($: EngineInterface, index: number, isKept: boolean): Promise<{ text: string }> => {
65 const list = await read($, stack);
66 const entry = list[index];
67 if (entry === undefined) {
68 const isEmpty = list.length === 0;
69 if (isEmpty) {
70 return { text: 'The stash is empty.' };
71 }
72
73 return { text: `No stash@{${index}}; /stash lists ${list.length}.` };
74 }
75
76 // The command's own run clears the box when it ends, so the fill waits for that.
77 $.clock.after(0, () => void fill($, entry, index, isKept));
78
79 return { text: `${isKept ? 'Applied' : 'Popped'} stash@{${index}}: ${preview(entry.text, 60)}` };
80};
81
82export const register: Register = (on, options) => {
83 isStatusShown = options.showStatus !== false;
84
85 on('session.start', async ($, e, next) => {
86 await $.command.register({
87 name: 'stash',
88 description: 'Drafts you wiped without sending: list, pop, apply, drop',
89 argumentHint: '[list | pop [N] | apply [N] | drop [N] | clear]',
90 });
91 const key = await storeKey($);
92 const kept = await $.store.get(key);
93 const list = Array.isArray(kept) ? (kept as Stashed[]) : [];
94 await update($, stack, () => list);
95 // Also takes down a line a reload or an earlier version left behind.
96 showCount($, list.length);
97
98 return next(e);
99 });
100
101 on('prompt.edit', async ($, e, next) => {
102 const gone = vanished(last, e.text);
103 const box = await next(e);
104 const cleared = wiped(e.text, box.text);
105 last = box.text;
106
107 const draft = cleared ?? gone;
108 if (draft !== null) {
109 await keep($, draft);
110 }
111
112 return box;
113 });
114
115 // A sent draft is not a lost one.
116 on('prompt.submit', async ($, e, next) => {
117 last = '';
118 const draft = stashRequest(e.text);
119 if (draft === null) {
120 return next(e);
121 }
122
123 await keep($, draft);
124 return { drop: 'Stashed instead of sent · /stash pop brings it back' };
125 });
126
127 on('command.run', { command: 'stash' }, async ($, e) => {
128 const action = parseArgs(e.args);
129 switch (action.kind) {
130 case 'list':
131 await $.ui.open({ id: PANE, title: 'stash', focus: true });
132 return {};
133 case 'pop':
134 return restore($, action.index, false);
135 case 'apply':
136 return restore($, action.index, true);
137 case 'drop': {
138 const list = await read($, stack);
139 if (list[action.index] === undefined) {
140 return { text: `No stash@{${action.index}}.` };
141 }
142 await drop($, action.index);
143 return { text: `Dropped stash@{${action.index}}.` };
144 }
145 case 'clear':
146 await save($, []);
147 return { text: 'Stash cleared.' };
148 case 'help':
149 return { text: `Unknown: ${action.text}. Use /stash [list | pop [N] | apply [N] | drop [N] | clear].` };
150 }
151 });
152
153 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154 const { Box, Text, Button } = $.ui.resolve(e);
155 const list = await read($, stack);
156 const width = Math.max(20, (e.viewport?.columns ?? 80) - 30);
157
158 return (
159 <Box flexDirection="column">
160 {list.length === 0 && <Text dimColor>Nothing stashed. Drafts you wipe without sending land here.</Text>}
161 {list.map((entry, i) => (
162 <Box key={`s${i}`}>
163 <Text dimColor>{`stash@{${i}} `}</Text>
164 <Text>{preview(entry.text, width)} </Text>
165 <Button key={`pop${i}`} label="Pop" onPress={() => void restore($, i, false)} />
166 <Text> </Text>
167 <Button key={`drop${i}`} label="Drop" dimColor onPress={() => void drop($, i)} />
168 </Box>
169 ))}
170 </Box>
171 );
172 });
173};
174hooks/args.ts 33 lines1export type StashAction =
2 | { kind: 'list' }
3 | { kind: 'pop'; index: number }
4 | { kind: 'apply'; index: number }
5 | { kind: 'drop'; index: number }
6 | { kind: 'clear' }
7 | { kind: 'help'; text: string };
8
9const index = (raw: string | undefined): number => {
10 const match = /^(?:stash@\{)?(\d+)\}?$/.exec(raw ?? '');
11 return match === null ? 0 : Number(match[1]);
12};
13
14export const parseArgs = (args: string): StashAction => {
15 const [verb = 'list', arg] = args.trim().split(/\s+/);
16 switch (verb.toLowerCase()) {
17 case '':
18 case 'list':
19 case 'ls':
20 return { kind: 'list' };
21 case 'pop':
22 return { kind: 'pop', index: index(arg) };
23 case 'apply':
24 return { kind: 'apply', index: index(arg) };
25 case 'drop':
26 return { kind: 'drop', index: index(arg) };
27 case 'clear':
28 return { kind: 'clear' };
29 default:
30 return { kind: 'help', text: verb };
31 }
32};
33hooks/capture.ts 47 lines1export const MIN_LENGTH = 15;
2export const MAX_ENTRIES = 50;
3
4// Only as the last word: a message that mentions the command, or quotes the mod's own
5// notice, is sent as written.
6const STASH_SUFFIX = /(^|\s)\/stash\s*$/;
7
8export const stashRequest = (text: string): string | null => {
9 const isRequest = STASH_SUFFIX.test(text);
10 if (!isRequest) {
11 return null;
12 }
13
14 const draft = text.replace(STASH_SUFFIX, '').trim();
15 return draft === '' ? null : draft;
16};
17
18const isWorthKeeping = (draft: string): boolean => draft.trim().length >= MIN_LENGTH;
19
20// The draft to keep when the box went from `before` to `after`: most of a
21// worthwhile draft wiped in one go (Ctrl+U, select-all + delete, a cut).
22export const wiped = (before: string, after: string): string | null =>
23 isWorthKeeping(before) && after.trim().length <= before.trim().length * 0.2 ? before : null;
24
25// The draft to keep when the box no longer holds what it last held, with no
26// edit of ours in between (Ctrl+S, Ctrl+C, a clear the editor did itself).
27// A slash command clears the box when it runs without passing prompt.submit,
28// so one that vanishes was run, not lost.
29export const vanished = (last: string, now: string): string | null => {
30 const isCommand = last.trimStart().startsWith('/');
31 if (isCommand) {
32 return null;
33 }
34
35 return isWorthKeeping(last) && now !== last && !now.includes(last.trim()) ? last : null;
36};
37
38export const push = <T extends { text: string }>(stack: readonly T[], entry: T): T[] => {
39 const isAlreadyNewest = stack[0]?.text === entry.text;
40 if (isAlreadyNewest) {
41 return [...stack];
42 }
43
44 const others = stack.filter((s) => s.text !== entry.text);
45 return [entry, ...others].slice(0, MAX_ENTRIES);
46};
47types/index.d.ts 11 lines1export type Stashed = { text: string; at: number };
2
3declare module 'claude-code' {
4 interface PluginState {
5 'stash-stack': {
6 // Newest first, as git numbers stash@{0}.
7 stack: Stashed[];
8 };
9 }
10}
11