SLOPSHOPPER

prompt-drafts

Keep prompts aside as drafts of the session. End a prompt with ;; to save it instead of sending it, then /drafts to put one back in the prompt box.

newpanebandcommandtoastprompt
v0.1.0MITupdated 2026-10-07vynnlee/mods/prompt-drafts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-drafts
│ ┃ prompt-drafts ✕ › fix the failing auth test and add an audit log call │ ┃ [ Save prompt box ] │ ┃ Click a draft or press its number to put it ⏺ Read(src/auth.ts) │ ┃ in the prompt box. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No drafts in this session. End a prompt with ⎿ Added 2 lines, removed 1 line │ ┃ ;; and press Enter to keep it aside. ⏺ 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 │ │ › /draft │ ⎿ prompt-drafts: Usage: /draft <text>. Or end the prompt you are w │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompt-drafts
[ Save prompt box ] Click a draft or press its number to put it in the prompt box. No drafts in this session. End a prompt with ;; and press Enter to keep it aside.
README

prompt-drafts

check Claude Code 2.1.287+ License: MIT

A Claude Code mod that keeps prompts aside as drafts and puts them back in the prompt box when you need them.

End a prompt with ;; and press Enter. It is saved, not sent. Type /drafts and press a number to bring it back.

prompt-drafts: save two prompts with ;;, open /drafts, press 2 to bring one back

Install

Requires Claude Code 2.1.287 or later. Mods are on by default.

claude plugin marketplace add vynnlee/mods
claude plugin install prompt-drafts@vynnlee

Or in a Claude Code session:

/plugin install prompt-drafts --marketplace vynnlee/mods

To try it for one session without installing:

git clone https://github.com/vynnlee/mods
claude --plugin-dir ./mods/prompt-drafts

Usage

ToDo
Save the prompt you are writingEnd it with ;; and press Enter. It is not sent.
Save some text/draft <text>
Put a draft back in the prompt box/drafts, then press its number. Or /drafts <n>
Save what is in the prompt boxSave prompt box in the sidebar
Delete a draft× in the sidebar, or /drafts rm <n>
Bring back the draft you just deletedUndo in the sidebar, or /drafts undo

What /drafts shows

  • 110 columns or wider: a sidebar with every draft and a numbered row above the prompt box. Press a number in the empty prompt box to use that draft, or 0 to close. You can also click a draft in the sidebar.
  • Narrower (a phone, a split pane): Claude Code's own question dialog. Use the arrow keys or a number, then Enter. It lists four drafts at a time, and the last option shows the next page.
  • No prompt box (headless runs): a plain numbered list.

How drafts are kept

  • Drafts belong to the session they were saved in. Other sessions do not see them, and resuming the session brings them back.
  • Newest first, up to 50 per session. Saving the same text again moves it to the top.
  • If the prompt box already holds other text when you put a draft in, that text is saved as a draft first.
  • The sidebar shows the first three lines of each draft.
  • Drafts of a session with no new draft for 30 days are removed.

Settings

/plugin configure prompt-drafts@vynnlee
SettingDefaultValues
languageenen, ko (Korean)

Update

claude plugin update prompt-drafts@vynnlee

Third party marketplaces do not update on their own unless you turn on auto update in /plugin under Marketplaces.

What it touches

claude plugin validate . reports every hook and call. In short:

