SLOPSHOPPER

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.

newpanecommandtoastpromptmodel
★ 1v0.2.0MITupdated 2026-10-15daydeda/daymods/mods/prompt-upgrade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-upgrade
│ ┃ refine ✕ › fix the failing auth test and add an aud╭────────────────────────╮ │ ┃ ✦ Refine your prompt │ prompt-upgrade │ │ ┃ ⏺ Read(src/auth.ts) │ Upgrading your prompt… │ │ ┃ YOUR PROMPT ⎿ Read 6 lines ╰────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing to refine right now. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /refine │ ⎿ prompt-upgrade: Mode: auto: steps in on prompts that look vague │ ⎿ prompt-upgrade: Detector: rules + Haiku · question by Haiku · re │ ⎿ prompt-upgrade: Last prompt: "fix the failing auth test and add │ ⎿ prompt-upgrade: Use /refine <prompt> to upgrade one prompt; /ref │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · refine
✦ Refine your prompt YOUR PROMPT Nothing to refine right now.
README

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

🧩 Mods

ModWhat it does
autoroutePicks the model and effort for each prompt, sends subagents to cheaper models, and keeps Fable and extra-usage spend in check.
prompt-upgradeCatches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs.
box-toolsA 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-statuslineA 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.

📦 Install

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.

🧭 autoroute

Routes each prompt to a suitable model and effort, so small jobs stop burning Opus tokens and big jobs get a plan.

How it works

  1. Pick a route per prompt. When you send a message, quick keyword and length rules choose a tier. If the rules cannot tell, a low-effort Haiku call decides. The choice is locked for the whole reply, so the prompt cache stays warm instead of being rebuilt on every step.
TierTypical promptsModelEffort
triviala question, typo, rename, quick lookupHaikulow
normala feature or fix in 1-3 filesSonnetmedium
hardtricky debugging, design, refactorsOpushigh
largemigrations, rewrites, architectureOpushigh, plus a delegation plan

Short follow-ups like "continue" or "yes" keep the previous route.

  1. Delegate large work. For the large tier, autoroute tells the model to work as a pipeline: Explore subagents map the code, a plan is written to a file, then a Sonnet subagent implements it. Other tiers are told to work directly.
  1. Route subagents. Explore agents run on Haiku at low effort, Plan agents on Opus, and any other subagent with no model set runs on Sonnet.
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]

Two spend rules

These apply to every request, whatever chose its model, and stay on even when routing is turned off.

  • Fable needs a Max plan. autoroute reads your plan from the local Claude login record (~/.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.
  • Spend guard. If extra usage is switched on for your account and any limit window (5-hour or weekly) passes the threshold (90% by default), routes are capped at Sonnet and a warning shows in the status line.

The plan check reads a local file the user can edit. It prevents accidental charges; it is not a security boundary.

Controls

  • Status line: by default autoroute adds no row of its own. It writes the current route to ~/.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).
  • Compact status line: with pace-statusline, 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.
  • Override one prompt: start a message with !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.
  • Options (in /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.

Notes and limits

  • The keyword rules are a starting point and will misjudge some prompts. Use ! overrides, or /route status to see why a route was chosen.
  • The model IDs are set in mods/autoroute/hooks/route.ts. If your Claude Code build uses different IDs, change them there.
  • The Haiku classifier sends the start of your prompt (up to 4000 characters) to Haiku through your own Claude session. Nothing goes to any other service.

✨ prompt-upgrade

Turns a vague prompt like "make a todo app" into a clear one before Claude starts, and lets you check it first.

How it works

  1. Spot vague prompts. Quick local rules let most prompts straight through: follow-ups like "yes" or "continue", slash commands, questions, and anything that names a file, function, error or URL. Short, broad prompts ("make it better", "build a website") count as vague. Borderline ones get a quick Haiku check, which can also decide the prompt is already clear. "fix it" right after an error is left alone, because the conversation already says what "it" is.
  2. Ask a few quick questions. Haiku drafts as few as will do: one when one thing is open, up to three for a very vague prompt. Each is about a specific open decision in your prompt (where it runs, what it saves, how big), and one may check its reading: "Do you mean something like this: …?". They appear one at a time in a side panel (see below). For each, pick a numbered choice, pick Type my own answer… (a wrapped text box appears under it), or Skip. Press Esc to send your prompt untouched.
  3. Rewrite. Sonnet rewrites the prompt with your answer. It keeps your intent and adds the goal, where in the project, constraints and how to check it's done. It doesn't invent requirements: anything still open is left for Claude to pick a sensible default and say which.
  4. Review. The panel shows the rewritten prompt and its method:
  5. Run it: sends the rewrite.
  6. Edit it myself: puts the rewrite in the prompt box for you to change and send.
  7. Use my original: sends what you typed, unchanged.
  8. Cancel: sends nothing, and your text goes back in the prompt box.
  9. Ask for a change…: type what to change ("use Go", "smaller scope") in the text box under it, and it rewrites again.

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:

  • Questions: 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 ✓).
  • Review: 1-4 as above, 5 asks for a change, b goes back to the questions with your answers kept, then rewrites again.
  • Always: 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.

