Enhance button above the prompt: Sonnet sharpens your draft using the recent conversation

<h1 align="center">cc-prompt-enhance-mod</h1>
<b>One click turns a vague draft into a precise prompt, without making it longer than it needs to be.</b><br> A <a href="https://code.claude.com/docs/en/plugins/mods/overview">Claude Code mod</a> that adds an <b>Enhance</b> button above the prompt.
<img src="https://img.shields.io/badge/Claude%20Code-%E2%89%A5%202.1.287-d97757?style=flat-square" alt="Claude Code 2.1.287 or later"> <img src="https://img.shields.io/badge/model-Sonnet-6a5acd?style=flat-square" alt="Uses Sonnet"> <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
<img src="assets/demo.gif" width="880" alt="A Claude Code session where Claude has explained a failing test: create_user reads payload['mail'] but the test passes 'email'. The draft 'fix that error so the test pases' is typed and Ctrl+E is pressed. A rainbow flows across the draft while Sonnet works, then the draft becomes a precise prompt naming the KeyError, app/users.py:2 and test_create_user">
You type fix that error so the test pases. Claude already knows which error you mean, but your prompt doesn't say. Enhance asks Sonnet to fill that in:
- fix that error so the test pases
+ fix the `KeyError: 'mail'` in `app/users.py:2` so `tests.test_users.TestUsers.test_create_user` passes
Run these as two separate prompts in Claude Code:
/plugin marketplace add yibudak/cc-prompt-enhance-mod
/plugin install cc-prompt-enhance-mod@cc-prompt-enhance-mod
Then run /reload-plugins, or start a new session. The button appears once the draft has at least two words and ten characters.
A plugin can't add key bindings, so add this to ~/.claude/keybindings.json. Run /keybindings to open the file, and merge the entry if you already have bindings:
{
"bindings": [
{ "context": "Global", "bindings": { "ctrl+e": "app:cycleDiffBase" } }
]
}
app:cycleDiffBase is used only by the /diff panel, so it's free at the prompt. While /diff is open, Ctrl+E goes to the panel.Chat context, the prompt's own Ctrl+E (move to end of line) wins. Global doesn't have that problem.Without the binding, press Ctrl+X Tab, then E. In fullscreen rendering (/tui), you can also click the button.
| | | | :- | :- | | Model | sonnet, through your own Claude Code session and account. The alias resolves on Anthropic, Bedrock and Vertex. | | What's sent | The system prompt in hooks/register.tsx, your draft, and the session's last 10 messages. Each message is capped at 2,000 characters and the whole context at 12,000. Tool calls are listed by name and file path or command. | | Your draft | Replaced with the edit. If you kept typing while Sonnet answered, your version stays. | | Revert | Brings back the original. Editing the text or sending it clears Revert. |
A mod runs with your permissions, so check what it reaches before you install it:
claude plugin validate .claude-plugin/plugin.json
It reads the conversation and the prompt box, fills the prompt box, makes one model call per press and shows toasts. It doesn't read files, start processes or make other network requests.
git clone https://github.com/yibudak/cc-prompt-enhance-mod
claude --plugin-dir ./cc-prompt-enhance-mod # loads it and hot-reloads on save
claude plugin validate ./cc-prompt-enhance-mod/.claude-plugin/plugin.json
claude plugin test ./cc-prompt-enhance-mod
cd cc-prompt-enhance-mod && vhs scripts/demo.tape # re-records assets/demo.gif
Claude Code writes the API types to .claude-plugin/types/ when it loads the mod. That folder is ignored by git.
MIT © Yigit Budak
hooks/register.tsx 296 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4// An alias, so it resolves on every provider (Anthropic, Bedrock, Vertex)
5const MODEL = 'sonnet'
6const RECENT_MESSAGES = 10
7const MESSAGE_CHARS = 2000
8const CONTEXT_CHARS = 12000
9
10const hasDraft = atom({ plugin: 'cc-prompt-enhance-mod', key: 'hasDraft' } as const, false)
11const isBusy = atom({ plugin: 'cc-prompt-enhance-mod', key: 'isBusy' } as const, false)
12const previous = atom({ plugin: 'cc-prompt-enhance-mod', key: 'previous' } as const, null)
13
14const SYSTEM = `You rewrite a developer's draft prompt for Claude Code into the prompt they would have written with more time and the whole session in view.
15The result is sent to Claude Code as is, in place of the draft, so it has to stand on its own: a teammate who missed the conversation should be able to act on it without asking anything. Claude does its best work from a clear goal, the reason behind it, concrete references and a visible finish line, so those are what you add.
16
17You get the recent conversation, then the draft.
18
19<how_to_enhance>
201. Keep the author's intent, scope and language. The draft says what they want; the conversation supplies the specifics.
212. Resolve vague references ("this", "that file", "the error", "like before") to the exact path, symbol, command, error message or earlier decision the conversation points to. Put code, paths and commands in backticks.
223. State the goal plainly. When the conversation shows why it matters, add the reason in a clause: a reason lets Claude make better judgment calls than a bare rule.
234. When the conversation shows how to tell the work is done (a test, a command, an observable behavior), name it in one sentence. Claude plans its own checks, so a finish line is enough; a test plan is not needed.
245. Carry over constraints the conversation already settled, such as files to leave alone, an approach that was rejected, or a style or version to match, so Claude doesn't reopen them.
256. Write a request with several parts as a short numbered list in the order the work should happen. Keep a single ask as one or two sentences of prose.
267. Fix typos and grammar, and phrase instructions as what to do rather than what to avoid.
27</how_to_enhance>
28
29<boundaries>
30- Every sentence you add traces back to the draft or the conversation. Where the draft leaves a real choice open and the conversation doesn't settle it, leave it open or turn it into a question for Claude to raise; the choice is the author's.
31- Leave out filler that Claude Code already acts on by default, because it adds length and extra work without changing the result: role lines ("You are an expert..."), "think step by step", "double-check your work", "be thorough", and requests to explain its reasoning in the reply.
32- Write in a calm, plain tone, without capital-letter emphasis or words like CRITICAL and MUST; Claude follows plain instructions closely and overreacts to emphasis.
33- Let the length follow the content: a resolved one-liner can stay one line, and a vague multi-part draft can grow into a short list. A draft that is already specific comes back with only typo fixes.
34</boundaries>
35
36<output_format>
37Output only the enhanced prompt, in the author's voice, as the message they will send: no preamble, no explanation, no quotes, and no code fence around it.
38</output_format>
39
40<examples>
41<example>
42<conversation>
43[user] run the tests
44(tools: Bash python -m pytest)
45
46[assistant] 1 failed: tests/test_users.py::TestUsers::test_create_user raised KeyError: 'mail' at app/users.py:2, where create_user reads data['mail'] but the test posts 'email'.
47</conversation>
48<draft>
49fix that error so the test pases
50</draft>
51<enhanced>
52Fix the \`KeyError: 'mail'\` in \`create_user\` at \`app/users.py:2\`: it reads \`data['mail']\` but the test posts \`email\`. Done means \`tests/test_users.py::TestUsers::test_create_user\` passes under \`python -m pytest\`.
53</enhanced>
54</example>
55
56<example>
57<conversation>
58[user] the settings page takes ages to load
59(tools: Read src/pages/Settings.tsx; Read src/api/prefs.ts)
60
61[assistant] SettingsPage in src/pages/Settings.tsx calls fetchUserPrefs once per toggle on mount, 14 requests in all. src/api/prefs.ts already has fetchPrefsBatch, which hits GET /api/prefs?keys= in one request.
62
63[user] don't touch anything under src/api, the backend team owns it
64</conversation>
65<draft>
66ok fix it and add a test
67</draft>
68<enhanced>
69Make \`SettingsPage\` in \`src/pages/Settings.tsx\` load faster. It currently calls \`fetchUserPrefs\` once per toggle on mount, 14 requests in all.
701. Load all preferences in one request with the existing \`fetchPrefsBatch\` from \`src/api/prefs.ts\`.
712. Add a test that shows the page makes a single preferences request on mount.
72
73Leave everything under \`src/api\` as it is; the backend team owns it.
74</enhanced>
75</example>
76
77<example>
78<conversation>
79[user] añade exportación a CSV en la tabla de pedidos
80(tools: Read src/components/OrdersTable.vue)
81
82[assistant] La tabla está en src/components/OrdersTable.vue. ¿Exporto solo las filas filtradas o todas?
83</conversation>
84<draft>
85solo las filtradas, con un boton igual al de imprimir
86</draft>
87<enhanced>
88Añade la exportación a CSV en \`src/components/OrdersTable.vue\`, exportando solo las filas que quedan tras aplicar los filtros actuales. Usa un botón con el mismo estilo y ubicación que el botón de imprimir de esa tabla.
89</enhanced>
90</example>
91
92<example>
93<conversation>
94(none yet)
95</conversation>
96<draft>
97Rename getUser to fetchUser in src/api/users.ts and update every caller.
98</draft>
99<enhanced>
100Rename \`getUser\` to \`fetchUser\` in \`src/api/users.ts\` and update every caller.
101</enhanced>
102</example>
103</examples>`
104
105// The one gate for the button and enhancing: two or more words and ten or more characters
106export const isEnhanceable = (text: string) => text.trim().split(/\s+/).length >= 2 && text.trim().length >= 10
107
108// The hue wheel walked one level at a time along the edges of the xterm
109// 256-color cube, so neighbors blend and a 256-color terminal shows the same
110// colors as a truecolor one instead of rounding a gradient into uneven steps
111const UP = [0, 95, 135, 175, 215]
112const DOWN = [255, 215, 175, 135, 95]
113const WHEEL = [
114 ...UP.map(v => [255, v, 0]), // red to yellow
115 ...DOWN.map(v => [v, 255, 0]), // yellow to green
116 ...UP.map(v => [0, 255, v]), // green to cyan
117 ...DOWN.map(v => [0, v, 255]), // cyan to blue
118 ...UP.map(v => [v, 0, 255]), // blue to magenta
119 ...DOWN.map(v => [255, 0, v]), // magenta back to red
120]
121
122// WCAG relative luminance of an [r, g, b] color
123const luminance = (rgb: number[]) => {
124 const [r = 0, g = 0, b = 0] = rgb.map(v => {
125 const c = v / 255
126 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
127 })
128 return 0.2126 * r + 0.7152 * g + 0.0722 * b
129}
130
131// Each background with black or white text, whichever contrasts more
132const RAINBOW = WHEEL.map(rgb => {
133 const l = luminance(rgb)
134 return {
135 backgroundColor: '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join(''),
136 color: (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) ? '#000000' : '#ffffff',
137 }
138})
139const FRAME_MS = 100
140
141// One background per character, shifted one step right each frame
142// ponytail: one decoration per character, group runs if long drafts lag
143export const rainbow = (text: string, frame: number) =>
144 Array.from({ length: text.length }, (_, i) => ({
145 start: i,
146 end: i + 1,
147 ...RAINBOW[(i - (frame % RAINBOW.length) + RAINBOW.length) % RAINBOW.length],
148 }))
149
150const clip = (text: string, max: number) => (text.length > max ? text.slice(0, max) + '…' : text)
151
152const describe = (m: SessionMessage) => {
153 const tools = m.toolUses.map(u => {
154 const target = u.input.file_path ?? u.input.command ?? u.input.path
155 return typeof target === 'string' ? `${u.tool} ${clip(target, 200)}` : u.tool
156 })
157 const text = clip(m.text, MESSAGE_CHARS)
158 return `[${m.role}] ${text}${tools.length ? `\n(tools: ${tools.join('; ')})` : ''}`
159}
160
161const conversation = async ($: EngineInterface) => {
162 const rows = (await $.session.messages()).filter(m => m.text.trim() !== '').slice(-RECENT_MESSAGES)
163 const text = rows.map(describe).join('\n\n')
164 return text.length > CONTEXT_CHARS ? '…' + text.slice(-CONTEXT_CHARS) : text
165}
166
167const unfence = (text: string) => text.trim().replace(/^```[^\n]*\n([\s\S]*?)\n```$/, '$1').trim()
168
169const enhance = async ($: EngineInterface) => {
170 if (await read($, isBusy)) return
171 const draft = (await $.prompt.read()).text
172 if (!isEnhanceable(draft)) {
173 // The next edit's answer carries no decorations, so the red clears itself
174 await $.prompt.fill({ text: draft, decorations: [{ start: 0, end: draft.length, backgroundColor: 'red' }] })
175 $.ui.toast('This prompt is too short to enhance')
176 return
177 }
178 await update($, isBusy, () => true)
179 try {
180 let isDone = false
181 // Repaints only while the box still holds the draft, so typing stops it
182 // ponytail: a key typed between the read and the fill is lost, a paint-only API would fix it
183 const painting = (async () => {
184 for (let frame = 0; !isDone; frame++) {
185 if ((await $.prompt.read()).text === draft) await $.prompt.fill({ text: draft, decorations: rainbow(draft, frame) })
186 await $.clock.sleep(FRAME_MS)
187 }
188 })()
189
190 const r = await $.model.complete({
191 model: MODEL,
192 system: SYSTEM,
193 prompt: `<conversation>\n${(await conversation($)) || '(none yet)'}\n</conversation>\n\n<draft>\n${draft}\n</draft>`,
194 maxTokens: 4096,
195 timeoutMs: 60000,
196 }).finally(() => {
197 // Stopped before the result lands, so no late frame paints over it
198 isDone = true
199 return painting
200 })
201 if (!r.isAnswered) {
202 const detail = r.reason === 'api-error' ? ` (${r.status ?? 'no response'}, ${r.error})` : ''
203 $.ui.toast(`Enhance failed: ${r.reason}${detail}`)
204 return
205 }
206
207 const text = unfence(r.text)
208 if ((await $.prompt.read()).text !== draft) {
209 $.ui.toast('Draft changed while enhancing; kept your version')
210 return
211 }
212 const filled = await $.prompt.fill({ text, mode: 'replace' })
213 if (!filled.isFilled) {
214 $.ui.toast('Could not replace the draft')
215 return
216 }
217 await update($, previous, () => draft)
218 await update($, hasDraft, () => isEnhanceable(filled.text))
219 } finally {
220 // Clears the rainbow on every path that left the draft in the box
221 if ((await $.prompt.read()).text === draft) await $.prompt.fill({ text: draft })
222 await update($, isBusy, () => false)
223 }
224}
225
226const revert = async ($: EngineInterface) => {
227 const text = await read($, previous)
228 if (text === null) return
229 const filled = await $.prompt.fill({ text, mode: 'replace' })
230 if (!filled.isFilled) return
231 await update($, previous, () => null)
232 await update($, hasDraft, () => isEnhanceable(text))
233}
234
235export const register: Register = on => {
236 on('session.start', async ($, e, next) => {
237 const text = (await $.prompt.read()).text
238 await update($, hasDraft, () => isEnhanceable(text))
239 return next(e)
240 })
241
242 on('prompt.edit', async ($, e, next) => {
243 const r = await next(e)
244 if (r.text === e.text) return r
245 const draft = isEnhanceable(r.text)
246 if (draft !== (await read($, hasDraft))) {
247 await update($, hasDraft, () => draft)
248 }
249 // Editing the enhanced text accepts it: the next press enhances again
250 if ((await read($, previous)) !== null) {
251 await update($, previous, () => null)
252 }
253 return r
254 })
255
256 on('prompt.submit', async ($, e, next) => {
257 if (e.origin.kind === 'composer') {
258 await update($, hasDraft, () => false)
259 await update($, previous, () => null)
260 }
261 return next(e)
262 })
263
264 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
265 const busy = await read($, isBusy)
266 const draft = await read($, hasDraft)
267 // Revert stays reachable even if the enhanced text is blank
268 const canRevert = (await read($, previous)) !== null
269 if (e.props.hasSurvey || (!busy && !draft && !canRevert)) return next(e)
270
271 const { Box, Button, Text } = $.ui.resolve(e)
272 if (busy) {
273 return (
274 <Box>
275 <Text dimColor>Enhancing with Sonnet…</Text>
276 </Box>
277 )
278 }
279
280 // ctrl+e is bound to this action in ~/.claude/keybindings.json
281 const shortcut = e.surface === 'terminal' ? ' ⌃E' : ''
282 return (
283 <Box>
284 <Button
285 key="enhance"
286 label={(canRevert ? 'Revert' : 'Enhance') + shortcut}
287 hotkey="e"
288 action="app:cycleDiffBase"
289 variant={canRevert ? 'secondary' : 'primary'}
290 onPress={() => (canRevert ? revert($) : enhance($))}
291 />
292 </Box>
293 )
294 })
295}
296types/index.d.ts 8 lines1export type Enhanced = { previous: string | null }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cc-prompt-enhance-mod': { hasDraft: boolean; isBusy: boolean; previous: Enhanced['previous'] }
6 }
7}
8