Stash a thought from the prompt box with ;; and bring it back in a later turn

A stash for the thoughts that cross your mind while Claude Code works: things you want to send in a later turn, at a moment you choose. Stash one with ;;, see what is waiting above the prompt, and bring it back when the time is right. Claude never reads a stashed thought.
/plugin marketplace add samaphp/prompt-stash
/plugin install prompt-stash@prompt-stash
| You do | What happens |
|---|---|
Type ;; and a thought, press Enter | The thought is stashed. The ;; turns blue as you type, and the line above the prompt says that Enter will stash it, so you know before you press. Works while Claude is answering. |
Type ;; alone, press Enter | The list opens and takes the keyboard. |
| In the list: a number, or arrows and Enter | That thought goes into the prompt box, and the list closes. A draft already in the box is kept; the thought lands after it. |
In the list: c | Copies the highlighted thought to the clipboard. It stays in the stash. |
In the list: d, then d again | Deletes the highlighted thought. The first press asks on the thought itself; the question goes away after four seconds or when you move. u brings the last deleted one back while the list is open. |
| Esc | Closes the list. |
The line above the prompt shows the count and the newest thought, and takes no room while the stash is empty:
● 3 ask whether the cache TTL should be configurable · 4m +2 more ;; + enter opens
/clear starts a new conversation with an empty stash.;; (a Lisp comment, say) is stashed instead of sent; the line above the prompt warns first, and the list brings it back.claude plugin validate .
claude plugin test .
npx -y -p typescript tsc -p . # once a session has loaded the mod and laid .claude-plugin/types/
Run it in a session without installing: claude --plugin-dir /path/to/prompt-stash.
MIT
hooks/register.tsx 699 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PaneOpenArgs, Register, UiPressArgument } from 'claude-code'
3
4import type { DraftKind, Thought } from '../types'
5
6const thoughts = atom({ plugin: 'prompt-stash', key: 'thoughts' } as const, [])
7const loadedSession = atom({ plugin: 'prompt-stash', key: 'sessionId' } as const, null)
8const shelfKey = atom({ plugin: 'prompt-stash', key: 'shelfKey' } as const, null)
9const draft = atom({ plugin: 'prompt-stash', key: 'draft' } as const, 'plain')
10const picked = atom({ plugin: 'prompt-stash', key: 'picked' } as const, null)
11const asking = atom({ plugin: 'prompt-stash', key: 'asking' } as const, null)
12const lastDiscarded = atom({ plugin: 'prompt-stash', key: 'lastDiscarded' } as const, null)
13
14// Decided 2026-10-06: a prompt that starts with this marker never reaches
15// Claude. With a thought after it, Enter shelves the thought; alone, Enter
16// opens the list. `thoughtIn` is the one test behind both the indicator shown
17// while typing and the catch at Enter, so what the band promises is what
18// Enter does.
19const MARKER = ';;'
20
21// The list is a pane that holds the keyboard while open, so its rows answer
22// to a bare number and `c`, `d` and `u` stay free for copy, delete and undo.
23const LIST = 'shelf-list'
24const LIST_PANE: PaneOpenArgs = { id: LIST, title: 'Stash', focus: true, closeOnEscape: true }
25const ROW_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
26const ROW_PREFIX = 'use-'
27
28// How long "delete?" waits for its second press before it withdraws itself.
29const ASK_MS = 4_000
30
31const TAG = '●'
32const HINT = `${MARKER} + enter opens`
33
34// The engine prints one transcript row for every prompt a hook drops, as
35// "Prompt dropped by a hook: <reason>". These reasons are this mod's own
36// words, and the row is rewritten to just them, so it reads as what
37// happened and never as an error. The match is on the words at the row's
38// end, so a change in the engine's wording before them still finds them.
39const NOTICE = {
40 stashed: `stashed · ${HINT} the stash`,
41 opened: 'stash open · esc closes it',
42 unopened: 'the stash could not open here',
43}
44
45// Decided 2026-10-06, after the shelf emptied when its conversation moved to
46// the agent window: a shelf belongs to a conversation. Claude Code gives a
47// conversation a new session id when it is continued elsewhere (the agent
48// window, a fork) and keeps reporting the time the conversation first
49// started; /clear starts a new time. That time, with the project folder, is
50// the shelf's key: thoughts follow the conversation, and a cleared one starts
51// with an empty shelf.
52const conversationKey = (root: string, startedAt: number) => `conversation:${startedAt}:${root}`
53
54
55const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
56
57const clip = (text: string, width: number) => {
58 const line = text.replace(/\s+/g, ' ').trim()
59
60 return line.length <= width ? line : `${line.slice(0, Math.max(1, width - 1))}…`
61}
62
63// A thought under a minute old shows no age: "now" after its text reads as
64// part of the sentence ("ask about the cache now").
65const age = (ms: number) => {
66 const minutes = Math.floor(ms / 60_000)
67
68 if (minutes < 1) {
69 return null
70 }
71
72 if (minutes < 60) {
73 return `${minutes}m`
74 }
75
76 const hours = Math.floor(minutes / 60)
77
78 return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
79}
80
81/** The thoughts waiting on the shelf, newest first. */
82const heldOf = (list: Thought[]) => list.filter(one => one.status === 'held').reverse()
83
84/** The thought in a marked draft, `''` for the marker alone, null for an ordinary draft. */
85const thoughtIn = (text: string): string | null => {
86 const body = text.trimStart()
87
88 return body.startsWith(MARKER) ? body.slice(MARKER.length).trim() : null
89}
90
91const kindOf = (text: string, cursor: number): DraftKind => {
92 const thought = thoughtIn(text)
93
94 if (thought !== null) {
95 return thought === '' ? 'marker' : 'thought'
96 }
97
98 // The engine draws its `/` command and `@` file pickers above the band, so
99 // the band steps aside while one is open and the picker sits on the prompt.
100 // Pattern taken from hoobnn-agent-mods' band kit (MIT).
101 const before = text.slice(0, cursor)
102
103 return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before) ? 'picker' : 'plain'
104}
105
106const setDraft = async ($: EngineInterface, kind: DraftKind) => {
107 if ((await read($, draft)) !== kind) {
108 await update($, draft, () => kind)
109 }
110}
111
112// The conversation's list in $.state is what the band and the list draw from.
113// A change is applied to the list saved in $.store: a continued conversation
114// can have two processes for a while, and each change then starts from what
115// the other one saved. Released and deleted thoughts stay on record there.
116const commit = async ($: EngineInterface, key: string, change: (list: Thought[]) => Thought[]) => {
117 const saved = await $.store.get(key)
118 const list = change(Array.isArray(saved) ? (saved as Thought[]) : [])
119
120 await $.store.set(key, list)
121 await update($, thoughts, () => list)
122}
123
124// Puts the current conversation's saved shelf into $.state: at process start,
125// and when the session id under the process changes (/clear, /resume, a
126// continuation elsewhere).
127const load = async ($: EngineInterface) => {
128 const sessionId = await $.session.id()
129 const key = conversationKey(await $.session.root(), (await $.session.usage()).startedAt)
130 const saved = await $.store.get(key)
131 const list = Array.isArray(saved) ? (saved as Thought[]) : []
132
133 await update($, thoughts, () => list)
134 await update($, shelfKey, () => key)
135 await update($, loadedSession, () => sessionId)
136
137 return { key, list }
138}
139
140/** The current conversation's stash, from the saved record; loaded first when the session id just changed. */
141const current = async ($: EngineInterface) => {
142 const key = await read($, shelfKey)
143
144 if (key === null || (await read($, loadedSession)) !== (await $.session.id())) {
145 return { isSwitched: true, ...(await load($)) }
146 }
147
148 // Read afresh: another process of the same conversation may have used or
149 // deleted thoughts since this one last drew them.
150 const saved = await $.store.get(key)
151 const list = Array.isArray(saved) ? (saved as Thought[]) : []
152
153 await update($, thoughts, () => list)
154
155 return { isSwitched: false, key, list }
156}
157
158const shelve = async ($: EngineInterface, text: string) => {
159 const { key } = await current($)
160 const sessionId = await $.session.id()
161 const caughtAt = await $.clock.now()
162 const cwd = await $.session.cwd()
163
164 await commit($, key, list => [
165 ...list,
166 {
167 id: `${caughtAt}-${list.length}-${sessionId.slice(0, 8)}`,
168 text,
169 caughtAt,
170 sessionId,
171 cwd,
172 scope: 'session',
173 status: 'held',
174 releasedAt: null,
175 releasedVia: null,
176 },
177 ])
178}
179
180/** Puts a thought in the prompt box and takes it off the shelf; false when the box refused it. */
181const releaseIntoBox = async ($: EngineInterface, id: string) => {
182 const { key, list: all } = await current($)
183 const thought = all.find(one => one.id === id && one.status === 'held')
184
185 if (!thought) {
186 $.ui.toast('That thought already left the stash.')
187
188 return false
189 }
190
191 // A draft already in the box is kept; the thought lands on a new line after it.
192 const box = await $.prompt.read()
193 const filled =
194 thoughtIn(box.text) === '' || box.text.trim() === ''
195 ? await $.prompt.fill({ text: thought.text, mode: 'replace' })
196 : await $.prompt.fill({ text: `\n${thought.text}`, mode: 'append' })
197
198 if (!filled.isFilled) {
199 $.ui.toast(`Not placed (${filled.refusal ?? 'another mod kept it out'}) · the thought stays in the stash.`)
200
201 return false
202 }
203
204 const releasedAt = await $.clock.now()
205
206 await commit($, key, list =>
207 list.map((one): Thought =>
208 one.id === id ? { ...one, status: 'released', releasedAt, releasedVia: 'fill' } : one,
209 ),
210 )
211
212 return true
213}
214
215// From Claude Code 2.1.295 a dropped prompt's text comes back into the box
216// after the hook answered, and a pane asked for while the box holds text is
217// placed without the keyboard. Once Enter has been answered this empties the
218// box and, when a pane is named, asks for it again so it takes the keyboard.
219// A no-op on versions that emptied the box themselves.
220const clearBox = ($: EngineInterface, reopen?: PaneOpenArgs) => {
221 $.clock.after(0, async () => {
222 try {
223 await $.prompt.fill({ text: '', mode: 'replace' })
224
225 if (reopen) {
226 await $.ui.open(reopen)
227 }
228 } catch (error) {
229 $.ui.log(`the box was not emptied: ${reasonOf(error)}`, { to: 'debug' })
230 }
231 })
232}
233
234/** The text of a transcript row's text blocks, joined. */
235const textOf = (content: readonly { type: string; text?: string }[]) =>
236 content.map(block => (block.type === 'text' ? (block.text ?? '') : '')).join('')
237
238// After `c`, `d` or `u` the highlight goes back to the thought it was on, so
239// the arrows continue from there.
240const highlight = async ($: EngineInterface, id: string | null) => {
241 if (id === null) {
242 return
243 }
244
245 try {
246 await $.ui.focus({ requestId: LIST, key: `${ROW_PREFIX}${id}` })
247 } catch (error) {
248 $.ui.log(`the highlight was not moved: ${reasonOf(error)}`, { to: 'debug' })
249 }
250}
251
252const pickedThought = async ($: EngineInterface, all: Thought[]) => {
253 const id = await read($, picked)
254 const thought = heldOf(all).find(one => one.id === id)
255
256 if (!thought) {
257 $.ui.toast('Move to a thought with the arrows first.')
258 }
259
260 return thought
261}
262
263const useThought = async ($: EngineInterface, id: string) => {
264 if (await releaseIntoBox($, id)) {
265 await $.ui.close({ id: LIST })
266 }
267}
268
269const COPY_REFUSALS: Record<string, string> = {
270 'no-clipboard': 'this terminal offers no clipboard',
271 'no-surface': 'nothing is drawing this session',
272}
273
274// Copying leaves the thought on the shelf: it may be wanted here as well as
275// wherever it is pasted. Delete is the way off.
276const copyPicked = async ($: EngineInterface, press: UiPressArgument) => {
277 const thought = await pickedThought($, (await current($)).list)
278
279 if (!thought) {
280 return
281 }
282
283 const copied = await $.ui.copy({ text: thought.text, surface: press.surface })
284
285 $.ui.toast(
286 copied.isCopied
287 ? `Copied "${clip(thought.text, 30)}" · it stays in the stash`
288 : `Not copied: ${COPY_REFUSALS[copied.reason] ?? copied.reason}.`,
289 )
290 await highlight($, thought.id)
291}
292
293// Delete asks first, on the thought itself: the first press shows "delete?"
294// on its row, a second press confirms, and the question withdraws after a few
295// seconds or when the highlight moves to another thought.
296const deletePicked = async ($: EngineInterface) => {
297 const { key, list: before } = await current($)
298 const thought = await pickedThought($, before)
299
300 if (!thought) {
301 return
302 }
303
304 if ((await read($, asking))?.id !== thought.id) {
305 const question = { id: thought.id, at: await $.clock.now() }
306
307 await update($, asking, () => question)
308 // Only the timer of this very question withdraws it: a question asked
309 // again on the same thought has its own.
310 $.clock.after(ASK_MS, async () => {
311 try {
312 const asked = await read($, asking)
313
314 if (asked?.id === question.id && asked.at === question.at) {
315 await update($, asking, () => null)
316 }
317 } catch (error) {
318 $.ui.log(`the delete question was not withdrawn: ${reasonOf(error)}`, { to: 'debug' })
319 }
320 })
321 await highlight($, thought.id)
322
323 return
324 }
325
326 const list = heldOf(before)
327 const index = list.findIndex(one => one.id === thought.id)
328 const neighbour = list[index + 1] ?? list[index - 1] ?? null
329 const discardedAt = await $.clock.now()
330
331 await commit($, key, all =>
332 all.map((one): Thought => (one.id === thought.id ? { ...one, status: 'discarded', discardedAt } : one)),
333 )
334 await update($, asking, () => null)
335 await update($, lastDiscarded, () => thought.id)
336 await update($, picked, () => neighbour?.id ?? null)
337 $.ui.toast(`Deleted "${clip(thought.text, 30)}" · u brings it back`)
338 await highlight($, neighbour?.id ?? null)
339}
340
341const undoDelete = async ($: EngineInterface) => {
342 const id = await read($, lastDiscarded)
343 const { key, list: before } = await current($)
344 const thought = before.find(one => one.id === id && one.status === 'discarded')
345
346 if (!thought) {
347 return
348 }
349
350 await commit($, key, all =>
351 all.map((one): Thought => {
352 if (one.id !== thought.id) {
353 return one
354 }
355
356 const { discardedAt: _deletedAt, ...kept } = one
357
358 return { ...kept, status: 'held' }
359 }),
360 )
361 await update($, lastDiscarded, () => null)
362 await update($, picked, () => thought.id)
363 $.ui.toast(`Brought back "${clip(thought.text, 40)}".`)
364 await highlight($, thought.id)
365}
366
367// A press that fails says why where the person pressed, and the same line
368// goes to the log. The engine leads every toast and log line with the mod's name.
369const guard =
370 ($: EngineInterface, action: string, run: (press: UiPressArgument) => Promise<unknown>) =>
371 async (press: UiPressArgument) => {
372 try {
373 await run(press)
374 } catch (error) {
375 const line = `${action} failed: ${reasonOf(error)}`
376 $.ui.log(line)
377 $.ui.toast(line)
378 }
379 }
380
381export const register: Register = on => {
382 on('session.start', async ($, e, next) => {
383 try {
384 // $.state outlives a hot reload, which raises session.start again: the
385 // shelf is loaded only when it is not this conversation's already.
386 await current($)
387 } catch (error) {
388 $.ui.log(`the saved stash was not loaded: ${reasonOf(error)}`)
389 }
390
391 return next(e)
392 })
393
394 // Tells the band what the prompt box holds, and paints the marker in the
395 // box itself the moment it is recognised.
396 on('prompt.edit', async ($, e, next) => {
397 const box = await next(e)
398
399 try {
400 const kind = kindOf(box.text, box.cursor)
401 await setDraft($, kind)
402
403 if (kind === 'marker' || kind === 'thought') {
404 const start = box.text.indexOf(MARKER)
405
406 return {
407 ...box,
408 decorations: [
409 ...(box.decorations ?? []),
410 { start, end: start + MARKER.length, color: 'remember', bold: true },
411 ],
412 }
413 }
414 } catch (error) {
415 $.ui.log(`the draft was not read: ${reasonOf(error)}`)
416 }
417
418 return box
419 })
420
421 on('prompt.submit', async ($, e, next) => {
422 // Only what the person typed is a capture; a prompt from a schedule, a
423 // peer session or another mod passes through whatever it starts with.
424 const thought = e.origin.kind === 'composer' ? thoughtIn(e.text) : null
425
426 if (thought === null) {
427 const entered = await next(e)
428
429 if (entered.drop === undefined && e.origin.kind === 'composer') {
430 try {
431 // The box is empty once the prompt entered.
432 await setDraft($, 'plain')
433 } catch (error) {
434 $.ui.log(`the draft kind was not reset: ${reasonOf(error)}`)
435 }
436 }
437
438 return entered
439 }
440
441 if (thought === '') {
442 // Asked for before any await, while Enter is still being answered: the
443 // engine then seats the list at any width and hands it the keyboard.
444 const opening = $.ui.open(LIST_PANE)
445
446 try {
447 await setDraft($, 'plain')
448 // The list opens with its newest thought highlighted and no question
449 // or undo left over from the last time it was open.
450 const [newest] = heldOf((await current($)).list)
451 await update($, picked, () => newest?.id ?? null)
452 await update($, asking, () => null)
453 await update($, lastDiscarded, () => null)
454 } catch (error) {
455 $.ui.log(`the list was not prepared: ${reasonOf(error)}`)
456 }
457
458 // The notice is worded once the list's fate is known. The box is emptied
459 // only then: a timer set earlier fires during this wait, before the
460 // engine puts the dropped text back.
461 try {
462 const opened = await opening
463
464 clearBox($, opened.isPlaced ? LIST_PANE : undefined)
465
466 return {
467 drop: opened.isPlaced ? NOTICE.opened : `${NOTICE.unopened} (${opened.reason})`,
468 }
469 } catch (error) {
470 $.ui.log(`the list did not open: ${reasonOf(error)}`)
471 clearBox($)
472
473 return { drop: `${NOTICE.unopened} (${reasonOf(error)})` }
474 }
475 }
476
477 try {
478 await setDraft($, 'plain')
479 await shelve($, thought)
480 clearBox($)
481
482 // The engine shows this after its own "Prompt dropped by a hook:". The
483 // thought's text stays out of it, so the notice says nothing of what was shelved.
484 return { drop: NOTICE.stashed }
485 } catch (error) {
486 // Decided 2026-10-06: when the shelf fails, the prompt reaches Claude as
487 // it would without the mod, and the person is told why.
488 const line = `Stashing failed, so the text went to Claude: ${reasonOf(error)}`
489 $.ui.log(line)
490 $.ui.toast(line)
491
492 return next(e)
493 }
494 })
495
496 // Follows the highlight as the arrows move it over the list, so `c` and
497 // `d` know which thought they act on.
498 on('ui.focus', async ($, e, next) => {
499 const moved = await next(e)
500
501 if (e.requestId === LIST && e.element?.startsWith(ROW_PREFIX)) {
502 try {
503 const id = e.element.slice(ROW_PREFIX.length)
504
505 if ((await read($, picked)) !== id) {
506 await update($, picked, () => id)
507 }
508
509 // The question belongs to the thought it was asked on.
510 const asked = await read($, asking)
511
512 if (asked !== null && asked.id !== id) {
513 await update($, asking, () => null)
514 }
515 } catch (error) {
516 $.ui.log(`the highlight was not followed: ${reasonOf(error)}`, { to: 'debug' })
517 }
518 }
519
520 return moved
521 })
522
523 on('session.append', { door: 'notice' }, async ($, e, next) => {
524 const text = textOf(e.message.content)
525 const start = [NOTICE.stashed, NOTICE.opened, NOTICE.unopened]
526 .map(words => text.indexOf(words))
527 .find(at => at > 0)
528
529 if (start !== undefined) {
530 return next({ ...e, message: { ...e.message, content: [{ type: 'text', text: text.slice(start) }] } })
531 }
532
533 return next(e)
534 })
535
536 on('ui.render', { component: 'Pane', requestId: LIST }, async ($, e) => {
537 const { Box, Button, Text } = $.ui.resolve(e)
538 const columns = e.props.bodyColumns
539 const list = heldOf(await read($, thoughts))
540 const asked = (await read($, asking))?.id ?? null
541 const undoId = await read($, lastDiscarded)
542 const now = await $.clock.now()
543
544 // The person asked to look here, so an empty shelf says so in words.
545 if (list.length === 0 && undoId === null) {
546 return <Text dimColor>{`nothing in the stash · type ${MARKER} and a thought, then enter`}</Text>
547 }
548
549 // "1: " 3 · padded label 2 · gap 1 · age or "delete?" 7
550 const textRoom = Math.max(12, columns - 13)
551 const textWidth = Math.min(textRoom, Math.max(0, ...list.map(one => clip(one.text, textRoom).length)))
552
553 return (
554 <Box flexDirection="column">
555 {list.map((one, index) => {
556 const hotkey = ROW_KEYS[index]
557
558 return (
559 <Box>
560 <Box width={textWidth + 6} flexShrink={0}>
561 <Button
562 key={`${ROW_PREFIX}${one.id}`}
563 {...(hotkey ? { hotkey } : {})}
564 {...(index === 0 ? { autoFocus: true } : {})}
565 plain
566 label={` ${clip(one.text, textWidth)} `}
567 onPress={guard($, 'bringing the thought back', () => useThought($, one.id))}
568 />
569 </Box>
570 {one.id === asked ? (
571 <Text color="error" bold>
572 delete?
573 </Text>
574 ) : (
575 <Text dimColor>{age(now - one.caughtAt) ?? ''}</Text>
576 )}
577 </Box>
578 )
579 })}
580 <Box>
581 {list.length > 0 ? (
582 <Button
583 key="copy"
584 hotkey="c"
585 plain
586 label=" copy "
587 onPress={guard($, 'copying the thought', press => copyPicked($, press))}
588 />
589 ) : null}
590 {list.length > 0 ? (
591 <Button
592 key="delete"
593 hotkey="d"
594 plain
595 label={asked === null ? ' delete ' : ' delete? '}
596 onPress={guard($, 'deleting the thought', () => deletePicked($))}
597 />
598 ) : null}
599 {undoId !== null ? (
600 <Button
601 key="undo"
602 hotkey="u"
603 plain
604 label=" undo delete "
605 onPress={guard($, 'bringing the thought back', () => undoDelete($))}
606 />
607 ) : null}
608 <Text dimColor>{list.length > 0 ? ' number or enter: use · esc: close' : ' esc: close'}</Text>
609 </Box>
610 </Box>
611 )
612 })
613
614 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
615 if (e.props.hasSurvey) {
616 return next(e)
617 }
618
619 // /clear and /resume change the session without raising session.start,
620 // and a render may not write state: the reload runs right after this draw.
621 if ((await read($, shelfKey)) === null || (await read($, loadedSession)) !== (await $.session.id())) {
622 $.clock.after(0, async () => {
623 try {
624 await load($)
625 } catch (error) {
626 $.ui.log(`the saved stash was not loaded: ${reasonOf(error)}`)
627 }
628 })
629
630 return next(e)
631 }
632
633 const kind = await read($, draft)
634 const held = heldOf(await read($, thoughts))
635 const [newest] = held
636
637 // Nothing held and nothing being shelved: the band takes no row at all.
638 if (kind === 'picker' || (!newest && kind === 'plain')) {
639 return next(e)
640 }
641
642 const { Box, Text } = $.ui.resolve(e)
643 // Two cells in, where the engine starts the lines under the prompt.
644 const columns = e.props.bodyColumns - 2
645 let row
646
647 if (kind === 'thought') {
648 // While a marked draft is in the box, the row is the promise of what
649 // Enter will do with it.
650 row = (
651 <Box gap={1}>
652 <Text color="remember" bold>{`${TAG} enter stashes this thought`}</Text>
653 <Text dimColor>· kept until you bring it back</Text>
654 </Box>
655 )
656 } else if (kind === 'marker') {
657 row = (
658 <Box gap={1}>
659 <Text color="remember" bold>
660 {newest ? `${TAG} enter opens the stash` : `${TAG} type the thought, then enter stashes it`}
661 </Text>
662 {newest ? <Text dimColor>· or type a thought to stash it</Text> : null}
663 </Box>
664 )
665 } else if (newest) {
666 // Decided 2026-10-06: this row only shows; the list opened by the marker
667 // and Enter is where a thought is used, copied or deleted.
668 const aged = age((await $.clock.now()) - newest.caughtAt)
669 const more = held.length > 1 ? `+${held.length - 1} more` : null
670 const hint = columns >= 60 ? HINT : null
671 // tag "● 99" 4 · gaps 6 · " · 12m" 6 · "+9 more" 7 · hint and its gap
672 const room = Math.max(12, columns - 16 - (more ? 9 : 0) - (hint ? hint.length + 2 : 0))
673
674 row = (
675 <Box width={columns} justifyContent="space-between">
676 <Box gap={2}>
677 <Text color="remember" bold>{`${TAG} ${held.length}`}</Text>
678 <Box gap={1}>
679 <Text>{clip(newest.text, room)}</Text>
680 {aged ? <Text dimColor>{`· ${aged}`}</Text> : null}
681 </Box>
682 {more ? <Text dimColor>{more}</Text> : null}
683 </Box>
684 {hint ? <Text dimColor>{hint}</Text> : null}
685 </Box>
686 )
687 }
688
689 // Other mods draw in this band too: this row stacks over whatever the
690 // rest of the chain drew instead of replacing it.
691 return (
692 <Box flexDirection="column">
693 <Box paddingLeft={2}>{row}</Box>
694 {await next(e)}
695 </Box>
696 )
697 })
698}
699types/index.d.ts 53 lines1export type Thought = {
2 id: string
3 text: string
4 /** Milliseconds since the epoch, when the thought was shelved. */
5 caughtAt: number
6 sessionId: string
7 cwd: string
8 /**
9 * Decided 2026-10-06: every thought is shelved per session for now, where a
10 * session is the conversation (it keeps its shelf when Claude Code continues
11 * it under a new session id). Kept on each record so a later upgrade can let
12 * the person pick session, project or general at shelving time without
13 * rewriting stored thoughts.
14 */
15 scope: 'session'
16 /**
17 * A released or deleted thought leaves the shelf and stays in the saved
18 * list, hidden, so usage stats can be computed from it later and a deleted
19 * one can be brought back.
20 */
21 status: 'held' | 'released' | 'discarded'
22 releasedAt: number | null
23 releasedVia: 'fill' | null
24 /** When the person deleted it; absent on a thought that is not deleted. */
25 discardedAt?: number
26}
27
28/**
29 * What the prompt box holds, as far as the band cares: an ordinary draft, a
30 * `/` or `@` picker being used, the capture marker alone (Enter opens the
31 * list), or the marker with a thought after it (Enter shelves that one).
32 */
33export type DraftKind = 'plain' | 'picker' | 'marker' | 'thought'
34
35declare module 'claude-code' {
36 interface PluginState {
37 'prompt-stash': {
38 thoughts: Thought[]
39 /** The session id `thoughts` was loaded under; a change of it triggers a reload. */
40 sessionId: string | null
41 /** The store key of the conversation whose shelf `thoughts` holds; null before the first load. */
42 shelfKey: string | null
43 draft: DraftKind
44 /** The thought under the highlight in the list: what copy and delete act on. */
45 picked: string | null
46 /** The thought whose "delete?" question is showing, and when it was asked. */
47 asking: { id: string; at: number } | null
48 /** The thought deleted last while the list was open: what undo brings back. */
49 lastDiscarded: string | null
50 }
51 }
52}
53