Methods

Each rewrite picks one working method and writes its step into the prompt:

MethodIdeaWhat the prompt asks for
Directprompt engineeringA clear spec and nothing else, for small well-defined changes.
Context firstcontext engineeringRead specific code, docs or data before deciding.
Plan firstplan, then buildA short plan and a pause for your OK before editing, for big or open-ended work.
Verify looploop engineeringA named check (tests, build, lint) and iterating until it passes, stopping after 3 failed tries.
Make it a ruleharness engineeringDo the task now, then suggest making it permanent as a CLAUDE.md rule, hook, skill or /loop.

Controls

  • /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.
  • Options (in /config): mode, detector (rules+haiku or rules alone), ui (panel or dialog), and rewriteModel (sonnet by default, or haiku or opus).

Notes and limits

  • It only steps in on prompts you type while Claude is idle. Prompts with images attached, prompts typed mid-turn, and prompts from other plugins go through untouched.
  • A leading !opus-style autoroute override is kept in front of the rewritten prompt.
  • An upgrade costs one small Haiku call, plus one Sonnet call if it rewrites, and adds a few seconds before the turn starts. The calls go through your own Claude session and send the prompt, the names of the top-level files in your project and the last few messages. Nothing goes to any other service.

🧰 box-tools

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                                     │
╰─────────────────────────────────────────────────────────╯

Controls

  • Open: click ✦ Tools, or run /tools. Clicking the button again closes the box.
  • Pick: click a choice, press its hotkey, or Tab to it and press Enter. Hotkeys: 1-5 for models, q w e r t for effort, a s d… for mods.
  • Close: Esc, a click anywhere outside the box (the transcript or the prompt), x, or the button again.

What each control does

  • Model sets the model row of /config, the same as picking it there. It becomes your default for new sessions too.
  • Effort runs the built-in /effort <level>, as if you typed it. Claude Code saves the level for the current model, so it carries over to new sessions.
  • Mods lists the daymods you have installed (Box Tools itself is left out). A toggle runs claude plugin enable or claude plugin disable, then /reload-plugins, so the change applies right away and stays on or off in later sessions.

Notes and limits

  • When autoroute is on, it picks the model and effort for each prompt and overrides what you pick here. The box says so. Turn autoroute off in the same box to keep your pick.
  • A change made while Claude is working waits until the turn ends, the same as a typed command.
  • Mods loaded with --plugin-dir aren't installed, so they don't appear in the list.
  • Options (in /config): button (show or hide). With hide, the footer is left alone and /tools opens the box.

🛠 Development

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.

📄 License

MIT

Source 4 files
hooks/register.tsx 517 lines
1import { 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}
517
hooks/upgrade.ts 187 lines
1export 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}
187
hooks/editor.tsx 49 lines
1import 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
49
types/index.d.ts 27 lines
1export 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