SLOPSHOPPER

stash-stack

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

newpanecommandtoaststatusprompt
★ 1v0.2.1MITupdated 2026-10-09yyosifov/prompt-mods/mods/stash-stack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stash-stack
│ ┃ stash ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing stashed. Drafts you wipe without │ ┃ sending land here. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /stash │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · stash
Nothing stashed. Drafts you wipe without sending land here.
README

stash-stack

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.

Commands

CommandDoes
/stash or /stash listOpen 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 clearDelete every stashed draft

N can also be written stash@{N}.

Install

/plugin install stash-stack --marketplace yyosifov/prompt-mods

Settings

SettingDefaultDoes
showStatusonShows stash: N · /stash in the status line while the stash holds anything

Change it from the plugin's config menu in /plugin.

Notes

  • Stashes are kept per project (git root) and survive restarts, in ~/.claude/plugins/store/.
  • The stack keeps the 50 newest drafts; stashing the same text twice keeps one copy.
Source 4 files
hooks/register.tsx 174 lines
1import 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};
174
hooks/args.ts 33 lines
1export 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};
33
hooks/capture.ts 47 lines
1export 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};
47
types/index.d.ts 11 lines
1export 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