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…

Install (Claude Code 2.1.304 or later): /plugin install prompt-upgrade --marketplace samuelbfernandes/moddeck
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.
[placeholders] where something is unknown./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.
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.
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.
auto) and the last 20 upgrades (history).haiku and sonnet.hooks/register.tsx 479 lines1// 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}
479hooks/lib.ts 190 lines1// 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
190types/index.d.ts 40 lines1/** 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