SLOPSHOPPER

prompt-upgrade

Turns a rough prompt into a great one before it is sent: up to three multiple-choice questions, a rewrite, and the original beside the upgrade in a pane…

newpanecommandtoastpromptmodel
★ 1v0.1.1MITupdated 2026-10-21samuelbfernandes/moddeck/mods/prompt-upgrade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-upgrade
› fix the failing auth test and add an audit log call ⏺ 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 › /better ⎿ prompt-upgrade: Prompt upgrade · type your prompt in the pane ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prompt-upgrade

Install (Claude Code 2.1.304 or later): /plugin install prompt-upgrade --marketplace samuelbfernandes/moddeck

What it does

Turns a rough prompt into a great one before it is sent. A pane walks four steps, shown as a tracker across the top: Your prompt, Questions, Rewrite, Review. Nothing runs until you press Run it.

  1. Questions: a fast model asks up to three multiple-choice questions (two to four options each, plus "Other (type it)" and "Skip"). If no question would help it goes straight to the rewrite.
  2. Rewrite: the model writes the upgrade and a short list of what it added. It keeps what you meant, adds a goal, an audience, a format and what "done" looks like, uses your answers, and leaves [placeholders] where something is unknown.
  3. Review: words before and after and how many details were added, then the buttons (Run it, Edit, Copy, Cancel), then the list of what was added, then your prompt and the upgrade side by side.

Commands

  • /better <prompt>: upgrade that prompt. /better alone opens a box to type one.
  • /better on / /better off: automatic mode. While on, a prompt typed at the prompt box goes to the pane first.
  • /better history: your last 20 upgrades; a click reopens its review.

Automatic mode lets these go straight through: slash commands, prompts with files or images, messages sent while Claude is working, short replies ("yes", "keep going", under minWords words), and anything starting with > (the > is removed). The upgrade is sent with $.prompt.submit, which skips this mod's own hooks, so it cannot loop back into the pane.

Buttons

Cancel closes the pane and puts your original back in the prompt box. Edit lets you change the upgrade; Enter runs it. A failed model call shows a card that says your prompt is safe: Try again, Send my original, Cancel. If the window is too narrow for the pane, your words go back in the prompt box.

Cost

Off until /better on (the command itself is free until used). Each upgrade is one fast-model call (default haiku, at most 600 tokens out) and one rewrite call (default sonnet, at most 1800 tokens out). Both models are settings.

Limits

  • Saved in the mod's own store: the switch (auto) and the last 20 upgrades (history).
  • Not verified live: the pane in a real session, in the desktop app, and the model aliases haiku and sonnet.
