Catches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs.

<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399?style=flat-square"></a> <img alt="Claude Code 2.1.300+" src="https://img.shields.io/badge/Claude%20Code-2.1.300%2B-818cf8?style=flat-square"> <img alt="mods" src="https://img.shields.io/badge/mods-4-f472b6?style=flat-square">
A collection of small mods for Claude Code. Each mod is its own plugin, so you install only the ones you want.
Mods use Claude Code's hooks-module API, which is early access and may change between releases. They were built and tested on Claude Code 2.1.300.
| Mod | What it does |
|---|---|
| autoroute | Picks the model and effort for each prompt, sends subagents to cheaper models, and keeps Fable and extra-usage spend in check. |
| prompt-upgrade | Catches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs. |
| box-tools | A filled ✦ Tools button at the right end of the prompt footer. Click it for a small box where you switch the model, the effort, and each of your daymods on or off. |
| pace-statusline | A 4-line status line with limit bars, reset times and a pace prediction (⚠ runs out today 4:19pm) so you know before you hit the wall. Lives in its own repo. |
More mods will be added here as they are ready.
Add the marketplace once, then install any mod from it.
In a Claude Code session:
/plugin install autoroute --marketplace daydeda/daymods
Or from a shell:
claude plugin marketplace add daydeda/daymods
claude plugin install autoroute@daymods
claude plugin install prompt-upgrade@daymods
claude plugin install box-tools@daymods
claude plugin install pace-statusline@daymods
Answer y when asked to add the marketplace and pick a scope. The mod is active right away. Mods installed at the user scope load in every later session.
To remove one: claude plugin uninstall autoroute.
Routes each prompt to a suitable model and effort, so small jobs stop burning Opus tokens and big jobs get a plan.
| Tier | Typical prompts | Model | Effort |
|---|---|---|---|
| trivial | a question, typo, rename, quick lookup | Haiku | low |
| normal | a feature or fix in 1-3 files | Sonnet | medium |
| hard | tricky debugging, design, refactors | Opus | high |
| large | migrations, rewrites, architecture | Opus | high, plus a delegation plan |
Short follow-ups like "continue" or "yes" keep the previous route.
flowchart LR
A([your prompt]) --> B{rules}
B -->|clear| D[tier]
B -->|unclear| C[Haiku classifier] --> D
D --> E{plan and spend gate}
E -->|Fable, not Max| F[Opus]
E -->|limit near, extra usage on| G[Sonnet cap]
E -->|ok| H[chosen model]
These apply to every request, whatever chose its model, and stay on even when routing is turned off.
~/.claude.json). Anything other than a Max plan, or a plan it cannot read, is downgraded from Fable to Opus. This covers the router, !fable, Fable picked in /model, and Fable subagents.The plan check reads a local file the user can edit. It prevents accidental charges; it is not a security boundary.
~/.claude/autoroute.state, and a status line script can show it inline. pace-statusline does: Opus ▶ high ⇢ Haiku·low ▶ ⌂ daymods, plus a ⚠ note for downgrades or spend warnings. The ⇢ part is hidden once the running model matches the route. Set display to line to get autoroute's own row instead, for example autoroute: Haiku · low · "typo" (rules).STATUSLINE_COMPACT=1 folds ctx, 5-hour, weekly and session stats into one row with short bars. The reset countdown shows only past 50% used.!haiku, !sonnet, !opus, !fable or !max. !auto routes normally./route on|off|status: turn routing on or off, or show the last route, your plan, and the spend guard state./config):classifier: rules+haiku (default) or rules only. Rules-only makes no model call.display: statusline (default) writes the route for your status line to show; line shows autoroute's own row.spendGuardPercent: threshold for the spend guard, default 90.! overrides, or /route status to see why a route was chosen.mods/autoroute/hooks/route.ts. If your Claude Code build uses different IDs, change them there.Turns a vague prompt like "make a todo app" into a clear one before Claude starts, and lets you check it first.
The panel opens beside the transcript (or above the prompt). A step bar at the top shows where you are: ✓ Your prompt → ● Questions 2/3 → ○ Rewrite → ○ Review. Keys:
1-3 pick a choice, the next number types your own answer, s skips the question, x skips the rest and rewrites now, b goes back to the previous question (your earlier answer is marked ✓).1-4 as above, 5 asks for a change, b goes back to the questions with your answers kept, then rewrites again.Esc sends your prompt as typed. Back, Skip the rest and Esc sit together in a bar at the bottom of the panel.Each question is its own card (QUESTION 2 OF 3, one dot per question). The panel uses Claude's orange for what is current and green for what is done, and opens at about 60% of the terminal width once you have run /refine once (100 columns before that).
Typing your own answer. Claude Code's text field is one line and cuts long text off, so the panel draws its own wrapped text box instead. Press the typing key (or click the box), type, and press Enter. It supports typing, Backspace and Ctrl+U to clear; there is no cursor movement. It is not available in the VS Code and mobile layouts, which only get the numbered choices and Skip.
The panel needs a terminal about 110 columns wide; on a narrower one it falls back to Claude Code's question dialog, with the rewrite shown inside the question. Set ui to dialog to always use the dialog.
Each rewrite picks one working method and writes its step into the prompt:
| Method | Idea | What the prompt asks for |
|---|---|---|
| Direct | prompt engineering | A clear spec and nothing else, for small well-defined changes. |
| Context first | context engineering | Read specific code, docs or data before deciding. |
| Plan first | plan, then build | A short plan and a pause for your OK before editing, for big or open-ended work. |
| Verify loop | loop engineering | A named check (tests, build, lint) and iterating until it passes, stopping after 3 failed tries. |
| Make it a rule | harness engineering | Do the task now, then suggest making it permanent as a CLAUDE.md rule, hook, skill or /loop. |
/refine <prompt>: upgrade one prompt on demand, whatever the mode./refine auto|always|off: when it steps in. auto (default) handles vague prompts only, always every new task you type, off only /refine <prompt>./refine status: the mode, and why the last prompt was or wasn't upgraded./config): mode, detector (rules+haiku or rules alone), ui (panel or dialog), and rewriteModel (sonnet by default, or haiku or opus).!opus-style autoroute override is kept in front of the rewritten prompt.A filled orange ✦ Tools pill sits at the right end of the prompt footer. Click it and a small box opens beside the transcript:
✦ Tools Opus · high
╭─────────────────────────────────────────────────────────╮
│ ◆ Model 1-5 │
│ 1: ○ Default 2: ○ Haiku 3: ○ Sonnet 4: ● Opus 5: ○ Fable │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ⚡ Effort q w e r t │
│ q: ○ low w: ○ medium e: ● high r: ○ xhigh t: ○ max │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ❖ Mods a s d … │
│ a: ● on autoroute │
╰─────────────────────────────────────────────────────────╯
✦ Tools, or run /tools. Clicking the button again closes the box.1-5 for models, q w e r t for effort, a s d… for mods.x, or the button again.model row of /config, the same as picking it there. It becomes your default for new sessions too./effort <level>, as if you typed it. Claude Code saves the level for the current model, so it carries over to new sessions.claude plugin enable or claude plugin disable, then /reload-plugins, so the change applies right away and stays on or off in later sessions.--plugin-dir aren't installed, so they don't appear in the list./config): button (show or hide). With hide, the footer is left alone and /tools opens the box.Each mod lives in mods/<name>/ with the standard layout:
mods/<name>/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.ts
└── tests/
Check and test a mod with:
claude plugin validate mods/autoroute
claude plugin test mods/autoroute
Try one without installing it: claude --plugin-dir mods/autoroute.
hooks/register.tsx 517 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 ASK_SYSTEM, METHODS, REWRITE_SYSTEM,
6 askPrompt, judge, methodLine, parseQuestions, parseRewrite, rewritePrompt, splitPrefix,
7} from './upgrade'
8import type { Answer, Background, Mode, Question, Rewrite, Verdict } from './upgrade'
9import type { View } from '../types'
10
11const MODE_KEY = 'mode'
12const PANE = 'refine'
13
14// Two colours and the default foreground: the accent (the theme's own Claude orange) marks where you are and what to act on,
15// green marks what is done. Plain terminal colour names, so they read on any theme.
16const ACCENT = 'claude' // Claude's orange; titles, frames, current step, keys
17const DONE = 'green' // finished steps, the upgraded prompt
18const REVIEW_QUESTION = 'Run this upgraded prompt?'
19const MAX_REVISIONS = 3
20
21let configMode: Mode = 'auto'
22let mode: Mode = 'auto'
23let isModeStored = false
24let useHaiku = true
25let wantsPane = true
26let rewriteModel = 'sonnet'
27let last: { request: string; verdict: string; method?: string } | undefined
28// Set when the box was refilled: what the person sends from it next is theirs, not upgraded again.
29let isRefilled = false
30// A panel flow is under way: a second one waits its turn.
31let isBusy = false
32// The terminal's width, learnt from /refine; the panel opens at about 60% of it.
33let termColumns = 0
34
35type Outcome =
36 | { kind: 'pass' } // send what was typed
37 | { kind: 'run'; text: string }
38 | { kind: 'edit'; text: string }
39 | { kind: 'cancel' }
40
41async function background($: EngineInterface): Promise<Background> {
42 const b: Background = {}
43 try {
44 b.cwd = await $.session.cwd()
45 const entries = await $.fs.list(b.cwd)
46 b.entries = entries.map(e => e.name).filter(n => !n.startsWith('.')).slice(0, 40)
47 } catch {
48 // The folder is a hint, not a requirement.
49 }
50 try {
51 const messages = await $.session.messages()
52 b.recent = messages
53 .filter(m => m.text.trim())
54 .slice(-6)
55 .map(m => ({ role: m.role, text: m.text.slice(0, 600) }))
56 } catch {
57 // Same for the conversation.
58 }
59 return b
60}
61
62const SKIP = 'Skip'
63const LABELS = { run: 'Run it', edit: 'Edit it myself', original: 'Use my original', cancel: 'Cancel' } as const
64const stripRecommended = (s: string) => s.replace(/\s*\(Recommended\)\s*$/i, '')
65
66type Pick = { pick: keyof typeof LABELS | 'back' } | { change: string }
67
68// Panel-only answers besides a choice or typed text.
69const BACK = '\u0000back'
70const SKIP_REST = '\u0000rest'
71const CLOSED = '\u0000closed'
72
73// Where the conversation happens: the engine's question dialog, or the side panel.
74// ask() gives the answer, BACK, SKIP_REST, or undefined when the person closed it.
75type Ui = {
76 ask: (q: Question, index: number, total: number, answered?: string) => Promise<string | undefined>
77 review: (r: Rewrite, round: number, canGoBack: boolean) => Promise<Pick>
78 working: (note: string, stage: number) => Promise<void>
79}
80
81function toPick(answer: string): Pick {
82 if (!answer) return { pick: 'cancel' }
83 if (answer === BACK) return { pick: 'back' }
84 for (const [pick, label] of Object.entries(LABELS)) if (answer === label) return { pick: pick as keyof typeof LABELS }
85 // Typed under "Other" / the text field: what to change.
86 return { change: answer }
87}
88
89// The engine's own question dialog: pick one of the choices, type under "Other", or Skip.
90// The rewrite sits in the question text itself, so it is read before anything runs.
91// It has no room for Back or Skip the rest: those are the panel's.
92const dialogUi = ($: EngineInterface): Ui => ({
93 ask: q => $.ui.ask(q.question, { options: [...q.options, SKIP], header: q.header }).catch(() => undefined),
94 review: async r =>
95 toPick(
96 await $.ui
97 .ask(`Method: ${methodLine(r)}\n\n${r.prompt}\n\n${REVIEW_QUESTION}`, { options: Object.values(LABELS), header: 'Review' })
98 .then(a => a.trim(), () => ''),
99 ),
100 working: async () => undefined,
101})
102
103const IDLE: View = {
104 step: 'idle', note: '', request: '', header: '', question: '', options: [], method: '', draft: '', round: 0,
105 stage: 0, index: 0, total: 0, answered: '',
106}
107const view = atom({ plugin: 'prompt-upgrade', key: 'view' } as const, IDLE)
108
109// The panel's buttons and field call answer(); the flow awaits waitFor().
110let pending: ((value: string) => void) | undefined
111const answer = (value: string) => {
112 const resolve = pending
113 pending = undefined
114 resolve?.(value)
115}
116const waitFor = () => new Promise<string>(resolve => (pending = resolve))
117
118const paneUi = ($: EngineInterface, request: string): Ui => ({
119 working: async (note, stage) => void (await update($, view, (v): View => ({ ...v, step: 'working', note, request, stage }))),
120 ask: async (q, index, total, answered) => {
121 await update($, view, (v): View => ({
122 ...v, step: 'ask', stage: 1, request, header: q.header, question: q.question, options: q.options,
123 index, total, answered: answered ?? '',
124 }))
125 const a = await waitFor()
126 return a === CLOSED ? undefined : a
127 },
128 review: async (r, round, canGoBack) => {
129 await update($, view, (v): View => ({ ...v, step: 'review', stage: 3, request, method: methodLine(r), draft: r.prompt, round, total: canGoBack ? v.total : 0 }))
130 const a = await waitFor()
131 return toPick(a === CLOSED ? '' : a.trim())
132 },
133})
134
135/**
136 * Asks the questions from `startAt`, keeping earlier answers so Back can show them.
137 * Back steps to the previous question; Skip the rest leaves the remaining ones open.
138 */
139async function askQuestions(ui: Ui, questions: Question[], answers: Answer[], startAt = 0): Promise<Answer[] | 'dismissed'> {
140 const out = questions.map((q, i) => answers[i] ?? { question: q.question })
141 let i = startAt
142 while (i < questions.length) {
143 const q = questions[i]!
144 const picked = await ui.ask(q, i, questions.length, out[i]?.answer)
145 if (picked === undefined) return 'dismissed'
146 if (picked === BACK) {
147 i = Math.max(0, i - 1)
148 continue
149 }
150 if (picked === SKIP_REST) {
151 for (let k = i; k < questions.length; k++) out[k] = { question: questions[k]!.question }
152 break
153 }
154 const a = picked.trim()
155 out[i] = { question: q.question, answer: a && a !== SKIP ? stripRecommended(a) : undefined }
156 i++
157 }
158 return out
159}
160
161async function rewrite($: EngineInterface, request: string, answers: Answer[], b: Background, previous?: Rewrite, change?: string) {
162 const r = await $.model
163 .complete({
164 model: rewriteModel,
165 system: REWRITE_SYSTEM,
166 prompt: rewritePrompt(request, answers, b, previous, change),
167 effort: 'low',
168 maxTokens: 1500,
169 timeoutMs: 60000,
170 })
171 .catch(() => undefined)
172 return r?.isAnswered ? parseRewrite(r.text) : undefined
173}
174
175/**
176 * Haiku drafts the question. Undefined means "send the prompt as typed": it was clear
177 * enough, or nothing usable came back. `forced`: the person asked for it (`/refine`, mode
178 * always), so a clear prompt is still rewritten, just without a question.
179 */
180async function draft($: EngineInterface, request: string, forced: boolean, b: Background): Promise<Question[] | undefined> {
181 const drafted = await $.model
182 .complete({ model: 'haiku', system: ASK_SYSTEM, prompt: askPrompt(request, b, forced), effort: 'low', maxTokens: 800, timeoutMs: 20000 })
183 .catch(() => undefined)
184 const questions = drafted?.isAnswered ? parseQuestions(drafted.text) : undefined
185 if (!forced && (questions === undefined || questions.length === 0)) {
186 if (last) last.verdict = questions ? 'clear (Haiku)' : 'no question drafted'
187 return undefined
188 }
189 return questions ?? []
190}
191
192// Ask, rewrite, review. Back from the review returns to the last question with the answers kept.
193async function converse($: EngineInterface, ui: Ui, request: string, questions: Question[], b: Background): Promise<Outcome> {
194 let answers: Answer[] = []
195 let startAt = 0
196 for (;;) {
197 if (questions.length > 0) {
198 const got = await askQuestions(ui, questions, answers, startAt)
199 if (got === 'dismissed') return { kind: 'pass' }
200 answers = got
201 }
202
203 await ui.working('Rewriting your prompt…', 2)
204 let current = await rewrite($, request, answers, b)
205 if (!current) {
206 $.ui.toast("prompt-upgrade: couldn't rewrite it; sending your prompt as typed.")
207 return { kind: 'pass' }
208 }
209 if (last) last.method = METHODS[current.method].label
210
211 let wentBack = false
212 for (let round = 0; ; round++) {
213 const pick = await ui.review(current, round, questions.length > 0)
214 if ('change' in pick) {
215 if (round >= MAX_REVISIONS) return { kind: 'edit', text: current.prompt }
216 await ui.working('Revising…', 2)
217 const revised = await rewrite($, request, answers, b, current, pick.change)
218 if (revised) current = revised
219 else $.ui.toast("prompt-upgrade: couldn't revise it; showing the last version.")
220 if (last) last.method = METHODS[current.method].label
221 continue
222 }
223 if (pick.pick === 'back' && questions.length > 0) {
224 wentBack = true
225 break
226 }
227 if (pick.pick === 'run') return { kind: 'run', text: current.prompt }
228 if (pick.pick === 'edit') return { kind: 'edit', text: current.prompt }
229 if (pick.pick === 'original') return { kind: 'pass' }
230 if (pick.pick === 'cancel') return { kind: 'cancel' }
231 }
232 if (wentBack) startAt = questions.length - 1
233 }
234}
235
236const panelColumns = () => (termColumns > 0 ? Math.max(64, Math.round(termColumns * 0.6)) : 100)
237
238/**
239 * Opens the side panel and runs the conversation there, after the calling hook has returned:
240 * a hook that waited on the person's clicks would outrun its time budget. False when no
241 * panel could be drawn (narrow terminal, no surface): the caller falls back to the dialog.
242 */
243async function runInPanel($: EngineInterface, request: string, prefix: string, original: string, questions: Question[], b: Background): Promise<boolean> {
244 if (!wantsPane || isBusy) return false
245 const opened = await $.ui.open({ id: PANE, title: 'Refine prompt', focus: true, closeOnEscape: true, columns: panelColumns() }).catch(() => undefined)
246 if (!opened?.isPlaced) {
247 await $.ui.close({ id: PANE }).catch(() => undefined)
248 return false
249 }
250 isBusy = true
251 const ui = paneUi($, request)
252 await ui.working('Thinking about your prompt…', 0)
253 $.clock.after(0, () => {
254 void (async () => {
255 const out = await converse($, ui, request, questions, b).catch((): Outcome => ({ kind: 'pass' }))
256 isBusy = false
257 pending = undefined
258 await update($, view, () => IDLE)
259 await $.ui.close({ id: PANE }).catch(() => undefined)
260 conclude($, out, prefix, original)
261 })()
262 })
263 return true
264}
265
266// What happens to the prompt once the person has chosen.
267function conclude($: EngineInterface, out: Outcome, prefix: string, original: string) {
268 if (out.kind === 'run') void $.prompt.submit({ text: prefix + out.text, asUser: true })
269 else if (out.kind === 'pass') void $.prompt.submit({ text: original, asUser: true })
270 else refill($, out.kind === 'edit' ? prefix + out.text : original)
271}
272
273// Filled once the dropped submission has cleared the box.
274function refill($: EngineInterface, text: string) {
275 isRefilled = true
276 $.clock.after(50, () => {
277 void $.prompt.fill({ text }).then(r => {
278 if (!r.isFilled) $.ui.log(`prompt-upgrade: couldn't fill the prompt box. The prompt was:\n${text}`)
279 })
280 })
281}
282
283function describeMode(m: Mode): string {
284 if (m === 'auto') return 'auto: steps in on prompts that look vague'
285 if (m === 'always') return 'always: steps in on every new task you type'
286 return 'off: only /refine <prompt>'
287}
288
289function describeVerdict(v: Verdict): string {
290 return `${v.kind} (${v.reason})`
291}
292
293export const register: Register = (on, options) => {
294 configMode = options.mode === 'always' || options.mode === 'off' ? options.mode : 'auto'
295 useHaiku = options.detector !== 'rules'
296 rewriteModel = typeof options.rewriteModel === 'string' ? options.rewriteModel : 'sonnet'
297 wantsPane = options.ui !== 'dialog'
298
299 on('session.start', async ($, e, next) => {
300 const stored = await $.store.get(MODE_KEY)
301 isModeStored = stored === 'auto' || stored === 'always' || stored === 'off'
302 mode = isModeStored ? (stored as Mode) : configMode
303 await $.command.register({
304 name: 'refine',
305 description: 'prompt-upgrade: upgrade one prompt, or set when it steps in',
306 argumentHint: '<prompt> | auto | always | off | status',
307 })
308 return next(e)
309 })
310
311 // The typing area posts its text when Enter is pressed in it.
312 on('ui.message', async ($, e, next) => {
313 const d = e.data as { kind?: string; text?: string } | undefined
314 if (e.element.startsWith('editor-') && d?.kind === 'submit' && d.text) answer(d.text)
315 return next(e)
316 })
317
318 on('ui.close', { id: PANE }, async ($, e, next) => {
319 if (e.origin.kind === 'person') answer(CLOSED)
320 return next(e)
321 })
322
323 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
324 const ui = $.ui.resolve(e)
325 const { Box, Text, Button } = ui
326 const v = await read($, view)
327 // Choices 1..n, then "Type my own answer" as the next number: it opens the question dialog to type in.
328 const typeKey = String(v.options.length + 1)
329 const choices = v.options.map((o, i) => (
330 <Button key={`opt${i}`} hotkey={String(i + 1)} plain onPress={() => answer(o)}>
331 {o}{v.answered === stripRecommended(o) ? ' ✓ your answer' : ''}
332 </Button>
333 ))
334 // Typing happens in the panel's own wrapped text area (a Client, see editor.tsx). It takes keys
335 // once focused; a click gives it focus, and the typing button tries to do the same.
336 const Client = 'Client' in ui ? ui.Client : undefined
337 const editorKey = `editor-${v.step}-${v.index}-${v.round}`
338 const focusEditor = () =>
339 void $.ui.focus({ requestId: PANE, key: editorKey }).then(
340 r => 'deny' in r && $.ui.toast('Click the box under it, then type.'),
341 () => undefined,
342 )
343 const editor = (placeholder: string) =>
344 Client && <Client key={editorKey} module="./editor.tsx" width="100%" props={{ id: editorKey, placeholder }} />
345
346 // The step bar, in its own frame: done is green ✓, current is bold ●, later is dim ○.
347 const steps = ['Your prompt', v.total > 0 ? `Questions ${v.step === 'ask' ? `${v.index + 1}/${v.total}` : ''}`.trim() : 'Questions', 'Rewrite', 'Review']
348 const bar = (
349 <Box borderStyle="round" borderColor={ACCENT} borderDimColor paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
350 {steps.flatMap((name, i) => {
351 const done = i < v.stage
352 const now = i === v.stage
353 const step = (
354 <Text key={`step${i}`} color={done ? DONE : now ? ACCENT : undefined} bold={now} dimColor={i > v.stage}>
355 {done ? '✓' : now ? '●' : '○'} {name}
356 </Text>
357 )
358 return i < steps.length - 1 ? [step, <Text key={`sep${i}`} dimColor>›</Text>] : [step]
359 })}
360 </Box>
361 )
362 const isLast = v.index + 1 >= v.total
363
364 // One dot per question: done green, current bold, later dim.
365 const dots = Array.from({ length: v.total }, (_, i) => (
366 <Text key={`dot${i}`} color={i < v.index ? DONE : i === v.index ? ACCENT : undefined} bold={i === v.index} dimColor={i > v.index}>
367 {i < v.index ? '✓' : i === v.index ? '●' : '○'}
368 </Text>
369 ))
370
371 return (
372 <Box flexDirection="column" gap={1} paddingX={1}>
373 <Text bold color={ACCENT}>✦ Refine your prompt</Text>
374 {v.step !== 'idle' && bar}
375 <Box flexDirection="column">
376 <Text dimColor>YOUR PROMPT</Text>
377 <Box paddingLeft={2}>
378 <Text>{v.request.length > 300 ? `${v.request.slice(0, 300)}…` : v.request}</Text>
379 </Box>
380 </Box>
381 {v.step === 'idle' && <Text dimColor>Nothing to refine right now.</Text>}
382 {v.step === 'working' && <Text color={ACCENT}>⟳ {v.note}</Text>}
383 {v.step === 'ask' && (
384 <Box flexDirection="column" gap={1}>
385 <Box borderStyle="round" borderColor={ACCENT} paddingX={1} paddingY={1} flexDirection="column" gap={1}>
386 <Box flexDirection="row" columnGap={1}>
387 <Text bold color={ACCENT}>{`QUESTION ${v.index + 1} OF ${v.total}`}</Text>
388 <Box flexDirection="row" columnGap={1}>{dots}</Box>
389 <Text dimColor>{v.header}</Text>
390 </Box>
391 <Text bold>{v.question}</Text>
392 <Box flexDirection="column" paddingLeft={2}>
393 {choices}
394 <Button key="type" hotkey={typeKey} plain onPress={focusEditor}>
395 Type my own answer…{v.answered && !v.options.some(o => stripRecommended(o) === v.answered) ? ` ✓ "${v.answered}"` : ''}
396 </Button>
397 {editor('click here and type your answer, Enter to send')}
398 <Button key="skip" hotkey="s" plain dimColor onPress={() => answer(SKIP)}>Skip this question</Button>
399 </Box>
400 </Box>
401 </Box>
402 )}
403 {v.step === 'review' && (
404 <Box flexDirection="column" gap={1}>
405 <Box flexDirection="column">
406 <Text bold color={DONE}>{v.round > 0 ? `Upgraded prompt (revision ${v.round})` : 'Upgraded prompt'}</Text>
407 <Text dimColor>Method: {v.method}</Text>
408 </Box>
409 <Box borderStyle="round" borderColor={DONE} paddingX={1} paddingY={1}>
410 <Text>{v.draft}</Text>
411 </Box>
412 <Box flexDirection="column" paddingLeft={2}>
413 <Button key="run" hotkey="1" plain onPress={() => answer(LABELS.run)}>{LABELS.run}</Button>
414 <Button key="edit" hotkey="2" plain onPress={() => answer(LABELS.edit)}>{LABELS.edit}</Button>
415 <Button key="original" hotkey="3" plain onPress={() => answer(LABELS.original)}>{LABELS.original}</Button>
416 <Button key="cancel" hotkey="4" plain onPress={() => answer(LABELS.cancel)}>{LABELS.cancel}</Button>
417 <Button key="change" hotkey="5" plain onPress={focusEditor}>Ask for a change…</Button>
418 {editor('click here and type what to change, Enter to send')}
419 {v.total > 0 && <Button key="back" hotkey="b" plain onPress={() => answer(BACK)}>Back: change my answers</Button>}
420 </Box>
421 </Box>
422 )}
423 {v.step !== 'idle' && (
424 // The way-out keys, always in one framed bar at the bottom: bright and bold, not dim.
425 <Box borderStyle="round" borderColor={ACCENT} paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
426 {v.step === 'ask' && v.index > 0 && (
427 <Button key="back" hotkey="b" plain onPress={() => answer(BACK)}><Text bold>Back</Text></Button>
428 )}
429 {v.step === 'ask' && !isLast && (
430 <Button key="rest" hotkey="x" plain onPress={() => answer(SKIP_REST)}><Text bold>Skip the rest, rewrite now</Text></Button>
431 )}
432 <Text>
433 <Text bold color={ACCENT}>Esc</Text>: <Text bold>Send my prompt as typed</Text>
434 </Text>
435 </Box>
436 )}
437 </Box>
438 )
439 })
440
441 on('command.run', { command: 'refine' }, async ($, e) => {
442 termColumns = e.presentation?.columns ?? termColumns
443 const arg = e.args.trim()
444 const word = arg.toLowerCase()
445
446 if (word === 'on' || word === 'auto' || word === 'always' || word === 'off') {
447 mode = word === 'on' ? 'auto' : word
448 isModeStored = true
449 await $.store.set(MODE_KEY, mode)
450 return { text: describeMode(mode) }
451 }
452
453 if (word === '' || word === 'status') {
454 return {
455 text: [
456 `Mode: ${describeMode(mode)} (${isModeStored ? 'set by /refine' : 'from /config'})`,
457 `Detector: ${useHaiku ? 'rules + Haiku' : 'rules only'} · question by Haiku · rewrite by ${rewriteModel} · shown in the ${wantsPane ? 'side panel' : 'dialog'}`,
458 `Last prompt: ${last ? `"${last.request.slice(0, 60)}" → ${last.verdict}${last.method ? ` → ${last.method}` : ''}` : 'none yet'}`,
459 'Use /refine <prompt> to upgrade one prompt; /refine auto|always|off to change when it steps in.',
460 ].join('\n'),
461 }
462 }
463
464 const { prefix, body } = splitPrefix(arg)
465 last = { request: body, verdict: 'asked with /refine' }
466 $.ui.toast('Upgrading your prompt…')
467 const b = await background($)
468 const questions = (await draft($, body, true, b)) ?? []
469 if (await runInPanel($, body, prefix, arg, questions, b)) return { text: 'Refining in the side panel.' }
470
471 const out = await converse($, dialogUi($), body, questions, b).catch((): Outcome => ({ kind: 'pass' }))
472 // A command may not submit while it holds the session; a timer runs once it has finished.
473 $.clock.after(0, () => conclude($, out, prefix, arg))
474 return { text: out.kind === 'run' || out.kind === 'pass' ? 'Sending the prompt.' : out.kind === 'edit' ? 'The upgraded prompt is going to the prompt box.' : 'Cancelled.' }
475 })
476
477 on('prompt.submit', async ($, e, next) => {
478 // Only fresh prompts the person typed while the session was idle.
479 if (e.command || e.isMeta || e.turnId || e.origin.kind !== 'composer' || (e.attachments?.length ?? 0) > 0) return next(e)
480 if (mode === 'off' || isBusy) return next(e)
481 if (isRefilled) {
482 isRefilled = false
483 return next(e)
484 }
485
486 const { prefix, body } = splitPrefix(e.text)
487 let hasHistory = false
488 try {
489 hasHistory = (await $.session.messages()).length > 0
490 } catch {
491 // Treat as a fresh session.
492 }
493 const verdict = judge(body, mode, hasHistory)
494 last = { request: body, verdict: describeVerdict(verdict) }
495 if (verdict.kind === 'skip' || verdict.kind === 'clear') return next(e)
496 if (verdict.kind === 'unsure' && !useHaiku) return next(e)
497
498 $.ui.toast('Upgrading your prompt…')
499 const b = await background($)
500 const questions = await draft($, body, mode === 'always', b).catch(() => undefined)
501 if (questions === undefined) return next(e)
502
503 if (await runInPanel($, body, prefix, e.text, questions, b)) {
504 return { drop: 'prompt-upgrade: refining in the side panel. Your prompt is sent when you choose.' }
505 }
506 const out = await converse($, dialogUi($), body, questions, b).catch((): Outcome => ({ kind: 'pass' }))
507 if (out.kind === 'pass') return next(e)
508 if (out.kind === 'run') return next({ ...e, text: prefix + out.text })
509 if (out.kind === 'edit') {
510 refill($, prefix + out.text)
511 return { drop: 'prompt-upgrade: the upgraded prompt is in the prompt box to edit.' }
512 }
513 refill($, e.text)
514 return { drop: 'prompt-upgrade: cancelled; your prompt is back in the box.' }
515 })
516}
517hooks/upgrade.ts 187 lines1export type Mode = 'auto' | 'always' | 'off'
2export type Verdict = { kind: 'skip' | 'clear' | 'vague' | 'unsure'; reason: string }
3export type Method = 'direct' | 'context' | 'plan' | 'loop' | 'harness'
4
5export type Question = { question: string; header: string; options: string[] }
6export type Answer = { question: string; answer?: string }
7export type Rewrite = { prompt: string; method: Method; why: string }
8
9// Each method is one of the "engineering" ideas, turned into a step the agent can take.
10export const METHODS: Record<Method, { label: string; aka: string }> = {
11 direct: { label: 'Direct', aka: 'prompt engineering' },
12 context: { label: 'Context first', aka: 'context engineering' },
13 plan: { label: 'Plan first', aka: 'plan, then build' },
14 loop: { label: 'Verify loop', aka: 'loop engineering' },
15 harness: { label: 'Make it a rule', aka: 'harness engineering' },
16}
17
18const FOLLOW_UP = /^(y|yes|yep|yeah|n|no|nope|ok|okay|k|sure|go|go on|go ahead|continue|keep going|proceed|do it|ship it|lgtm|thanks?|thank you|ty|stop|next|done|again|retry|[1-9])[\s.!]*$/i
19
20// Signs the prompt already points at something concrete.
21const ANCHORS = [
22 /`[^`]+`/,
23 /\b[\w-]+\.(tsx?|jsx?|mjs|py|go|rs|md|json|ya?ml|toml|css|scss|html|sh|swift|kt|java|rb|php|c|cpp|h|sql|txt|env)\b/i,
24 /\b(README|Dockerfile|Makefile|CHANGELOG|LICENSE|CLAUDE)\b/,
25 /(^|\s)[.~]?\/[\w.-]+\/?/,
26 /https?:\/\//,
27 /\b[a-z]+[A-Z]\w*\b/,
28 /\b[a-z]+_[a-z_]+\b/i,
29 /\b\w+\(\)/,
30 /"[^"]{3,}"|'[^'\s][^']{2,}'/,
31 /\b(line|L)\s?\d+\b/,
32 /\b(error|exception|traceback|stack trace|ENOENT|TypeError|undefined is not|404|500)\b/i,
33]
34
35const BROAD_VERB = /\b(make|build|create|improve|fix|clean ?up|refactor|optimi[sz]e|update|add|write|design|help|change|redo|rework|polish|set ?up|implement)\b/i
36const FUZZY = /\b(better|nicer|cool|good|great|modern|faster|prettier|cleaner|professional|something|stuff|things?|some|an? app|website|site|game|tool|bot|dashboard|landing page)\b/i
37const POINTS_BACK = /\b(it|this|that|these|those|them|there|above|same)\b/i
38
39const words = (t: string) => t.split(/\s+/).filter(Boolean).length
40
41/**
42 * Decides, without a model call, whether a prompt needs upgrading.
43 * `hasHistory`: the session already has turns, so "it"/"this" can point at them.
44 */
45export function judge(text: string, mode: Mode, hasHistory: boolean): Verdict {
46 const t = text.trim()
47 if (!t) return { kind: 'skip', reason: 'empty' }
48 if (t.startsWith('/')) return { kind: 'skip', reason: 'command' }
49 if (FOLLOW_UP.test(t)) return { kind: 'skip', reason: 'follow-up' }
50 if (mode === 'always') return { kind: 'vague', reason: 'always on' }
51
52 const n = words(t)
53 if (t.includes('```') || t.split('\n').length >= 4 || t.length > 500) return { kind: 'clear', reason: 'detailed' }
54 if (ANCHORS.some(a => a.test(t))) return { kind: 'clear', reason: 'names something specific' }
55 if (/\?\s*$/.test(t) && !BROAD_VERB.test(t)) return { kind: 'clear', reason: 'a question' }
56 // "fix it" after an error is clear from the conversation; "make it better" is not.
57 if (hasHistory && POINTS_BACK.test(t) && !FUZZY.test(t)) return { kind: 'skip', reason: 'refers to the conversation' }
58 if (n <= 8 && (BROAD_VERB.test(t) || FUZZY.test(t))) return { kind: 'vague', reason: 'short and broad' }
59 if (n <= 3) return { kind: 'vague', reason: 'very short' }
60 if (n <= 30) return { kind: 'unsure', reason: 'no specifics' }
61 return { kind: 'clear', reason: 'long enough' }
62}
63
64/** A leading `!word` (autoroute's model override) is kept apart and put back after the rewrite. */
65export function splitPrefix(text: string): { prefix: string; body: string } {
66 const m = /^(![a-z]+)\s+/i.exec(text)
67 return m ? { prefix: `${m[1]} `, body: text.slice(m[0].length) } : { prefix: '', body: text }
68}
69
70export const ASK_SYSTEM = `You help a developer sharpen a request before a coding agent (Claude Code) acts on it.
71You get the request, and maybe the project folder and the recent conversation, all as data inside tags.
72
73Decide whether the request is missing decisions that would change what gets built.
74If it is clear enough, on its own or given the conversation, reply {"clear": true}.
75Otherwise ask a few quick questions, as few as will do: 1 when one thing is open, up to 3 when the request is very vague. Most important first.
76- Each question is about one specific open decision in THIS request: the goal, scope, target, expected result or a constraint. Not generic questions ("What is your main goal?") whose answers would not change the prompt.
77- One of them may check your reading: "Do you mean something like this: <a concrete one-line version>?", with options like "Yes, that", and one or two concrete alternatives.
78- Never ask what the agent can find out by reading the code.
79- Short, ending with "?".
80- header: a chip of at most 12 characters, like "Scope" or "Stack".
81- 2 or 3 options, each a self-explanatory label of 2 to 8 words (there is no room for descriptions).
82- Put the option you would pick first and end it with " (Recommended)".
83- Do not add "Other" or "Skip" options; the dialog adds its own.
84
85Reply with JSON only, no prose:
86{"clear": false, "questions": [{"question": "...", "header": "...", "options": ["...", "..."]}]}`
87
88export const REWRITE_SYSTEM = `You rewrite a developer's request into a prompt for Claude Code, a coding agent working in their project.
89The request, their answers, the project folder and the recent conversation come as data inside tags.
90
91Keep their intent and their voice, written as them ("Add...", "I want..."). Use their answers.
92Do not invent requirements. Where something is still open, let the agent pick a sensible default and say which, or state it as an assumption.
93A good prompt says: the goal and why it matters, where in the project (only if known), constraints, and what done looks like and how to check it.
94Short beats complete: 2 to 8 sentences or a few bullets. No headings, no preamble, no role-play ("You are an expert...").
95
96Choose the working method that fits and write its step into the prompt:
97- direct: a small, well-defined change. No extra step.
98- context: the agent must first read specific code, docs or data before deciding. Say what to read first.
99- plan: several reasonable designs, or a large or risky change. Ask for a short plan and a pause for approval before editing.
100- loop: success can be checked by a command (tests, build, lint, a script). Name the check; iterate until it passes; after 3 failed tries stop and report.
101- harness: the user wants the same behaviour every time (a standing rule, a repeated chore). Do it now, and propose making it permanent as a CLAUDE.md rule, a hook, a skill or a scheduled /loop.
102
103Reply with JSON only, no prose:
104{"prompt": "...", "method": "direct|context|plan|loop|harness", "why": "one short sentence on why this method fits"}`
105
106export type Background = { cwd?: string; entries?: string[]; recent?: { role: string; text: string }[] }
107
108export function describeBackground(b: Background): string {
109 const parts: string[] = []
110 if (b.cwd) parts.push(`<project folder="${b.cwd}">${(b.entries ?? []).join(', ')}</project>`)
111 if (b.recent && b.recent.length > 0) {
112 parts.push(`<conversation>\n${b.recent.map(m => `${m.role}: ${m.text}`).join('\n')}\n</conversation>`)
113 }
114 return parts.join('\n')
115}
116
117export function askPrompt(request: string, b: Background, mustAsk: boolean): string {
118 return [
119 `<request>${request}</request>`,
120 describeBackground(b),
121 mustAsk ? 'The developer asked for help with this prompt: ask a question unless there is truly nothing to decide.' : '',
122 ].filter(Boolean).join('\n')
123}
124
125export function rewritePrompt(request: string, answers: Answer[], b: Background, previous?: Rewrite, change?: string): string {
126 const lines = [`<request>${request}</request>`]
127 if (answers.length > 0) {
128 lines.push(`<answers>\n${answers.map(a => `${a.question} → ${a.answer ?? '(skipped; use a sensible default)'}`).join('\n')}\n</answers>`)
129 }
130 const bg = describeBackground(b)
131 if (bg) lines.push(bg)
132 if (previous && change) {
133 lines.push(`<previous_rewrite method="${previous.method}">${previous.prompt}</previous_rewrite>`)
134 lines.push(`<change_request>${change}</change_request>`)
135 lines.push('Revise the previous rewrite as the change request asks.')
136 }
137 return lines.join('\n')
138}
139
140function parseJson(text: string): unknown {
141 const start = text.indexOf('{')
142 const end = text.lastIndexOf('}')
143 if (start < 0 || end <= start) return undefined
144 try {
145 return JSON.parse(text.slice(start, end + 1))
146 } catch {
147 return undefined
148 }
149}
150
151const str = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
152
153/** The questions to ask, `[]` when the model judged the prompt clear, undefined when the reply was unusable. */
154export function parseQuestions(text: string): Question[] | undefined {
155 const j = parseJson(text) as { clear?: unknown; questions?: unknown } | undefined
156 if (!j || typeof j !== 'object') return undefined
157 if (j.clear === true) return []
158 if (!Array.isArray(j.questions)) return undefined
159 const qs: Question[] = []
160 for (const raw of j.questions.slice(0, 3)) {
161 const q = raw as { question?: unknown; header?: unknown; options?: unknown }
162 const question = str(q.question, 200)
163 if (!question || !Array.isArray(q.options)) continue
164 const options = q.options
165 .map(o => str(typeof o === 'string' ? o : (o as { label?: unknown } | null)?.label, 60))
166 .filter(o => o && !/^skip\b/i.test(o))
167 .slice(0, 3)
168 // The dialog wants 2-4 distinct labels, and Skip takes the fourth place.
169 if (options.length < 2 || new Set(options).size !== options.length) continue
170 qs.push({ question, header: str(q.header, 12) || 'Clarify', options })
171 }
172 return qs.length > 0 ? qs : undefined
173}
174
175export function parseRewrite(text: string): Rewrite | undefined {
176 const j = parseJson(text) as { prompt?: unknown; method?: unknown; why?: unknown } | undefined
177 const prompt = str(j?.prompt, 4000)
178 if (!prompt) return undefined
179 const method = typeof j?.method === 'string' && j.method in METHODS ? (j.method as Method) : 'direct'
180 return { prompt, method, why: str(j?.why, 200) }
181}
182
183export function methodLine(r: Rewrite): string {
184 const m = METHODS[r.method]
185 return `${m.label} (${m.aka})${r.why ? `: ${r.why}` : ''}`
186}
187hooks/editor.tsx 49 lines1import type { ClientModule } from 'claude-code'
2
3// A small typing area for the refine panel: the whole text, wrapped, in one place.
4// Claude Code's own text field is a single line that cuts long text off, so this draws its own.
5type Props = { id: string; placeholder: string }
6type State = { text: string }
7
8const ACCENT = 'claude'
9const NAMED = new Set([
10 'up', 'down', 'left', 'right', 'return', 'tab', 'backspace', 'delete', 'pageup', 'pagedown', 'home', 'end',
11 'insert', 'escape', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
12])
13
14// The text lives here, not in the closure, so a burst of keys (a paste) never loses characters.
15let buffer = ''
16let current = ''
17
18const Editor: ClientModule<Props, State> = (props, surface) => {
19 const { Box, Text } = surface.elements
20 if (props.id !== current) {
21 current = props.id
22 buffer = ''
23 }
24
25 surface.onKey(event => {
26 if (event.key === 'return') {
27 const text = buffer.trim()
28 if (text) surface.post({ kind: 'submit', id: props.id, text })
29 return
30 }
31 if (event.key === 'backspace' || event.key === 'delete') buffer = [...buffer].slice(0, -1).join('')
32 else if (event.ctrl && event.key === 'u') buffer = ''
33 // The space bar arrives by its name, not as a character.
34 else if (!event.ctrl && !event.meta && event.key === 'space') buffer += ' '
35 else if (!event.ctrl && !event.meta && !NAMED.has(event.key)) buffer += event.key
36 else return
37 surface.setState({ text: buffer })
38 })
39
40 return (
41 <Box flexDirection="row" paddingLeft={2} columnGap={1}>
42 <Text bold color={ACCENT}>›</Text>
43 {buffer ? <Text wrap="wrap">{buffer}▌</Text> : <Text dimColor>{props.placeholder}</Text>}
44 </Box>
45 )
46}
47
48export default Editor
49types/index.d.ts 27 lines1export type Step = 'idle' | 'working' | 'ask' | 'review'
2
3// What the side panel shows: one step of the refine flow at a time.
4export type View = {
5 step: Step
6 note: string
7 request: string
8 header: string
9 question: string
10 options: string[]
11 method: string
12 draft: string
13 round: number
14 // The step bar: 0 your prompt, 1 questions, 2 rewrite, 3 review.
15 stage: number
16 // Which question is shown (0-based) of how many, and the answer given before, if any.
17 index: number
18 total: number
19 answered: string
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'prompt-upgrade': { view: View }
25 }
26}
27