Hookssession.start, prompt.submit, command.run (draft, drafts), ui.close, ui.render (AbovePrompt, Pane)
Prompt box$.prompt.read, $.prompt.fill
Storage$.store.get, $.store.set, $.store.delete, $.store.keys (the plugin's own store on your machine)
Session$.session.id (which session a draft belongs to), $.session.surface
Interface$.command.register, $.ui.ask, $.ui.open, $.ui.close, $.ui.panes, $.ui.toast, $.ui.resolve, $.state.get, $.state.set, $.clock.now
  • No network requests, no processes, no files outside the plugin store.
  • Only prompts you type are checked for ;;. Prompts submitted by other plugins or sessions pass through unchanged.

Development

cd prompt-drafts
claude plugin validate . --strict
claude plugin test .
claude --plugin-dir .

The demo GIF is recorded with VHS from demo/prompt-drafts.tape. demo/run.sh starts Claude Code in a throwaway config with only this mod loaded, so no login or model call is needed.

The pure logic (titles, previews, paging, messages) is in hooks/drafts.ts and tested in hooks/drafts.test.ts. The hooks are in hooks/register.tsx. Every release raises version in .claude-plugin/plugin.json, since installed copies only update when it changes. Changes are listed in CHANGELOG.md.

License

MIT

Source 3 files
hooks/register.tsx 288 lines
1// prompt-drafts: keep prompts aside as drafts of this session and bring them back later.
2//   Save:  end a prompt with ";;" and press Enter: it is saved, not sent. Or "/draft <text>".
3//   Use:   "/drafts". On a wide terminal, a sidebar (click a draft, × deletes, Save prompt box) and a row of
4//          numbered buttons above the prompt box: a bare digit in an empty prompt box presses one, 0 closes.
5//          On a narrow one, Claude Code's own question dialog, a page at a time.
6//          "/drafts <n>" puts draft n in the prompt box, "/drafts rm <n>" deletes it, "/drafts undo" brings it back.
7// Each session has its own drafts (resuming the session brings them back); other sessions never see them.
8// They live in this plugin's store, newest first, 50 per session; a session idle for 30 days is dropped.
9// Putting a draft in a prompt box that already holds text saves that text as a draft first, so nothing is lost.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12import type { Deleted, Draft } from '../types'
13import { add, ago, askPage, KEEP_MS, listText, MARKER, marked, preview, restore, text, title, type Lang } from './drafts'
14
15const PANE = 'prompt-drafts'
16const SHOWN = 9
17// Below this many columns there is no room for the sidebar: drafts are picked in Claude Code's own question dialog.
18const WIDE = 110
19const drafts = atom({ plugin: 'prompt-drafts', key: 'drafts' } as const, [])
20const isPicking = atom({ plugin: 'prompt-drafts', key: 'isPicking' } as const, false)
21const deleted = atom({ plugin: 'prompt-drafts', key: 'deleted' } as const, null)
22
23let lang: Lang = 'en'
24let storeKey = ''
25
26async function key($: EngineInterface) {
27  if (!storeKey) storeKey = `session:${await $.session.id()}`
28  return storeKey
29}
30
31async function load($: EngineInterface) {
32  const list = ((await $.store.get(await key($))) as Draft[] | undefined) ?? []
33  await update($, drafts, () => list)
34  return list
35}
36
37async function store($: EngineInterface, list: Draft[]) {
38  if (list.length) await $.store.set(await key($), list)
39  else await $.store.delete(await key($))
40  await update($, drafts, () => list)
41}
42
43async function save($: EngineInterface, body: string) {
44  const now = await $.clock.now()
45  const list = add(await load($), { id: String(now), title: title(body), text: body, savedAt: now })
46  await store($, list)
47  return list
48}
49
50async function remove($: EngineInterface, d: Draft) {
51  const list = await load($)
52  const index = list.findIndex(x => x.id === d.id)
53  if (index < 0) return
54  await store($, list.filter(x => x.id !== d.id))
55  await update($, deleted, (): Deleted => ({ draft: d, index }))
56}
57
58async function undo($: EngineInterface) {
59  const last = await read($, deleted)
60  if (!last) return null
61  await store($, restore(await load($), last.draft, last.index))
62  await update($, deleted, () => null)
63  return last.draft
64}
65
66// Drop the drafts of sessions idle for 30 days.
67async function tidy($: EngineInterface) {
68  const now = await $.clock.now()
69  const mine = await key($)
70  for (const k of await $.store.keys()) {
71    if (!k.startsWith('session:') || k === mine) continue
72    const list = ((await $.store.get(k)) as Draft[] | undefined) ?? []
73    if (!list.some(d => now - d.savedAt < KEEP_MS)) await $.store.delete(k)
74  }
75}
76
77// Put a draft in the prompt box, saving any other text that was there as a draft first.
78async function use($: EngineInterface, d: Draft) {
79  const box = await $.prompt.read()
80  if (box.text.trim() && box.text.trim() !== d.text.trim() && marked(box.text) === null) {
81    await save($, box.text)
82    $.ui.toast(text[lang].stashed)
83  }
84  const done = await $.prompt.fill({ text: d.text, mode: 'replace' })
85  return done.isFilled
86}
87
88// Picking puts the draft in the prompt box and closes both the sidebar and the number row.
89async function pick($: EngineInterface, d: Draft) {
90  await closePicker($)
91  await use($, d)
92}
93
94async function closePicker($: EngineInterface) {
95  await update($, isPicking, () => false)
96  await update($, deleted, () => null)
97  if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
98}
99
100async function saveBox($: EngineInterface) {
101  const { text: body } = await $.prompt.read()
102  if (!body.trim()) return $.ui.toast(text[lang].empty)
103  const list = await save($, body)
104  await $.prompt.fill({ text: '', mode: 'replace' })
105  $.ui.toast(text[lang].saved(list.length, title(body)))
106}
107
108async function undoFromPane($: EngineInterface) {
109  const d = await undo($)
110  if (d) $.ui.toast(text[lang].restored(d.title))
111}
112
113// Narrow terminals: Claude Code's own question dialog, a page at a time (it takes 2-4 options).
114async function askPick($: EngineInterface, list: Draft[]) {
115  const t = text[lang]
116  let start = 0
117  for (;;) {
118    const { page, labels, hasMore, next } = askPage(list, start)
119    const more = t.more(Math.min(3, list.length - next))
120    let answer: string
121    try {
122      answer = await $.ui.ask(t.ask, { header: t.picker, options: hasMore ? [...labels, more] : labels.length > 1 ? labels : [...labels, t.close] })
123    } catch {
124      return null // dismissed
125    }
126    if (hasMore && answer === more) { start = next; continue }
127    const i = labels.indexOf(answer)
128    return i >= 0 ? page[i]! : null
129  }
130}
131
132// The sidebar shows when it has room; the number row above the prompt is what the keys reach either way.
133async function paneShown($: EngineInterface) {
134  return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
135}
136
137export const register: Register = (on, options) => {
138  lang = options.language === 'ko' ? 'ko' : 'en'
139  const t = text[lang]
140
141  on('session.start', async ($, e, next) => {
142    storeKey = ''
143    await $.command.register({ name: 'draft', description: t.cmdDraft, argumentHint: '<text>' })
144    await $.command.register({ name: 'drafts', description: t.cmdDrafts, argumentHint: '[n | rm n | undo]' })
145    await load($)
146    void tidy($)
147    return next(e)
148  })
149
150  // A typed prompt ending with ";;" is saved and not sent. Any prompt also closes the list.
151  on('prompt.submit', async ($, e, next) => {
152    if (e.origin && e.origin.kind !== 'composer') return next(e)
153    await update($, isPicking, () => false)
154    const body = marked(e.text)
155    if (body === null) return next(e)
156    if (!body) return { drop: t.empty }
157    const list = await save($, body)
158    return { drop: t.saved(list.length, title(body)) }
159  }).catch(($, e, next) => (next.called ? next(e) : { drop: t.failed }))
160
161  on('command.run', { command: 'draft' }, async ($, e) => {
162    const body = e.args.trim()
163    if (!body) return { text: t.draftUsage }
164    const list = await save($, body)
165    return { text: t.saved(list.length, title(body)) }
166  })
167
168  on('command.run', { command: 'drafts' }, async ($, e) => {
169    const list = await load($)
170    const args = e.args.trim()
171    const nth = (s: string) => list[Number(s) - 1]
172    if (!args) {
173      const drawn = (await $.session.surface()) !== null
174      if (!list.length || !drawn) return { text: listText(list, await $.clock.now(), lang) }
175      if (e.presentation.columns < WIDE) {
176        const d = await askPick($, list)
177        if (!d) return { text: t.closed }
178        return { text: (await use($, d)) ? t.loaded(d.title) : t.noBox }
179      }
180      await $.ui.open({ id: PANE, title: t.picker })
181      await update($, isPicking, () => true)
182      return { text: (await paneShown($)) ? t.openedWide : t.openedNarrow }
183    }
184    if (args === 'undo') {
185      const d = await undo($)
186      return { text: d ? t.restored(d.title) : t.noUndo }
187    }
188    const rm = args.match(/^rm\s+(\d+)$/)
189    if (rm) {
190      const d = nth(rm[1]!)
191      if (!d) return { text: t.missing(rm[1]!) }
192      await remove($, d)
193      return { text: t.removed(d.title) }
194    }
195    if (/^\d+$/.test(args)) {
196      const d = nth(args)
197      if (!d) return { text: t.missing(args) }
198      return { text: (await use($, d)) ? t.loaded(d.title) : t.noBox }
199    }
200    return { text: t.usage }
201  })
202
203  // Closing the sidebar (its ×, or Escape) closes the number row too.
204  on('ui.close', { id: PANE }, async ($, e, next) => {
205    await update($, isPicking, () => false)
206    await update($, deleted, () => null)
207    return next(e)
208  })
209
210  // Above the prompt: a bare digit in an empty prompt box presses one of these buttons. With the sidebar shown it is
211  // one row of numbers; without it it is the whole list.
212  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213    if (e.props.hasSurvey || !(await read($, isPicking))) return next(e)
214    const list = (await read($, drafts)).slice(0, SHOWN)
215    if (!list.length) return next(e)
216    const { Box, Text, Button } = $.ui.resolve(e)
217    if (await paneShown($)) {
218      return (
219        <Box>
220          <Text bold>{t.picker}</Text>
221          <Text dimColor>  {t.keysHint}  </Text>
222          {list.map((d, i) => (
223            <Button key={`use-${d.id}`} label={`${d.title.slice(0, 12)}${d.title.length > 12 ? '…' : ''}  `} hotkey={String(i + 1)} plain onPress={() => pick($, d)} />
224          ))}
225          <Button key="close" label={t.close} hotkey="0" plain onPress={() => closePicker($)} />
226        </Box>
227      )
228    }
229    const now = await $.clock.now()
230    return (
231      <Box flexDirection="column">
232        <Box>
233          <Text bold>{t.picker}</Text>
234          <Text dimColor>  {t.pickHint}</Text>
235        </Box>
236        {list.map((d, i) => (
237          <Box key={d.id}>
238            <Button key={`use-${d.id}`} label={d.title} hotkey={String(i + 1)} plain onPress={() => pick($, d)} />
239            <Text dimColor>   {t.meta(ago(now, d.savedAt, lang), d.text.length)}</Text>
240          </Box>
241        ))}
242        <Button key="close" label={t.close} hotkey="0" plain onPress={() => closePicker($)} />
243      </Box>
244    )
245  })
246
247  // The sidebar: this session's drafts with up to three lines each, click to use, × to delete, undo the last delete.
248  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249    const { Box, Text, Button } = $.ui.resolve(e)
250    const list = await read($, drafts)
251    const gone = await read($, deleted)
252    const now = await $.clock.now()
253    const width = Math.max(20, (e.viewport?.columns ?? 60) - 6)
254    let rows = Math.max(4, (e.viewport?.rows ?? 24) - (gone ? 6 : 4))
255    const shown: { d: Draft; i: number; more: string[] }[] = []
256    for (const [i, d] of list.entries()) {
257      const more = preview(d.text, width)
258      if (rows < more.length + 2) break
259      rows -= more.length + 2
260      shown.push({ d, i, more })
261    }
262    return (
263      <Box flexDirection="column">
264        <Button key="save" label={t.saveButton} onPress={() => saveBox($)} />
265        <Text dimColor>{t.paneHint}</Text>
266        {gone && (
267          <Box>
268            <Text dimColor>{t.deletedLine(gone.draft.title.slice(0, 24))}  </Text>
269            <Button key="undo" label={t.undoButton} plain onPress={() => undoFromPane($)} />
270          </Box>
271        )}
272        <Text> </Text>
273        {list.length === 0 && <Text dimColor>{t.none}</Text>}
274        {shown.map(({ d, i, more }) => (
275          <Box flexDirection="column" key={d.id}>
276            <Button key={`pane-${d.id}`} label={`${i + 1}  ${d.title}`} plain onPress={() => pick($, d)} />
277            {more.map(line => <Text dimColor>   {line}</Text>)}
278            <Box>
279              <Text dimColor>   {t.meta(ago(now, d.savedAt, lang), d.text.length)}  </Text>
280              <Button key={`rm-${d.id}`} label="×" plain onPress={() => remove($, d)} />
281            </Box>
282          </Box>
283        ))}
284      </Box>
285    )
286  })
287}
288
hooks/drafts.ts 136 lines
1// The pure part of prompt-drafts: the save marker, titles and previews, the list, and every message in two languages.
2import type { Draft } from '../types'
3
4export const MAX = 50
5// A prompt that ends with this is saved as a draft instead of sent.
6export const MARKER = ';;'
7// A session's drafts are dropped after this long without a new one.
8export const KEEP_MS = 30 * 24 * 3600 * 1000
9export type Lang = 'en' | 'ko'
10
11// The text before the marker when the prompt ends with it, else null.
12export function marked(text: string) {
13  const t = text.trimEnd()
14  if (!t.endsWith(MARKER)) return null
15  return t.slice(0, -MARKER.length).trimEnd()
16}
17
18const lines = (text: string) => text.split('\n').map(l => l.trim()).filter(Boolean)
19const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
20
21export function title(text: string) {
22  return cut(lines(text)[0] ?? '', 48)
23}
24
25// Up to two more lines under the title, the second ending in … when the draft goes on.
26export function preview(text: string, width = 60) {
27  const rest = lines(text).slice(1)
28  const shown = rest.slice(0, 2).map(l => cut(l, width))
29  if (rest.length > 2 && shown.length) shown[shown.length - 1] = `${shown[shown.length - 1]!.replace(/…$/, '')}…`
30  return shown
31}
32
33// Newest first; saving the same text again moves it to the top instead of keeping two.
34export function add(list: readonly Draft[], d: Draft) {
35  return [d, ...list.filter(x => x.text !== d.text)].slice(0, MAX)
36}
37
38// Put a deleted draft back where it was.
39export function restore(list: readonly Draft[], d: Draft, index: number) {
40  const out = list.filter(x => x.id !== d.id)
41  out.splice(Math.min(index, out.length), 0, d)
42  return out.slice(0, MAX)
43}
44
45export function ago(now: number, at: number, lang: Lang) {
46  const m = Math.max(0, Math.round((now - at) / 60000))
47  const [h, d] = [Math.floor(m / 60), Math.floor(m / 1440)]
48  if (lang === 'ko') return m < 1 ? '방금' : m < 60 ? `${m}분 전` : h < 24 ? `${h}시간 전` : `${d}일 전`
49  return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : h < 24 ? `${h}h ago` : `${d}d ago`
50}
51
52export const text = {
53  en: {
54    saved: (n: number, t: string) => `✓ Saved as a draft (${n} in this session): ${t}`,
55    empty: 'Nothing to save: the prompt is empty.',
56    failed: 'The draft could not be saved, so the prompt was not sent either.',
57    loaded: (t: string) => `In the prompt box: ${t}`,
58    noBox: 'There is no prompt box here to put the draft in.',
59    stashed: 'What was in the prompt box was saved as a draft first.',
60    removed: (t: string) => `Deleted: ${t}. /drafts undo brings it back.`,
61    restored: (t: string) => `Back: ${t}`,
62    noUndo: 'Nothing to bring back.',
63    missing: (n: string) => `There is no draft ${n}.`,
64    none: `No drafts in this session. End a prompt with ${MARKER} and press Enter to keep it aside.`,
65    usage: 'Usage: /drafts, /drafts <n>, /drafts rm <n>, /drafts undo',
66    draftUsage: `Usage: /draft <text>. Or end the prompt you are writing with ${MARKER} and press Enter.`,
67    picker: 'Drafts',
68    pickHint: 'press a number to use one, 0 to close',
69    keysHint: 'number keys',
70    paneHint: 'Click a draft or press its number to put it in the prompt box.',
71    saveButton: 'Save prompt box',
72    undoButton: 'Undo',
73    deletedLine: (t: string) => `Deleted: ${t}`,
74    close: 'close',
75    ask: 'Which draft?',
76    closed: 'Closed.',
77    more: (n: number) => `Next ${n} ▸`,
78    openedWide: 'Pick a draft in the sidebar, or press its number. 0 closes.',
79    openedNarrow: 'Press a number to put a draft in the prompt box. 0 closes.',
80    meta: (a: string, n: number) => `${a}, ${n} chars`,
81    cmdDraft: `Save a prompt as a draft of this session: /draft <text>, or end any prompt with ${MARKER}`,
82    cmdDrafts: "This session's drafts: pick one, /drafts <n>, /drafts rm <n>, /drafts undo",
83  },
84  ko: {
85    saved: (n: number, t: string) => `✓ 임시저장했습니다 (이 세션 ${n}개): ${t}`,
86    empty: '입력창이 비어 있어 저장하지 않았습니다.',
87    failed: '임시저장에 실패해서 보내지도 않았습니다.',
88    loaded: (t: string) => `입력창에 꺼냈습니다: ${t}`,
89    noBox: '여기에는 꺼낸 프롬프트를 넣을 입력창이 없습니다.',
90    stashed: '입력창에 있던 내용은 먼저 임시저장했습니다.',
91    removed: (t: string) => `지웠습니다: ${t}. 되돌리려면 /drafts undo`,
92    restored: (t: string) => `되돌렸습니다: ${t}`,
93    noUndo: '되돌릴 것이 없습니다.',
94    missing: (n: string) => `${n}번 임시저장이 없습니다.`,
95    none: `이 세션에 임시저장한 프롬프트가 없습니다. 프롬프트 끝에 ${MARKER} 를 붙이고 Enter 하면 저장됩니다.`,
96    usage: '사용법: /drafts, /drafts <번호>, /drafts rm <번호>, /drafts undo',
97    draftUsage: `사용법: /draft <내용>. 또는 쓰던 프롬프트 끝에 ${MARKER} 를 붙이고 Enter.`,
98    picker: '임시저장',
99    pickHint: '번호를 누르면 꺼내고, 0은 닫기',
100    keysHint: '번호 키',
101    paneHint: '클릭하거나 번호를 누르면 입력창에 꺼냅니다.',
102    saveButton: '입력창 저장',
103    undoButton: '되돌리기',
104    deletedLine: (t: string) => `지웠습니다: ${t}`,
105    close: '닫기',
106    ask: '어떤 임시저장을 꺼낼까요?',
107    closed: '닫았습니다.',
108    more: (n: number) => `다음 ${n}개 ▸`,
109    openedWide: '사이드바에서 고르거나, 번호를 눌러 바로 꺼내세요. 0은 닫기.',
110    openedNarrow: '번호를 누르면 입력창에 꺼냅니다. 0은 닫기.',
111    meta: (a: string, n: number) => `${a}, ${n}자`,
112    cmdDraft: `이 세션에 프롬프트 임시저장: /draft <내용>, 또는 프롬프트 끝에 ${MARKER}`,
113    cmdDrafts: '이 세션의 임시저장: 골라서 꺼내기, /drafts <번호>, /drafts rm <번호>, /drafts undo',
114  },
115} satisfies Record<Lang, Record<string, unknown>>
116
117// One page of the native question dialog (2-4 options): up to 4 drafts, or 3 and a "next" option.
118// Labels must differ, so a repeated title gets its number.
119export function askPage(list: readonly Draft[], start: number) {
120  const rest = list.slice(start)
121  const page = rest.length <= 4 ? rest : rest.slice(0, 3)
122  const seen = new Set<string>()
123  const labels = page.map((d, i) => {
124    const label = seen.has(d.title) || !d.title ? `${start + i + 1}. ${d.title}` : d.title
125    seen.add(label)
126    return label
127  })
128  return { page, labels, hasMore: rest.length > page.length, next: start + page.length }
129}
130
131export function listText(list: readonly Draft[], now: number, lang: Lang) {
132  const t = text[lang]
133  if (!list.length) return t.none
134  return list.map((d, i) => `${String(i + 1).padStart(2)}  ${d.title}  (${t.meta(ago(now, d.savedAt, lang), d.text.length)})`).join('\n')
135}
136
types/index.d.ts 9 lines
1export type Draft = { id: string; title: string; text: string; savedAt: number }
2export type Deleted = { draft: Draft; index: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'prompt-drafts': { drafts: Draft[]; isPicking: boolean; deleted: Deleted | null }
7  }
8}
9