Source 3 files
hooks/register.tsx 479 lines
1// prompt-upgrade: turns a rough prompt into a great one before it is sent. A pane walks four steps (your prompt, up to
2// three multiple-choice questions, a rewrite, a review with the original beside the upgrade); nothing runs until Run.
3// /better <prompt> starts one; /better on|off sends every typed prompt there first; /better history lists the last 20.
4// The upgrade is sent with $.prompt.submit, which skips this plugin's own hooks, so it can never loop back into the pane.
5// FAIL OPEN: a failure in a hook lets the prompt through as typed; a failed model call shows a card and keeps the prompt.
6// A draw only reads; every change of state comes from a press, a command or another event.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, PluginOptions, Register } from 'claude-code'
9
10import type { UpgradeEntry, UpgradeSession, UpgradeStep } from '../types'
11import {
12  COLORS, STEP_NAMES, STEP_NAMES_NARROW, addHistory, answeredCount, bigNumber, cardWidth, fit, heading, isDone, isNarrow, parseQuestions,
13  parseRewrite, questionsPrompt, QUESTIONS_SYSTEM, REWRITE_SYSTEM, rewritePrompt, route, rule, trackerIndex, wordCount,
14} from './lib'
15
16type Dollar = EngineInterface
17
18const PANE = 'prompt-upgrade'
19const COMMAND = 'better'
20const session = atom({ plugin: 'prompt-upgrade', key: 'session' } as const, null as UpgradeSession | null)
21const history = atom({ plugin: 'prompt-upgrade', key: 'history' } as const, [] as UpgradeEntry[])
22const isAuto = atom({ plugin: 'prompt-upgrade', key: 'isAuto' } as const, false)
23
24const COMMAND_HINT = 'Prompt upgrade · /better <prompt> · /better on|off · /better history'
25
26async function isAutoOn($: Dollar): Promise<boolean> {
27  try {
28    return (await $.store.get('auto')) === true
29  } catch {
30    return false
31  }
32}
33
34async function savedHistory($: Dollar): Promise<UpgradeEntry[]> {
35  try {
36    const v = await $.store.get('history')
37    return Array.isArray(v) ? (v as UpgradeEntry[]).filter(e => e !== null && typeof e === 'object' && typeof e.text === 'string').slice(0, 20) : []
38  } catch {
39    return []
40  }
41}
42
43const blank = (original: string, runId: number, step: UpgradeStep): UpgradeSession => ({
44  runId, step, original, questions: [], answers: {}, otherFor: null, rewrite: null, isEditing: false, error: '',
45})
46
47/** Changes the session only while it is still the run that asked (a late model answer is dropped). */
48const patch = ($: Dollar, runId: number, fn: (s: UpgradeSession) => UpgradeSession) =>
49  update($, session, s => (s !== null && s.runId === runId ? fn(s) : s))
50
51const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 160)
52
53/** Step 1 to 2: the fast model asks up to three questions; none that would help goes straight to the rewrite. */
54async function ask($: Dollar, runId: number, original: string, options: PluginOptions): Promise<void> {
55  try {
56    const model = typeof options.questionModel === 'string' && options.questionModel !== '' ? options.questionModel : 'haiku'
57    const r = await $.model.complete({ model, system: QUESTIONS_SYSTEM, prompt: questionsPrompt(original), maxTokens: 600, timeoutMs: 45_000 })
58    const questions = r.isAnswered ? parseQuestions(r.text) : undefined
59    if (questions === undefined) throw new Error(r.isAnswered ? 'the model answered in a form I could not read' : 'the model did not answer')
60    if (questions.length === 0) return await toRewrite($, runId, options)
61    await patch($, runId, s => ({ ...s, step: 'questions', questions }))
62  } catch (error) {
63    await patch($, runId, s => ({ ...s, step: 'error', failedAt: 'reading', error: message(error) }))
64  }
65}
66
67/** Step 3: the upgraded prompt and a short list of what was added. */
68async function toRewrite($: Dollar, runId: number, options: PluginOptions): Promise<void> {
69  await patch($, runId, s => ({ ...s, step: 'rewrite', otherFor: null }))
70  try {
71    const s = await read($, session)
72    if (s === null || s.runId !== runId) return
73    const model = typeof options.rewriteModel === 'string' && options.rewriteModel !== '' ? options.rewriteModel : 'sonnet'
74    const r = await $.model.complete({ model, system: REWRITE_SYSTEM, prompt: rewritePrompt(s.original, s.questions, s.answers), maxTokens: 1800, timeoutMs: 90_000 })
75    const rewrite = r.isAnswered ? parseRewrite(r.text) : undefined
76    if (rewrite === undefined) throw new Error(r.isAnswered ? 'the model answered in a form I could not read' : 'the model did not answer')
77    await patch($, runId, cur => ({ ...cur, step: 'review', rewrite, isEditing: false }))
78  } catch (error) {
79    await patch($, runId, cur => ({ ...cur, step: 'error', failedAt: 'rewrite', error: message(error) }))
80  }
81}
82
83/** Puts the words back in the prompt box, so nothing typed is lost. */
84async function restore($: Dollar, text: string): Promise<boolean> {
85  try {
86    const out = (await $.prompt.fill({ text })) as { isFilled?: boolean } | undefined
87    return out?.isFilled === true
88  } catch {
89    return false
90  }
91}
92
93const closePane = ($: Dollar) => $.ui.close({ id: PANE }).catch(() => undefined)
94
95/** Opens the pane on a fresh session. False (and the words back in the box) where no surface can draw it now. */
96async function begin($: Dollar, text: string, step: UpgradeStep, options: PluginOptions): Promise<boolean> {
97  const runId = await $.clock.now()
98  await update($, session, () => blank(text, runId, step))
99  let isPlaced = false
100  try {
101    isPlaced = (await $.ui.open({ id: PANE, title: 'Prompt upgrade', focus: true, closeOnEscape: true })).isPlaced
102  } catch {
103    isPlaced = false
104  }
105  if (!isPlaced) {
106    await update($, session, () => null)
107    await closePane($)
108    return false
109  }
110  if (step === 'reading') void ask($, runId, text, options)
111  return true
112}
113
114const NARROW_LINE = 'Prompt upgrade · the window is too narrow for the pane · your words are back in the prompt box'
115
116/** The pane could not open: the words go back in the box and one line says why. */
117function putBack($: Dollar, text: string): void {
118  $.clock.after(50, () => {
119    if (text !== '') void restore($, text)
120    $.ui.toast(NARROW_LINE)
121  })
122}
123
124async function pickAnswer($: Dollar, runId: number, i: number, value: string | null, options: PluginOptions): Promise<void> {
125  await patch($, runId, s => ({ ...s, answers: { ...s.answers, [i]: value }, otherFor: s.otherFor === i ? null : s.otherFor }))
126  const s = await read($, session)
127  if (s !== null && s.runId === runId && s.step === 'questions' && isDone(s.questions, s.answers)) await toRewrite($, runId, options)
128}
129
130async function skipRest($: Dollar, runId: number, options: PluginOptions): Promise<void> {
131  await patch($, runId, s => ({ ...s, answers: Object.fromEntries(s.questions.map((_, i) => [i, s.answers[i] ?? null])), otherFor: null }))
132  await toRewrite($, runId, options)
133}
134
135async function send($: Dollar, text: string): Promise<void> {
136  try {
137    await $.prompt.submit({ text, asUser: true })
138  } catch {
139    $.ui.toast('Prompt upgrade · could not send it, your prompt is in the history')
140  }
141}
142
143/** Run it: the upgrade goes to the model as my own words, the pane closes, the upgrade is kept in the history. */
144async function run($: Dollar, text: string): Promise<void> {
145  const s = await read($, session)
146  if (s === null) return
147  const entry: UpgradeEntry = { id: s.runId, at: await $.clock.now(), original: s.original, text, added: s.rewrite?.added ?? [] }
148  const list = addHistory(await read($, history), entry)
149  await update($, history, () => list)
150  await $.store.set('history', list).catch(() => undefined)
151  await update($, session, () => null)
152  await closePane($)
153  void send($, text)
154}
155
156async function sendOriginal($: Dollar): Promise<void> {
157  const s = await read($, session)
158  if (s === null) return
159  await update($, session, () => null)
160  await closePane($)
161  void send($, s.original)
162}
163
164/** Cancel: the pane closes and the original prompt is back in the box. */
165async function cancel($: Dollar): Promise<void> {
166  const s = await read($, session)
167  await update($, session, () => null)
168  await closePane($)
169  if (s !== null && s.original !== '') await restore($, s.original)
170}
171
172async function reopen($: Dollar, entry: UpgradeEntry): Promise<void> {
173  const next: UpgradeSession = { ...blank(entry.original, entry.id, 'review'), rewrite: { text: entry.text, added: entry.added } }
174  await update($, session, () => next)
175}
176
177export const register: Register = (on, options) => {
178  const minWords = typeof options.minWords === 'number' && options.minWords >= 1 ? options.minWords : 12
179
180  on('session.start', async ($, e, next) => {
181    try {
182      const isOn = await isAutoOn($)
183      await update($, isAuto, () => isOn)
184    } catch {
185      // reads as off
186    }
187    try {
188      const saved = await savedHistory($)
189      await update($, history, list => (list.length > 0 ? list : saved))
190    } catch {
191      // an empty history
192    }
193    try {
194      await $.command.register({ name: COMMAND, description: 'Upgrade a rough prompt before it is sent: /better <prompt> | on | off | history', argumentHint: '<prompt> | on | off | history', immediate: true })
195    } catch {
196      // automatic mode still works
197    }
198    // Reloaded mid-step: a model call that was in flight is lost, so it is asked again and the pane comes back.
199    try {
200      const s = await read($, session)
201      if (s !== null) {
202        const opened = await $.ui.open({ id: PANE, title: 'Prompt upgrade', focus: true, closeOnEscape: true }).catch(() => undefined)
203        if (opened === undefined || !opened.isPlaced) await update($, session, () => null)
204        else if (s.step === 'reading') void ask($, s.runId, s.original, options)
205        else if (s.step === 'rewrite') void toRewrite($, s.runId, options)
206      }
207    } catch {
208      // the pane stays closed
209    }
210    return next(e)
211  })
212
213  // A command answers at once; a pane it opens is opened a moment later, in the background.
214  on('command.run', { command: COMMAND }, async ($, e) => {
215    const text = e.args.trim()
216    const word = text.toLowerCase()
217    if (word === 'on' || word === 'off') {
218      const to = word === 'on'
219      if (to) await $.store.set('auto', true)
220      else await $.store.delete('auto')
221      await update($, isAuto, () => to)
222      return { text: to ? 'Prompt upgrade · on · typed prompts go to the pane first' : 'Prompt upgrade · off · prompts are sent as typed' }
223    }
224    const open = (what: string, step: UpgradeStep) =>
225      $.clock.after(1, () => void begin($, what, step, options).then(isOpen => (isOpen ? undefined : putBack($, what))))
226    if (word === 'history') {
227      open('', 'history')
228      return { text: `Prompt upgrade · ${(await read($, history)).length} saved upgrades` }
229    }
230    if (text === '') {
231      open('', 'input')
232      return { text: 'Prompt upgrade · type your prompt in the pane' }
233    }
234    open(text, 'reading')
235    return { text: 'Prompt upgrade · reading your prompt' }
236  }).catch(() => ({ text: 'Prompt upgrade · that did not work, nothing changed' }))
237
238  on('prompt.submit', async ($, e, next) => {
239    try {
240      if (!(await read($, isAuto)) || (await read($, session)) !== null) return next(e)
241      const r = route(
242        { text: e.text, origin: e.origin.kind, hasCommand: e.command !== undefined, attachments: e.attachments?.length ?? 0, isMeta: e.isMeta === true, isMidTurn: e.turnId !== undefined },
243        minWords,
244      )
245      if (r.kind === 'pass') return next(e)
246      if (r.kind === 'strip') return next({ ...e, text: r.text })
247      if (!(await begin($, r.text, 'reading', options))) {
248        putBack($, r.text)
249        return { drop: NARROW_LINE }
250      }
251      return { drop: 'Prompt upgrade · your prompt is in the pane' }
252    } catch {
253      return next(e)
254    }
255  }).catch(($, e, next) => next(e))
256
257  // The pane: only reads. Every press below changes state from its own handler.
258  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
259    const R = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Input?: (p: Record<string, unknown>) => unknown }
260    const { Box, Button, Text } = R
261    const Input = R.Input
262    const s = await read($, session)
263    if (s === null) return next(e)
264    const cols = Math.max(24, e.props.bodyColumns)
265    const narrow = isNarrow(cols)
266    const desk = e.surface === 'desktop' ? ({ minWidth: 0, overflow: 'hidden' } as const) : {}
267
268    const gold = (key: string, label: string, onPress: () => void) => (
269      <Button key={key} plain onPress={onPress}>
270        <Text backgroundColor={COLORS.gold} color={COLORS.ink} bold>{` ${label} `}</Text>
271      </Button>
272    )
273    const slate = (key: string, label: string, onPress: () => void, isChosen = false) => (
274      <Button key={key} plain onPress={onPress}>
275        <Text backgroundColor={isChosen ? COLORS.green : COLORS.hairline} color={isChosen ? COLORS.ink : COLORS.text} bold={isChosen}>{` ${label} `}</Text>
276      </Button>
277    )
278    const quiet = (key: string, label: string, onPress: () => void) => (
279      <Button key={key} plain onPress={onPress}>
280        <Text color={COLORS.muted}>{label}</Text>
281      </Button>
282    )
283    const head = (label: string, key: string) => <Text key={key} color={COLORS.muted} wrap="truncate-end">{heading(label, cols)}</Text>
284
285    const title = 'PROMPT UPGRADE'
286    const titleRow = (
287      <Box key="title" flexDirection="column">
288        <Text bold color={COLORS.text}>{title}</Text>
289        <Text wrap="truncate-end">
290          <Text color={COLORS.gold}>{rule(title.length, '━')}</Text>
291          <Text color={COLORS.hairline}>{rule(cols - title.length, '─')}</Text>
292        </Text>
293      </Box>
294    )
295
296    const at = trackerIndex(s.step, s.failedAt)
297    const names = narrow ? STEP_NAMES_NARROW : STEP_NAMES
298    const tracker = (
299      <Text key="tracker" wrap="truncate-end">
300        {names.map((n, i) => (
301          <Text key={`t${i}`} color={i < at ? COLORS.green : i === at ? COLORS.gold : COLORS.muted} bold={i === at}>
302            {`${i < at ? '✓' : i === at ? '●' : '○'} ${n}${i < names.length - 1 ? '   ' : ''}`}
303          </Text>
304        ))}
305      </Text>
306    )
307
308    const card = (key: string, label: string, text: string, width: number, outline?: string) => (
309      <Box key={key} flexDirection="column" width={width} {...desk}>
310        <Text color={outline ?? COLORS.muted} bold={outline !== undefined} wrap="truncate-end">{fit(label.toUpperCase(), width)}</Text>
311        <Box borderStyle="round" borderColor={outline ?? COLORS.hairline} paddingX={1} width={width} {...desk}>
312          <Text color={COLORS.text} wrap="wrap">{text}</Text>
313        </Box>
314      </Box>
315    )
316
317    const body: unknown[] = []
318
319    if (s.step === 'input') {
320      body.push(head('Your prompt', 'h-input'))
321      body.push(
322        Input !== undefined ? (
323          <Input key="upgrade-input" label="" placeholder="Type a rough prompt, Enter to upgrade it" rows={4} autoFocus submitLabel="upgrade" onSubmit={(v: string) => { if (v.trim() !== '') void startFrom($, v.trim(), options) }} />
324        ) : (
325          <Text key="upgrade-noinput" color={COLORS.muted}>Type /better followed by your prompt.</Text>
326        ),
327      )
328      body.push(<Box key="c-input">{quiet('cancel', 'Cancel', () => void cancel($))}</Box>)
329    } else if (s.step === 'reading' || s.step === 'rewrite') {
330      body.push(head('Your prompt', 'h-prompt'))
331      body.push(card('card-orig', 'Your prompt', s.original, cols))
332      body.push(<Text key="working" color={COLORS.gold}>{s.step === 'reading' ? 'Reading your prompt and choosing what to ask…' : 'Writing the upgrade from your answers…'}</Text>)
333      body.push(<Box key="c-wait">{quiet('cancel', 'Cancel', () => void cancel($))}</Box>)
334    } else if (s.step === 'questions') {
335      const total = s.questions.length
336      const count = answeredCount(s.questions, s.answers)
337      body.push(head('Questions', 'h-q'))
338      s.questions.forEach((q, i) => {
339        const a = s.answers[i]
340        const isAnswered = a !== undefined
341        body.push(
342          <Box key={`q${i}`} flexDirection="column">
343            <Text wrap="wrap">
344              <Text backgroundColor={isAnswered && a !== null ? COLORS.green : COLORS.gold} color={COLORS.ink} bold>{isAnswered && a !== null ? ' ✓ ' : ` ${i + 1} `}</Text>
345              <Text color={COLORS.text} bold>{` ${q.q}`}</Text>
346            </Text>
347            <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginLeft={3}>
348              {q.options.map((o, j) => slate(`q${i}o${j}`, fit(o, Math.max(8, cols - 8)), () => void pickAnswer($, s.runId, i, o, options), a === o))}
349              {slate(`q${i}other`, 'Other (type it)', () => void update($, session, cur => (cur !== null && cur.runId === s.runId ? { ...cur, otherFor: i } : cur)), s.otherFor === i || (typeof a === 'string' && !q.options.includes(a)))}
350              {a === null ? <Text key={`q${i}skipped`} color={COLORS.muted}>skipped</Text> : quiet(`q${i}skip`, 'Skip', () => void pickAnswer($, s.runId, i, null, options))}
351            </Box>
352            {s.otherFor === i && Input !== undefined ? (
353              <Box key={`q${i}input`} marginLeft={3}>
354                <Input key={`other${i}`} label="" placeholder="Your answer, Enter to keep it" autoFocus submitLabel="answer" onSubmit={(v: string) => { if (v.trim() !== '') void pickAnswer($, s.runId, i, v.trim().slice(0, 120), options) }} />
355              </Box>
356            ) : null}
357          </Box>,
358        )
359      })
360      body.push(
361        <Box key="q-foot" columnGap={2}>
362          <Text color={COLORS.muted}>{`${count} of ${total} answered`}</Text>
363          {slate('skip-rest', 'Skip the rest, upgrade now', () => void skipRest($, s.runId, options))}
364          {quiet('cancel', 'Cancel', () => void cancel($))}
365        </Box>,
366      )
367    } else if (s.step === 'review' && s.rewrite !== null) {
368      const up = s.rewrite
369      const before = wordCount(s.original)
370      const after = wordCount(up.text)
371      const tiles: [string, number][] = [['words before', before], ['words after', after], ['details added', up.added.length]]
372      body.push(head('Review', 'h-review'))
373      body.push(
374        <Box key="tiles" flexDirection="row" columnGap={1}>
375          {tiles.map(([label, n]) => (
376            <Box key={`tile-${label}`} flexDirection="column" borderStyle="round" borderColor={COLORS.hairline} paddingX={1}>
377              {bigNumber(n).map((row, r) => <Text key={`tile-${label}-${r}`} color={label === 'details added' ? COLORS.green : COLORS.text} bold>{row}</Text>)}
378              <Text color={COLORS.muted}>{label}</Text>
379            </Box>
380          ))}
381        </Box>,
382      )
383      // The buttons come before the cards, so a long rewrite never pushes them off the screen.
384      if (s.isEditing) {
385        body.push(
386          Input !== undefined ? (
387            <Input key="edit-input" label="" value={up.text} rows={Math.min(10, Math.max(4, Math.ceil(up.text.length / Math.max(20, cols - 4)) + 1))} autoFocus submitLabel="run"
388              onInput={(v: string) => void update($, session, cur => (cur !== null && cur.runId === s.runId && cur.rewrite !== null ? { ...cur, rewrite: { ...cur.rewrite, text: v } } : cur))}
389              onSubmit={(v: string) => { if (v.trim() !== '') void run($, v.trim()) }} />
390          ) : (
391            <Text key="edit-none" color={COLORS.muted}>Editing needs a text field this window does not draw.</Text>
392          ),
393        )
394        body.push(
395          <Box key="edit-buttons" columnGap={1}>
396            {gold('run', 'Run it', () => void run($, up.text))}
397            {slate('back', 'Done editing', () => void update($, session, cur => (cur !== null && cur.runId === s.runId ? { ...cur, isEditing: false } : cur)))}
398            {quiet('cancel', 'Cancel', () => void cancel($))}
399          </Box>,
400        )
401      } else {
402        body.push(
403          <Box key="buttons" columnGap={1}>
404            {gold('run', 'Run it', () => void run($, up.text))}
405            {slate('edit', 'Edit', () => void update($, session, cur => (cur !== null && cur.runId === s.runId ? { ...cur, isEditing: true } : cur)))}
406            {slate('copy', 'Copy', () => void $.ui.copy({ text: up.text, surface: e.surface }).catch(() => undefined))}
407            {quiet('cancel', 'Cancel', () => void cancel($))}
408          </Box>,
409        )
410      }
411      if (up.added.length > 0) {
412        body.push(head('What I added', 'h-added'))
413        up.added.forEach((a, i) => body.push(<Text key={`add${i}`} color={COLORS.green} wrap="truncate-end">{`+ ${fit(a, cols - 2)}`}</Text>))
414      }
415      body.push(
416        narrow ? (
417          <Box key="cards" flexDirection="column" rowGap={1}>
418            {card('card-orig', 'Your prompt', s.original, cols)}
419            {card('card-up', 'Upgrade', up.text, cols, COLORS.gold)}
420          </Box>
421        ) : (
422          <Box key="cards" flexDirection="row" columnGap={1}>
423            {card('card-orig', 'Your prompt', s.original, cardWidth(cols))}
424            {card('card-up', 'Upgrade', up.text, cardWidth(cols), COLORS.gold)}
425          </Box>
426        ),
427      )
428    } else if (s.step === 'error') {
429      body.push(head('Something went wrong', 'h-err'))
430      body.push(
431        <Box key="err" flexDirection="column" borderStyle="round" borderColor={COLORS.amber} paddingX={1} width={cols} {...desk}>
432          <Text color={COLORS.amber} bold>Your prompt is safe.</Text>
433          <Text color={COLORS.text} wrap="wrap">{`The model call failed: ${s.error}`}</Text>
434        </Box>,
435      )
436      body.push(
437        <Box key="err-buttons" columnGap={1}>
438          {gold('retry', 'Try again', () => void retry($, s, options))}
439          {slate('original', 'Send my original', () => void sendOriginal($))}
440          {quiet('cancel', 'Cancel', () => void cancel($))}
441        </Box>,
442      )
443    } else if (s.step === 'history') {
444      const list = await read($, history)
445      body.push(head('Last 20 upgrades', 'h-hist'))
446      if (list.length === 0) body.push(<Text key="none" color={COLORS.muted}>No upgrades yet.</Text>)
447      list.forEach(en => body.push(
448        <Box key={`hist${en.id}`}>
449          {slate(`open${en.id}`, fit(en.original.replace(/\s+/g, ' '), Math.max(12, cols - 4)), () => void reopen($, en))}
450        </Box>,
451      ))
452      body.push(<Box key="c-hist">{quiet('cancel', 'Close', () => void cancel($))}</Box>)
453    }
454
455    return (
456      <Box flexDirection="column" rowGap={1} width={cols} {...desk}>
457        {titleRow}
458        {tracker}
459        {body as never}
460      </Box>
461    )
462  }).catch(($, e, next) => next(e))
463}
464
465async function startFrom($: Dollar, text: string, options: PluginOptions): Promise<void> {
466  const runId = await $.clock.now()
467  await update($, session, () => blank(text, runId, 'reading'))
468  void ask($, runId, text, options)
469}
470
471async function retry($: Dollar, s: UpgradeSession, options: PluginOptions): Promise<void> {
472  if (s.failedAt === 'rewrite') {
473    await toRewrite($, s.runId, options)
474    return
475  }
476  await patch($, s.runId, cur => ({ ...cur, step: 'reading', error: '' }))
477  void ask($, s.runId, s.original, options)
478}
479
hooks/lib.ts 190 lines
1// prompt-upgrade: pure helpers. Which prompts the mod takes, the prompts to the models, reading their answers, and the
2// three-row numbers and the fitted rows the pane draws.
3import type { UpgradeQuestion, UpgradeRewrite } from '../types'
4
5export const MAX_HISTORY = 20
6export const MAX_QUESTIONS = 3
7export const MAX_PROMPT_CHARS = 8000
8
9export const COLORS = {
10  gold: '#F2C46D',
11  hairline: '#262B33',
12  muted: '#7D8590',
13  text: '#E6EDF3',
14  green: '#3DD68C',
15  amber: '#E3B341',
16  ink: '#0D1117',
17} as const
18
19const WORDS = (s: string): number => s.trim().split(/\s+/).filter(Boolean).length
20export const wordCount = (s: string): number => (s.trim() === '' ? 0 : WORDS(s))
21
22/** Replies that never need an upgrade. */
23const SHORT_REPLY = /^(y|n|yes|no|yep|nope|ok|okay|sure|go|go on|go ahead|continue|keep going|proceed|thanks|thank you|do it|sounds good|lgtm|stop|done|next)[.!? ]*$/i
24
25export type Route =
26  | { kind: 'take'; text: string }
27  | { kind: 'pass' }
28  | { kind: 'strip'; text: string }
29
30/** The facts of a submission that decide where it goes. */
31export type Submission = {
32  text: string
33  origin: string
34  hasCommand: boolean
35  attachments: number
36  isMeta: boolean
37  /** Typed while a turn was running. */
38  isMidTurn: boolean
39}
40
41/**
42 * Automatic mode: take a typed prompt into the pane, or let it through. Slash commands, attachments, messages sent
43 * while Claude works, short replies and anything not typed at the prompt go on untouched; one that starts with ">"
44 * goes on with the ">" removed.
45 */
46export function route(s: Submission, minWords: number): Route {
47  const text = s.text.trim()
48  if (s.origin !== 'composer' || s.isMeta || s.hasCommand || s.attachments > 0 || s.isMidTurn) return { kind: 'pass' }
49  if (text === '' || text.startsWith('/')) return { kind: 'pass' }
50  if (text.startsWith('>')) return { kind: 'strip', text: text.replace(/^>\s*/, '') }
51  if (SHORT_REPLY.test(text) || wordCount(text) < minWords) return { kind: 'pass' }
52  return { kind: 'take', text }
53}
54
55export const QUESTIONS_SYSTEM = [
56  'You help someone sharpen a rough prompt before they send it to an AI assistant.',
57  `Ask at most ${MAX_QUESTIONS} multiple-choice questions whose answers would most improve the result. Ask only what the prompt leaves open and a different answer would change the output (goal, audience, format, scope, what done looks like).`,
58  'Each question has 2 to 4 short options (a few words each). Never ask about something the prompt already says.',
59  'If no question would help, return an empty list.',
60  'Answer with JSON only: {"questions": [{"q": string, "options": string[]}]}.',
61].join('\n')
62
63export const REWRITE_SYSTEM = [
64  'You rewrite a rough prompt into one great prompt the person could have typed themselves.',
65  'Keep exactly what they meant. Add the goal, the expected output and what "done" looks like; add an audience only for writing meant for people.',
66  'Use what they answered to the questions; leave out what they skipped.',
67  'Never invent facts, names, numbers, prices, file paths, function names or package versions. Where something is unknown, leave a [placeholder] in square brackets.',
68  'Write it as one request in plain language, no preamble, no markdown headings.',
69  'Answer with JSON only: {"upgraded": string, "added": string[]} where added lists, in a few words each, the details you added (at most 6).',
70].join('\n')
71
72const clip = (s: string): string => (s.length > MAX_PROMPT_CHARS ? `${s.slice(0, MAX_PROMPT_CHARS)}\n[cut]` : s)
73
74export const questionsPrompt = (original: string): string => `The rough prompt:\n\n${clip(original)}`
75
76export function rewritePrompt(original: string, questions: readonly UpgradeQuestion[], answers: Readonly<Record<number, string | null>>): string {
77  const lines = questions.map((q, i) => {
78    const a = answers[i]
79    return `- ${q.q} ${a === undefined || a === null ? '(skipped)' : `Answer: ${a}`}`
80  })
81  const asked = lines.length > 0 ? `\n\nQuestions and answers:\n${lines.join('\n')}` : ''
82  return `The rough prompt:\n\n${clip(original)}${asked}`
83}
84
85const jsonOf = (raw: string): Record<string, unknown> | undefined => {
86  const body = raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)
87  try {
88    const v = JSON.parse(body) as unknown
89    return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : undefined
90  } catch {
91    return undefined
92  }
93}
94
95const short = (v: unknown, max: number): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim().slice(0, max) : undefined)
96
97/** The questions in the model's JSON (it may wrap it in a fence); undefined when the answer is not usable at all. */
98export function parseQuestions(raw: string): UpgradeQuestion[] | undefined {
99  const v = jsonOf(raw)
100  if (v === undefined || !Array.isArray(v.questions)) return undefined
101  const out: UpgradeQuestion[] = []
102  for (const item of v.questions as unknown[]) {
103    if (item === null || typeof item !== 'object') continue
104    const q = short((item as { q?: unknown }).q, 200)
105    const opts = (item as { options?: unknown }).options
106    if (q === undefined || !Array.isArray(opts)) continue
107    const options = [...new Set(opts.map(o => short(o, 40)).filter((o): o is string => o !== undefined))].slice(0, 4)
108    if (options.length >= 2) out.push({ q, options })
109    if (out.length === MAX_QUESTIONS) break
110  }
111  return out
112}
113
114export function parseRewrite(raw: string): UpgradeRewrite | undefined {
115  const v = jsonOf(raw)
116  const text = typeof v?.upgraded === 'string' ? v.upgraded.trim() : ''
117  if (v === undefined || text === '') return undefined
118  const added = Array.isArray(v.added) ? v.added.map(a => short(a, 90)).filter((a): a is string => a !== undefined).slice(0, 6) : []
119  return { text, added }
120}
121
122/** Every question is answered or skipped. */
123export const isDone = (questions: readonly UpgradeQuestion[], answers: Readonly<Record<number, string | null>>): boolean =>
124  questions.every((_, i) => answers[i] !== undefined)
125
126export const answeredCount = (questions: readonly UpgradeQuestion[], answers: Readonly<Record<number, string | null>>): number =>
127  questions.filter((_, i) => typeof answers[i] === 'string').length
128
129/** Newest first, no repeat of the same upgrade, at most MAX_HISTORY. */
130export const addHistory = <T extends { id: number }>(list: readonly T[], entry: T): T[] =>
131  [entry, ...list.filter(e => e.id !== entry.id)].slice(0, MAX_HISTORY)
132
133// --- drawing helpers -------------------------------------------------------------------------------------------
134
135/** Heavy box-drawing digits, three cells wide and three rows tall. */
136const DIGITS: Record<string, readonly [string, string, string]> = {
137  '0': ['┏━┓', '┃ ┃', '┗━┛'],
138  '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
139  '2': ['╺━┓', '┏━┛', '┗━╸'],
140  '3': ['╺━┓', ' ━┫', '╺━┛'],
141  '4': ['╻ ╻', '┗━┫', '  ╹'],
142  '5': ['┏━╸', '┗━┓', '╺━┛'],
143  '6': ['┏━╸', '┣━┓', '┗━┛'],
144  '7': ['╺━┓', '  ┃', '  ╹'],
145  '8': ['┏━┓', '┣━┫', '┗━┛'],
146  '9': ['┏━┓', '┗━┫', '╺━┛'],
147}
148
149/** A number as three rows of heavy box-drawing digits, a space between digits. */
150export function bigNumber(n: number): [string, string, string] {
151  const digits = String(Math.max(0, Math.round(n))).split('')
152  return [0, 1, 2].map(r => digits.map(d => (DIGITS[d] ?? DIGITS['0']!)[r]).join(' ')) as [string, string, string]
153}
154
155/** A line of `ch` as wide as the pane (never below 1). */
156export const rule = (cols: number, ch = '─'): string => ch.repeat(Math.max(1, cols))
157
158/** `LABEL ────────` to the pane's width. */
159export const heading = (label: string, cols: number): string => {
160  const l = label.toUpperCase()
161  return `${l} ${rule(cols - l.length - 1)}`
162}
163
164/** The text cut to `max` cells, with an ellipsis. */
165export const fit = (s: string, max: number): string => (s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}…`)
166
167export const STEP_NAMES = ['Your prompt', 'Questions', 'Rewrite', 'Review'] as const
168export const STEP_NAMES_NARROW = ['Prompt', 'Ask', 'Rewrite', 'Review'] as const
169
170/** Which of the four tracker steps a step belongs to (0 to 3). */
171export function trackerIndex(step: string, failedAt?: 'reading' | 'rewrite'): number {
172  const at = step === 'error' ? (failedAt === 'rewrite' ? 'rewrite' : 'reading') : step
173  switch (at) {
174    case 'input':
175    case 'reading':
176    case 'history':
177      return 0
178    case 'questions':
179      return 1
180    case 'rewrite':
181      return 2
182    default:
183      return 3
184  }
185}
186
187/** The row's width in a pane that is `cols` wide; a pane too narrow for two cards stacks them. */
188export const cardWidth = (cols: number): number => Math.max(10, Math.floor((cols - 1) / 2))
189export const isNarrow = (cols: number): boolean => cols < 64
190
types/index.d.ts 40 lines
1/** One multiple-choice question the fast model asked. */
2export type UpgradeQuestion = { q: string; options: string[] }
3
4/** What the rewrite step returns. */
5export type UpgradeRewrite = { text: string; added: string[] }
6
7/** `reading` and `rewrite` are model calls in flight; `input` waits for typed words; `history` lists the past upgrades. */
8export type UpgradeStep = 'input' | 'reading' | 'questions' | 'rewrite' | 'review' | 'error' | 'history'
9
10/** The upgrade on screen. `answers[i]` is the chosen words, `null` for a skipped question, absent while open. */
11export type UpgradeSession = {
12  runId: number
13  step: UpgradeStep
14  /** Where a failed call stood, so Try again repeats it. */
15  failedAt?: 'reading' | 'rewrite'
16  original: string
17  questions: UpgradeQuestion[]
18  answers: Record<number, string | null>
19  /** The question whose "Other" field is open. */
20  otherFor: number | null
21  rewrite: UpgradeRewrite | null
22  isEditing: boolean
23  error: string
24}
25
26export type UpgradeEntry = { id: number; at: number; original: string; text: string; added: string[] }
27
28declare module 'claude-code' {
29  interface PluginState {
30    'prompt-upgrade': {
31      /** The upgrade on screen; null while the pane is closed. */
32      session: UpgradeSession | null
33      /** The last 20 upgrades, newest first. Persisted as `$.store` 'history'. */
34      history: UpgradeEntry[]
35      /** True while automatic mode is on. Persisted as `$.store` 'auto'. */
36      isAuto: boolean
37    }
38  }
39}
40