SLOPSHOPPER

cc-prompt-enhance-mod

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

newbandtoastpromptmodel
v0.1.0MITupdated 2026-10-09yibudak/cc-prompt-enhance-mod
A shopper browsing a rack in a slop shop
README

<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
  • Grounded edits. It fixes typos, resolves "this", "that file" or "the error" from the recent conversation, names what "done" looks like, carries over constraints you already settled, and splits multi-part asks into steps. Everything it adds comes from your draft or the conversation; choices you left open stay open.
  • Leaves clear drafts alone. If there's nothing to resolve, your draft comes back unchanged, in the language you wrote it in.
  • One key, both ways. Ctrl+E enhances. Press it again to revert.

Install

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.

Ctrl+E (one-time setup)

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" } }
  ]
}
  • A mod's button can only be pressed through an existing Claude Code action. 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.
  • In the Chat context, the prompt's own Ctrl+E (move to end of line) wins. Global doesn't have that problem.
  • While the button shows, Ctrl+E doesn't move to the end of the line. Use End instead.

Without the binding, press Ctrl+X Tab, then E. In fullscreen rendering (/tui), you can also click the button.

How it works

| | | | :- | :- | | 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. |

Requirements

  • Claude Code v2.1.287 or later, in a terminal
  • Not tested in the Desktop app's Code tab. The app draws its own prompt box, and the mods API says a mod can't read or fill a box like that, so expect it not to work there.

Permissions

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.

Development

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.

License

MIT © Yigit Budak

Source 2 files
hooks/register.tsx 296 lines
1import { 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}
296
types/index.d.ts 8 lines
1export 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