/prompt-sharpen <prompt> opens a pane that asks a few clarifying questions with recommended answers, then rewrites the prompt clearer and more complete with…

/prompt-sharpen <prompt> opens a pane that walks your prompt through three steps: Questions, Rewrite, Review.
Asks up to three multiple-choice questions about what the prompt leaves open. Each has its recommended answer already picked: change it, type your own with Other, or Skip.
Rewrites the prompt clearer and more complete with the same intent, keeping every detail of the original. It adds only what came from your answers, the conversation, or structure and output format.
[placeholder].Shows the sharpened prompt first, then what was added, with your original as a single line below.
| Action | What it does |
|---|---|
| Run it | Sends it (with a warning and Run anyway while a [placeholder] is left) |
| Edit | Puts it in the prompt to change first |
| Copy | Copies it |
| Send original | Sends what you typed |
| Cancel / Esc | Puts your original back in the prompt |
/plugin install prompt-sharpen --marketplace thitiwats/claude-mods
claude plugin validate plugins/prompt-sharpen
claude plugin test plugins/prompt-sharpen
Grade the questions and rewrites with a real model and a judge model (about $1 a run on Opus; results go to eval/results-<model>.json):
bun plugins/prompt-sharpen/eval/run.ts [--model opus] [--judge opus] [--case <name>]hooks/register.tsx 432 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Answer, Ask, Question, Rewrite, Upgrade } from '../types'
5
6const PANE = 'prompt-sharpen'
7const USAGE = 'Usage: /prompt-sharpen <prompt>'
8
9const MAX_QUESTIONS = 3
10const MAX_OPTIONS = 4
11
12const upgrade = atom({ plugin: 'prompt-sharpen', key: 'upgrade' } as const, null as Upgrade | null)
13
14const IDLE = { status: 'idle' } as const
15const LOADING = { status: 'loading' } as const
16
17// Translated jargon reads oddly to developers writing in Thai and other languages, who keep it in English.
18const LANGUAGE =
19 'Write in the language the prompt is written in, the way a developer who speaks it writes: keep technical terms in English ' +
20 '(code identifiers, file names, commands, tools, libraries, and jargon such as commit, pull request, deploy, refactor, endpoint), never translated or transliterated.'
21
22const questionsPrompt = (original: string) =>
23 [
24 'The user wants to send the prompt below to you, and asked to improve it first.',
25 'Ask at most 3 multiple-choice clarifying questions about what the prompt leaves open that would most change the result.',
26 'Ask one only when the user\'s intent could go two ways that lead to clearly different results, and neither the prompt nor this conversation settles it.',
27 'Never ask about what you could find by reading the code or files, edge cases or implementation details you would sensibly decide yourself, or work the prompt does not mention (tests, docs, error handling).',
28 'When the prompt already says what to do and where, ask none and reply {"questions":[]}: that is the expected answer for a clear prompt.',
29 'Give each question 2 to 4 short options. Put first the option you judge most likely matches what the user means,',
30 'given this conversation: it is shown to them as the recommended one.',
31 `Questions and options: ${LANGUAGE}`,
32 'Reply with JSON only, no markdown: {"questions":[{"question":"...","options":["...","..."]}]}',
33 '',
34 '<prompt>',
35 original,
36 '</prompt>',
37 ].join('\n')
38
39const rewritePrompt = (original: string, answered: string) =>
40 [
41 'Rewrite the user\'s prompt below into a clearer, more complete prompt for you, with the same intent.',
42 'Keep every detail, constraint, name, path, quote, and code snippet of the original; never drop or weaken one.',
43 'Add only what comes from the user\'s answers, from this conversation, or structure and output format.',
44 'Never add work the user did not ask for (tests, docs, secrets, extra checks) or implementation choices (resources, libraries, outputs); if one seems worth suggesting, write it as an assumption.',
45 'Answers marked "unconfirmed" are your own guesses, which the user did not pick: write each as an assumption the user can change, not as a requirement.',
46 'Never invent facts: write anything still unknown as a [placeholder].',
47 LANGUAGE,
48 'Keep it under 300 words, or 1.5 times the original\'s length when that is longer; shorter is fine when the task is simple.',
49 'Also list each detail the rewrite states that the original did not, including those inside examples and steps, a few words each, in the same language.',
50 'Reply with JSON only, no markdown: {"prompt":"...","added":["..."]}',
51 '',
52 '<prompt>',
53 original,
54 '</prompt>',
55 ...(answered ? ['', '<answers>', answered, '</answers>'] : []),
56 ].join('\n')
57
58// Models wrap JSON in a fence or a sentence now and then; read the outermost object.
59const parseJson = (text: string): unknown => {
60 const start = text.indexOf('{')
61 const end = text.lastIndexOf('}')
62 if (start < 0 || end < start) return undefined
63 try {
64 return JSON.parse(text.slice(start, end + 1))
65 } catch {
66 return undefined
67 }
68}
69
70const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
71
72const asQuestions = (text: string): Question[] | undefined => {
73 const list = (parseJson(text) as { questions?: unknown } | undefined)?.questions
74 if (!Array.isArray(list)) return undefined
75 return list
76 .map(q => ({ text: (q as { question?: unknown }).question, options: (q as { options?: unknown }).options }))
77 .filter((q): q is { text: string; options: unknown[] } => isText(q.text) && Array.isArray(q.options))
78 .map(q => ({ text: q.text.trim(), options: q.options.filter(isText).map(o => o.trim()).slice(0, MAX_OPTIONS) }))
79 .filter(q => q.options.length > 0)
80 .slice(0, MAX_QUESTIONS)
81}
82
83const asRewrite = (text: string): Rewrite | undefined => {
84 const reply = parseJson(text) as { prompt?: unknown; added?: unknown } | undefined
85 if (!isText(reply?.prompt)) return undefined
86 return { text: reply.prompt.trim(), added: Array.isArray(reply.added) ? reply.added.filter(isText) : [] }
87}
88
89const answerText = (q: Question, a: Answer | undefined) =>
90 a?.kind === 'option' ? q.options[a.index] : a?.kind === 'other' && a.text.trim() ? a.text.trim() : undefined
91
92const formatAnswers = (questions: Question[], answers: Answer[]) =>
93 questions
94 .map((q, i) => ({ a: answerText(q, answers[i]), guess: answers[i]?.kind === 'option' && answers[i].isGuess, q }))
95 .filter(({ a }) => a !== undefined)
96 .map(({ q, a, guess }) => `Q: ${q.text}\nA: ${a}${guess ? ' (unconfirmed)' : ''}`)
97 .join('\n')
98
99const words = (text: string) => text.split(/\s+/).filter(Boolean).length
100
101const isThai = (text: string) => /[\u0E00-\u0E7F]/.test(text)
102
103const placeholderWarning = (count: number, original: string) =>
104 isThai(original)
105 ? `! ยังเหลือ ${count} [placeholder] ที่ต้องเติม: กด Edit ก่อน หรือ Run anyway`
106 : `! Still has ${count} [placeholder] to fill: use Edit first, or Run anyway`
107
108// The fork sees this conversation; a new or cleared one has nothing to fork, so ask the same model without it.
109const ask = async ($: EngineInterface, prompt: string) => {
110 const forked = await $.model.fork({ prompt })
111 if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return forked
112 return $.model.complete({ model: await $.session.model(), prompt })
113}
114
115// Writes a step's result only while its run is still the one in the pane.
116const settle = ($: EngineInterface, id: number, change: (u: Upgrade) => Upgrade) =>
117 update($, upgrade, u => (u?.id === id ? change(u) : u))
118
119// Reads the answers as they stand now, not as a drawing captured them.
120const rewrite = async ($: EngineInterface, id: number) => {
121 let u: Upgrade | undefined
122 await settle($, id, x => ((u = x), { ...x, rewrite: LOADING }))
123 if (!u) return
124 const questions = u.questions.status === 'ready' ? u.questions.value : []
125 const reply = await ask($, rewritePrompt(u.original, formatAnswers(questions, u.answers)))
126 const value = reply.isAnswered ? asRewrite(reply.text) : undefined
127 const result: Ask<Rewrite> = value
128 ? { status: 'ready', value }
129 : { status: 'failed', reason: reply.isAnswered ? 'unreadable reply' : reply.reason }
130 await settle($, u.id, x => ({ ...x, rewrite: result }))
131}
132
133const askQuestions = async ($: EngineInterface, u: Upgrade) => {
134 await settle($, u.id, x => ({ ...x, questions: LOADING }))
135 const reply = await ask($, questionsPrompt(u.original))
136 const value = reply.isAnswered ? asQuestions(reply.text) : undefined
137 if (!value) {
138 const reason = reply.isAnswered ? 'unreadable reply' : reply.reason
139 await settle($, u.id, x => ({ ...x, questions: { status: 'failed', reason } }))
140 return
141 }
142 // Each question starts on its recommended option, so accepting them all is one press.
143 const ready: Upgrade = { ...u, questions: { status: 'ready', value }, answers: value.map(() => ({ kind: 'option', index: 0, isGuess: true })) }
144 await settle($, u.id, () => ready)
145 if (value.length === 0) await rewrite($, u.id)
146}
147
148// Clears the run before closing, so the close hook knows the pane was not dismissed.
149const finish = async ($: EngineInterface) => {
150 await update($, upgrade, () => null)
151 await $.ui.close({ id: PANE })
152}
153
154export const register: Register = on => {
155 on('session.start', async ($, e, next) => {
156 await $.command.register({
157 name: 'prompt-sharpen',
158 description: 'Ask a few questions, then rewrite a prompt clearer and more complete with the same intent',
159 argumentHint: '<prompt>',
160 })
161 return next(e)
162 })
163
164 on('command.run', { command: 'prompt-sharpen' }, async ($, e) => {
165 const original = e.args.trim()
166 if (!original) return { text: USAGE }
167
168 const previous = await read($, upgrade)
169 const u: Upgrade = { id: (previous?.id ?? 0) + 1, original, questions: IDLE, answers: [], rewrite: IDLE }
170 await update($, upgrade, () => u)
171 const opened = await $.ui.open({ id: PANE, title: 'Prompt upgrade', focus: true, closeOnEscape: true })
172 if (!opened.isPlaced) {
173 await finish($)
174 await $.prompt.fill({ text: original })
175 $.ui.toast(`Prompt upgrade skipped: ${opened.reason}`)
176 return {}
177 }
178 await askQuestions($, u)
179 return {}
180 })
181
182 // Esc or the close mark leaves the original prompt where the user can still send it.
183 on('ui.close', { id: PANE }, async ($, e, next) => {
184 const u = await read($, upgrade)
185 if (u) {
186 await update($, upgrade, () => null)
187 await $.prompt.fill({ text: u.original })
188 }
189 return next(e)
190 })
191
192 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
193 const rest = await next(e)
194 const u = await read($, upgrade)
195 if (!u) return rest
196
197 const elements = $.ui.resolve(e)
198 const { Box, Button, Text } = elements
199 // Mobile draws no text field, so it offers no typed answer.
200 const Input = 'Input' in elements ? elements.Input : undefined
201 const step = u.rewrite.status === 'ready' ? 2 : u.rewrite.status !== 'idle' ? 1 : 0
202 const STEPS = ['Questions', 'Rewrite', 'Review']
203
204 const stats = u.rewrite.status === 'ready' ? `${words(u.original)} → ${words(u.rewrite.value.text)} words · ${u.rewrite.value.added.length} added` : ''
205
206 const steps = (
207 <Box columnGap={1} flexWrap="wrap" justifyContent="space-between">
208 <Box columnGap={1}>
209 {STEPS.map((name, i) => (
210 <Text key={name} color={i < step ? 'success' : i === step ? 'warning' : undefined} dimColor={i > step} bold={i === step}>
211 {name}
212 {i < STEPS.length - 1 ? ' ›' : ''}
213 </Text>
214 ))}
215 </Box>
216 <Text dimColor>{stats}</Text>
217 </Box>
218 )
219
220 // One dim line instead of a box: the user just typed it.
221 const original = (
222 <Text wrap="truncate-end">
223 <Text dimColor>{u.rewrite.status === 'ready' ? 'Original ' : 'Prompt '}</Text>
224 {u.original.replace(/\s+/g, ' ')}
225 </Text>
226 )
227
228 const box = (title: string, text: string) => (
229 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
230 <Text bold color="warning">
231 {title}
232 </Text>
233 <Text>{text}</Text>
234 </Box>
235 )
236
237 const cancel = (
238 <Button
239 key="cancel"
240 plain
241 dimColor
242 label="Cancel"
243 onPress={async () => {
244 await finish($)
245 await $.prompt.fill({ text: u.original })
246 }}
247 />
248 )
249 const sendOriginal = (
250 <Button
251 key="original"
252 plain
253 dimColor
254 label="Send original"
255 onPress={async () => {
256 await finish($)
257 await $.prompt.submit({ text: u.original, asUser: true })
258 }}
259 />
260 )
261
262 const failure = (reason: string, retry: () => Promise<void>) => (
263 <Box flexDirection="column" rowGap={1}>
264 {original}
265 <Text color="error">✗ Could not upgrade the prompt: {reason}</Text>
266 <Box columnGap={1}>
267 <Button key="retry" variant="primary" label="Retry" onPress={retry} />
268 {sendOriginal}
269 {cancel}
270 </Box>
271 </Box>
272 )
273
274 let body: RenderChildren
275 if (u.questions.status === 'failed') {
276 body = failure(u.questions.reason, () => askQuestions($, u))
277 } else if (u.rewrite.status === 'failed') {
278 body = failure(u.rewrite.reason, () => rewrite($, u.id))
279 } else if (u.rewrite.status === 'ready') {
280 const { text, added } = u.rewrite.value
281 const placeholders = text.match(/\[[^\]\n]+\]/g) ?? []
282 body = (
283 <Box flexDirection="column" rowGap={1}>
284 {box('Upgraded prompt', text)}
285 {added.length > 0 && (
286 <Box flexDirection="column">
287 <Text dimColor bold>
288 Added
289 </Text>
290 {added.map((detail, i) => (
291 <Text key={`added-${i}`}>
292 {' '}
293 <Text color="success">+ </Text>
294 {detail}
295 </Text>
296 ))}
297 </Box>
298 )}
299 {placeholders.length > 0 && (
300 <Text color="warning">{placeholderWarning(placeholders.length, u.original)}</Text>
301 )}
302 {original}
303 <Box columnGap={1} flexWrap="wrap">
304 <Button
305 key="run"
306 variant={placeholders.length > 0 ? undefined : 'primary'}
307 label={placeholders.length > 0 ? '▶ Run anyway' : '▶ Run it'}
308 onPress={async () => {
309 await finish($)
310 await $.prompt.submit({ text, asUser: true })
311 }}
312 />
313 <Button
314 key="edit"
315 variant={placeholders.length > 0 ? 'primary' : undefined}
316 label="Edit"
317 onPress={async () => {
318 await finish($)
319 await $.prompt.fill({ text })
320 }}
321 />
322 <Button
323 key="copy"
324 label="Copy"
325 onPress={async press => {
326 const copied = await $.ui.copy({ text, surface: press.surface })
327 $.ui.toast(copied.isCopied ? 'Copied' : `Could not copy: ${copied.reason}`)
328 }}
329 />
330 {sendOriginal}
331 {cancel}
332 </Box>
333 </Box>
334 )
335 } else if (u.rewrite.status === 'loading') {
336 body = (
337 <Box flexDirection="column" rowGap={1}>
338 {original}
339 <Text bold>
340 <Text color="warning">◆</Text> Writing your upgraded prompt… <Text dimColor>(a few seconds)</Text>
341 </Text>
342 <Box>{cancel}</Box>
343 </Box>
344 )
345 } else if (u.questions.status === 'ready') {
346 const questions = u.questions.value
347 const pick = (i: number, answer: Answer) =>
348 update($, upgrade, x => (x?.id === u.id ? { ...x, answers: x.answers.map((a, j) => (j === i ? answer : a)) } : x))
349 body = (
350 <Box flexDirection="column" rowGap={1}>
351 {original}
352 <Text dimColor bold>
353 Answer a few questions <Text dimColor>· recommended is picked; change any, or skip</Text>
354 </Text>
355 {questions.map((q, i) => {
356 const a = u.answers[i]
357 return (
358 <Box key={`q${i}`} flexDirection="column">
359 <Text bold>
360 <Text color="warning">{i + 1}</Text> {q.text}
361 </Text>
362 <Box flexDirection="column" paddingLeft={2}>
363 {q.options.map((option, j) => (
364 <Box key={`o${j}`} columnGap={1} flexWrap="wrap">
365 <Button
366 key={`q${i}-${j}`}
367 plain
368 label={`${a?.kind === 'option' && a.index === j ? '◉' : '○'} ${option}`}
369 onPress={() => pick(i, { kind: 'option', index: j })}
370 />
371 {j === 0 && <Text dimColor>recommended</Text>}
372 </Box>
373 ))}
374 {Input && (
375 <Button
376 key={`q${i}-other`}
377 plain
378 label={`${a?.kind === 'other' ? '◉' : '○'} Other…`}
379 onPress={() => pick(i, { kind: 'other', text: a?.kind === 'other' ? a.text : '' })}
380 />
381 )}
382 <Button
383 key={`q${i}-skip`}
384 plain
385 dimColor
386 label={`${a?.kind === 'skip' ? '◉' : '○'} Skip`}
387 onPress={() => pick(i, { kind: 'skip' })}
388 />
389 </Box>
390 {Input && a?.kind === 'other' && (
391 <Box paddingLeft={2}>
392 <Input
393 key={`q${i}-text`}
394 placeholder="Type your answer"
395 value={a.text}
396 submitLabel="keep"
397 onInput={text => pick(i, { kind: 'other', text })}
398 onSubmit={text => pick(i, { kind: 'other', text })}
399 />
400 </Box>
401 )}
402 </Box>
403 )
404 })}
405 <Box columnGap={1}>
406 <Button key="upgrade" variant="primary" label="Upgrade now" onPress={() => rewrite($, u.id)} />
407 {cancel}
408 </Box>
409 </Box>
410 )
411 } else {
412 body = (
413 <Box flexDirection="column" rowGap={1}>
414 {original}
415 <Text dimColor>Reading your prompt…</Text>
416 <Box>{cancel}</Box>
417 </Box>
418 )
419 }
420
421 return (
422 <Box flexDirection="column">
423 {rest}
424 <Box flexDirection="column" rowGap={1}>
425 {steps}
426 {body}
427 </Box>
428 </Box>
429 )
430 })
431}
432types/index.d.ts 33 lines1/** A model call the pane waits on: idle, running, its value, or why it failed. */
2export type Ask<T> =
3 | { status: 'idle' }
4 | { status: 'loading' }
5 | { status: 'ready'; value: T }
6 | { status: 'failed'; reason: string }
7
8/** A clarifying question; its first option is the recommended option. */
9export type Question = { text: string; options: string[] }
10
11/** The user's answer to one clarifying question: an option by index (`isGuess` while it is still the recommended one nobody picked), their own text, or skipped. */
12export type Answer = { kind: 'option'; index: number; isGuess?: boolean } | { kind: 'other'; text: string } | { kind: 'skip' }
13
14/** The upgraded prompt and the added details it states that the original prompt did not. */
15export type Rewrite = { text: string; added: string[] }
16
17/** One run of /prompt-sharpen, from the original prompt to the upgraded one; `id` tells a stale reply from the current run. */
18export type Upgrade = {
19 id: number
20 original: string
21 questions: Ask<Question[]>
22 answers: Answer[]
23 rewrite: Ask<Rewrite>
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'prompt-sharpen': {
29 upgrade: Upgrade | null
30 }
31 }
32}
33