SLOPSHOPPER

prompt-stash

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

newpanebandtoastprompttimer
v0.1.1MITupdated 2026-10-09samaphp/prompt-stash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-stash
│ ┃ shelf-list ✕ › fix the failing auth test and add an audit log call │ ┃ nothing in the stash · type ;; and a │ ┃ thought, then enter ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · shelf-list
nothing in the stash · type ;; and a thought, then enter
README

prompt-stash

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.

Install

/plugin marketplace add samaphp/prompt-stash
/plugin install prompt-stash@prompt-stash

Use

You doWhat happens
Type ;; and a thought, press EnterThe 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 EnterThe list opens and takes the keyboard.
In the list: a number, or arrows and EnterThat 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: cCopies the highlighted thought to the clipboard. It stays in the stash.
In the list: d, then d againDeletes 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.
EscCloses 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

What to know

  • One stash per conversation. It comes back when the conversation is resumed, and stays when Claude Code continues the conversation under a new session id (the agent window, a fork). /clear starts a new conversation with an empty stash.
  • Stashed text never reaches Claude. The prompt is dropped before it enters the conversation; Claude Code prints one line saying so. If stashing itself fails, the text goes to Claude as an ordinary prompt and a toast tells you why.
  • Deleted and used thoughts stay on record, hidden, in the plugin's own store.
  • A pasted snippet that starts with ;; (a Lisp comment, say) is stashed instead of sent; the line above the prompt warns first, and the list brings it back.

Development

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.

License

MIT

Source 2 files
hooks/register.tsx 699 lines
1import { 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}
699
types/index.d.ts 53 lines
1export 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