SLOPSHOPPER

prompt-refiner

A Refine button under the prompt, any time in the chat: pick a rewrite style (Clearer, Shorter, Elaborate, Structured, Ask me first, My guidance), then use it…

newbandspinnercommandtoastmodel
v0.2.0no licenseupdated 2026-10-24jz-wilson/claude-mods/prompt-refiner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-refiner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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-refiner: Press Refine under the prompt, or /refine <prompt> to pick a style for it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Refine ] [ Hide ]

Draws

Prompt hint
? for shortcuts [ Refine ] [ Hide ]
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it.
filetreeFork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree.
helper-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used.
screen-share/screen-share hides names, emails, phone numbers and money amounts on screen while you record.
replay-theater/replay steps through the file edits Claude made in the last turn, one diff at a time.
mods/mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet.
effort-optimizerBefore Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto.
prompt-refinerA Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same.

The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.

Reflect setting

Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.

Install

At the prompt of a terminal session:

/plugin install helper-desk --marketplace jz-wilson/claude-mods

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.

cache-bar and claude-hud

cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.

The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.

The same config file sets the handoff threshold and the context floor:

{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }

The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).

To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.

Development

Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.

claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-bar
Source 3 files
hooks/register.tsx 390 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Phase, Refined } from '../types'
5import { GAPS_SYSTEM, REFINE_SYSTEM, STYLES, layout, parseGaps, parseRefined, refinePrompt, wrapLines } from './text'
6import type { Gap, QA, StyleKey } from './text'
7
8// A Refine button sits in the hint line under the prompt for the whole chat.
9// Pressed, it offers rewrite styles as chips in the band above the box, the way a phone keyboard offers Professional or
10// Elaborate, for the prompt in the box (or an idea it asks for when the box
11// is empty). The rewrite shows in the band with what changed: Use it fills
12// the box, Refine offers the chips again on the rewrite, Discard drops it.
13// Nothing runs until the button is pressed.
14
15const NAME = 'prompt-refiner'
16const CMD = 'refine'
17const MODEL = 'sonnet'
18// The gap check only reads short text and returns a few questions as JSON: Haiku at high effort is enough.
19const GAPS_MODEL = 'haiku'
20// Border and padding on each side.
21const CHROME_COLUMNS = 4
22const STARTERS = ['Fix a bug', 'Build a feature', 'Research something'] as const
23const GUIDES = ['More technical', 'Friendlier tone'] as const
24
25const phase = atom({ plugin: 'prompt-refiner', key: 'phase' } as const, { kind: 'idle' } as Phase)
26const isHidden = atom({ plugin: 'prompt-refiner', key: 'isHidden' } as const, false)
27const isExpanded = atom({ plugin: 'prompt-refiner', key: 'isExpanded' } as const, false)
28
29// The /mods switchboard turns this mod off in every chat with switches.json.
30let off = false
31
32async function readSwitch($: EngineInterface) {
33  let now = false
34  try {
35    const path = `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
36    if (await $.fs.exists(path)) {
37      const json = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
38      now = json[NAME] === false
39    }
40  } catch {
41    now = off
42  }
43  if (now === off) return
44  off = now
45  try {
46    $.ui.invalidate('ui.render')
47  } catch {
48    // The next state change redraws anyway.
49  }
50}
51
52async function log($: EngineInterface, line: string) {
53  try {
54    const home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
55    const path = `${home}/.claude/mods-logs/${NAME}.log`
56    let old = ''
57    if (await $.fs.exists(path).catch(() => false)) old = String(await $.fs.read(path))
58    await $.fs.write(path, `${old}${new Date().toISOString()} ${line}\n`)
59  } catch {
60    // Logging must never break a refine.
61  }
62}
63
64const failWhy = (r: { reason: string; error?: string; status?: number | null }) =>
65  r.reason === 'api-error' ? `the model call failed (${r.error}${r.status ? ` ${r.status}` : ''})` : r.reason === 'aborted' ? 'the model call timed out' : 'the model gave no text'
66
67const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
68
69function setPhase($: EngineInterface, next: Phase) {
70  return update($, phase, () => next)
71}
72
73// Starts a pass when the phase allows it: one pass at a time, so a double
74// press or a second /refine does nothing while one runs.
75async function claim($: EngineInterface, step: string, from: (p: Phase) => boolean) {
76  const run = `${Date.now()}-${Math.random()}`
77  let prev: Phase | undefined
78  await update($, phase, (p): Phase => {
79    prev = p.kind !== 'working' && from(p) ? p : undefined
80    return prev ? { kind: 'working', step, run, back: prev.kind === 'picking' ? prev.back : undefined } : p
81  })
82  return prev ? { run, prev } : undefined
83}
84
85// Writes the phase only while this pass still owns it. A Cancel, a discard,
86// a reload takes it over, and the late result is dropped.
87async function advance($: EngineInterface, run: string, next: Phase) {
88  let owned = false
89  await update($, phase, p => {
90    owned = p.kind === 'working' && p.run === run
91    return owned ? next : p
92  })
93  return owned
94}
95
96// Never throws: a failed call or bad JSON means zero questions, logged.
97async function findGaps($: EngineInterface, draft: string): Promise<Gap[]> {
98  try {
99    const r = await $.model.complete({
100      model: GAPS_MODEL,
101      system: GAPS_SYSTEM,
102      prompt: `Here is the rough prompt. Return the JSON.\n\n<draft>\n${draft}\n</draft>`,
103      maxTokens: 800,
104      effort: 'high',
105      timeoutMs: 45_000,
106    })
107    if (!r.isAnswered) {
108      await log($, `gap check failed: ${failWhy(r)}; going on with 0 questions`)
109      return []
110    }
111    try {
112      return parseGaps(r.text)
113    } catch (err) {
114      await log($, `gap check parse failed: ${errText(err)}; going on with 0 questions`)
115      return []
116    }
117  } catch (err) {
118    await log($, `gap check threw: ${errText(err)}; going on with 0 questions`)
119    return []
120  }
121}
122
123// A dismissed question is skipped, not fatal; null once the pass lost the phase.
124async function askGaps($: EngineInterface, run: string, gaps: Gap[], back?: Refined): Promise<QA[] | null> {
125  const out: QA[] = []
126  for (const [i, g] of gaps.entries()) {
127    if (!(await advance($, run, { kind: 'working', step: `question ${i + 1} of ${gaps.length}`, run, back }))) return null
128    let a: string | null = null
129    try {
130      a = (await $.ui.ask(g.q, { options: g.options, header: `Refine ${i + 1}/${gaps.length}` })).trim() || null
131    } catch {
132      a = null
133    }
134    out.push({ q: g.q, a })
135  }
136  return out
137}
138
139async function write($: EngineInterface, prompt: string) {
140  const r = await $.model.complete({ model: MODEL, system: REFINE_SYSTEM, prompt, maxTokens: 2000, effort: 'low', timeoutMs: 60_000 })
141  if (!r.isAnswered) throw new Error(failWhy(r))
142  return parseRefined(r.text)
143}
144
145// Shows the chips for text to rewrite. `back` is the rewrite being refined
146// again, which Cancel returns to.
147async function pick($: EngineInterface, source: string, pass: number, back?: Refined) {
148  if ((await read($, phase)).kind === 'working') return
149  await update($, isHidden, () => false)
150  // Each new rewrite starts collapsed.
151  await update($, isExpanded, () => false)
152  await setPhase($, { kind: 'picking', source, pass, back })
153}
154
155// One idea question at a time; a second press while it is open does nothing.
156let isAsking = false
157
158// The Refine button: the box's text, or an idea asked for when it is empty.
159async function pickFromBox($: EngineInterface) {
160  if (isAsking) return
161  let source = (await $.prompt.read()).text.trim()
162  if (!source || source.startsWith('/')) {
163    isAsking = true
164    try {
165      source = (await $.ui.ask('What do you want to get done? A rough idea is enough.', { options: STARTERS, header: 'Refine' })).trim()
166    } catch (err) {
167      // Dismissed, or no one to ask.
168      await log($, `idea ask ended: ${errText(err)}`)
169      return
170    } finally {
171      isAsking = false
172    }
173    if (!source) return
174    // A /refine run while the question was open moved the band on; that wins.
175    if ((await read($, phase)).kind !== 'idle') return
176  }
177  await pick($, source, 1)
178}
179
180// One rewrite in the chosen style. A dismissed guidance ask or a failure puts
181// the chips back, so another style can be tried.
182async function rewrite($: EngineInterface, key: StyleKey) {
183  const style = STYLES.find(s => s.key === key)
184  if (!style) return
185  const claimed = await claim($, `writing it ${style.label.toLowerCase()}`, p => p.kind === 'picking')
186  if (!claimed || claimed.prev.kind !== 'picking') return
187  const { run, prev } = claimed
188  const { source, pass } = prev
189  try {
190    let qa: QA[] = []
191    let guidance: string | undefined
192    if (key === 'ask') {
193      if (!(await advance($, run, { kind: 'working', step: 'reading for gaps', run, back: prev.back }))) return
194      const answered = await askGaps($, run, await findGaps($, source), prev.back)
195      if (!answered) return
196      qa = answered
197    } else if (key === 'guide') {
198      try {
199        guidance = (await $.ui.ask('How should I change it? Pick one or type your own.', { options: GUIDES, header: 'Guidance' })).trim()
200      } catch {
201        await advance($, run, prev)
202        return
203      }
204    }
205    if (!(await advance($, run, { kind: 'working', step: `writing it ${style.label.toLowerCase()}`, run, back: prev.back }))) return
206    const { text, changes } = await write($, refinePrompt(source, style.how, qa, guidance))
207    if (await advance($, run, { kind: 'ready', refined: { text, changes, pass, style: style.label } })) {
208      await log($, `pass ${pass} ${key}: ${qa.length} questions${guidance ? `, guidance ${JSON.stringify(guidance)}` : ''}`)
209    }
210  } catch (err) {
211    if (!(await advance($, run, prev))) return
212    $.ui.toast(`Could not rewrite it: ${errText(err)}.`)
213    await log($, `pass ${pass} ${key} failed: ${errText(err)}`)
214  }
215}
216
217async function useIt($: EngineInterface) {
218  const now = await read($, phase)
219  if (now.kind !== 'ready') return 'Nothing rewritten to use yet.'
220  const filled = await $.prompt.fill({ text: now.refined.text, mode: 'replace' }).catch(async (err: unknown) => {
221    await log($, `prompt.fill threw: ${errText(err)}`)
222    return null
223  })
224  if (!filled?.isFilled) {
225    const why = filled?.refusal ? `the prompt box refused it (${filled.refusal})` : 'the prompt box did not take it'
226    $.ui.toast(`Could not use it: ${why}.`)
227    return `Could not use it: ${why}.`
228  }
229  await setPhase($, { kind: 'idle' })
230  $.ui.toast('Rewrite is in the box. Edit it, then press Enter.')
231  return 'Rewrite is in the box.'
232}
233
234// Cancel from the chips goes back to the rewrite they came from, if any.
235function cancel($: EngineInterface, now: Phase) {
236  return setPhase($, now.kind === 'picking' && now.back ? { kind: 'ready', refined: now.back } : { kind: 'idle' })
237}
238
239export const register: Register = on => {
240  on('session.start', async ($, e, next) => {
241    const result = await next(e)
242    await $.command.register({
243      name: CMD,
244      description: 'Rewrite a prompt in a style you pick; show, hide, expand, use or discard drive the band',
245      argumentHint: '[prompt | show | hide | expand | use | discard]',
246    })
247    // A reload mid-rewrite drops its awaits, so a stuck working phase starts over.
248    if ((await read($, phase)).kind === 'working') await setPhase($, { kind: 'idle' })
249    await readSwitch($).catch(() => undefined)
250    $.clock.every(3000, () => {
251      void readSwitch($).catch(() => undefined)
252    })
253    return result
254  })
255
256  on('command.run', { command: CMD }, async ($, e) => {
257    if (off) return { text: 'Prompt Refiner is off. Turn it on with /mods.' }
258    const arg = e.args.trim()
259    switch (arg) {
260      case 'show':
261        await update($, isHidden, () => false)
262        return { text: 'Prompt Refiner shown.' }
263      case 'hide':
264        await update($, isHidden, () => true)
265        return { text: 'Prompt Refiner hidden; /refine show brings it back.' }
266      case 'use':
267        return { text: await useIt($) }
268      case 'expand':
269        await update($, isExpanded, v => !v)
270        return { text: (await read($, isExpanded)) ? 'Showing the whole rewrite.' : 'Showing the short view.' }
271      case 'discard':
272        await setPhase($, { kind: 'idle' })
273        return { text: 'Rewrite discarded.' }
274      case '':
275        return { text: 'Press Refine under the prompt, or /refine <prompt> to pick a style for it.' }
276      default:
277        await pick($, arg, 1)
278        return { text: 'Pick a style above the prompt.' }
279    }
280  })
281
282  // The idle Refine button sits in the hint line under the prompt box. During a
283  // turn the engine's line ("esc to interrupt") keeps its place.
284  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
285    if (off || e.props.isWorking || (await read($, isHidden)) || (await read($, phase)).kind !== 'idle') return next(e)
286    const { Box, Text, Button } = $.ui.resolve(e)
287    return (
288      <Box key="refine-hint" flexDirection="row" alignItems="center" gap={1}>
289        <Box flexShrink={1}>
290          <Text dimColor wrap="truncate-end">
291            {e.props.hint}
292          </Text>
293        </Box>
294        <Button key="refine" label="Refine" variant="primary" onPress={() => pickFromBox($)} />
295        <Button key="hide" label="Hide" dimColor onPress={() => update($, isHidden, () => true)} />
296      </Box>
297    )
298  })
299
300  // The chips, the progress and the rewrite open in the band above the box.
301  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
302    if (off || e.props.hasSurvey || (await read($, isHidden))) return next(e)
303    const now = await read($, phase)
304    if (now.kind === 'idle') return next(e)
305    const { Box, Text, Button } = $.ui.resolve(e)
306
307    if (now.kind === 'picking') {
308      return (
309        <Box key="refine-picking" flexDirection="row" flexWrap="wrap" alignItems="center" gap={1} paddingX={1}>
310          <Text color="subtle">Rewrite as</Text>
311          {STYLES.map(s => (
312            <Button key={s.key} label={s.label} onPress={() => rewrite($, s.key)} />
313          ))}
314          <Button key="cancel" label="Cancel" dimColor onPress={() => cancel($, now)} />
315        </Box>
316      )
317    }
318
319    if (now.kind === 'working') {
320      return (
321        <Box key="refine-working" flexDirection="row" justifyContent="space-between" alignItems="center" gap={2} paddingX={1}>
322          <Box flexShrink={1}>
323            <Text color="suggestion" wrap="truncate-end">
324              Refining: {now.step}
325            </Text>
326          </Box>
327          <Button key="cancel" label="Cancel" dimColor onPress={() => setPhase($, now.back ? { kind: 'ready', refined: now.back } : { kind: 'idle' })} />
328        </Box>
329      )
330    }
331
332    const refined = now.refined
333    const lines = wrapLines(refined.text, Math.max(10, e.props.bodyColumns - CHROME_COLUMNS))
334    // Expanded shows every line; a band taller than maxRows scrolls in the engine's window,
335    // so the buttons move to the top where they stay in reach.
336    const collapsed = layout(e.props.maxRows, lines.length, refined.changes.length)
337    // Only a rewrite that is cut has anything to expand.
338    const canExpand = collapsed.more > 0
339    const expanded = canExpand && (await read($, isExpanded))
340    const fit = expanded ? { body: lines.length, more: 0, changes: refined.changes.length } : collapsed
341    const actions = (
342      <Box flexDirection="row" flexWrap="wrap" gap={1}>
343        <Button key="use" label="Use it" variant="primary" onPress={() => useIt($)} />
344        <Button key="again" label="Refine" onPress={() => pick($, refined.text, refined.pass + 1, refined)} />
345        {canExpand ? (
346          <Button key="expand" label={expanded ? 'Show less' : `Show all ${lines.length} lines`} onPress={() => update($, isExpanded, v => !v)} />
347        ) : null}
348        <Button key="discard" label="Discard" dimColor onPress={() => setPhase($, { kind: 'idle' })} />
349      </Box>
350    )
351
352    return (
353      <Box
354        key="refine-ready"
355        flexDirection="column"
356        paddingX={1}
357        borderStyle="round"
358        borderColor="promptBorder"
359        hover={{ borderColor: 'claude' }}
360      >
361        <Box flexDirection="row" justifyContent="space-between" gap={2}>
362          <Box flexShrink={1}>
363            <Text color="text" weight="bold" wrap="truncate-end">
364              {refined.style}
365            </Text>
366          </Box>
367          <Text color="subtle">pass {refined.pass}</Text>
368        </Box>
369        {expanded ? actions : null}
370        {lines.slice(0, fit.body).map(line => (
371          <Text color="text" wrap="truncate-end">
372            {line || ' '}
373          </Text>
374        ))}
375        {fit.more > 0 ? (
376          <Text color="subtle" wrap="truncate-end">
377            +{fit.more} more lines
378          </Text>
379        ) : null}
380        {refined.changes.slice(0, fit.changes).map(change => (
381          <Text color="success" wrap="truncate-end">
382            + {change}
383          </Text>
384        ))}
385        {expanded ? null : actions}
386      </Box>
387    )
388  })
389}
390
hooks/text.ts 160 lines
1// Pure text work for the refiner: the model prompts, reply parsers, and the
2// band's wrapping and row budget. No `$` here, so it tests without a session.
3
4export const MAX_QUESTIONS = 3
5const MAX_BODY_ROWS = 8
6const MAX_CHANGES = 3
7// Border (2), header (1), and the button row with room to wrap once (2).
8const CHROME_ROWS = 5
9const SKIPPED = 'skipped, no answer'
10
11export const GAPS_SYSTEM = `You read a person's rough prompt for an AI coding and research assistant before it is refined.
12Find only real gaps: two instructions that contradict each other, or a must-know fact that is missing and would change the work (for example which file or system, the goal behind it, the scope, or how to tell it is done).
13
14Return JSON only, no prose, no code fences, in exactly this shape:
15{"questions":[{"q":"<short question ending in a question mark>","options":["<option>","<option>"]}]}
16
17Rules:
18- 0 to 3 questions. When the prompt is clear, return {"questions":[]}. Fewer is better.
19- Never ask about something the prompt already answers, and never ask about style or nice-to-haves.
20- For a conflict, name both sides in the question and offer each side as an option.
21- Each question has 2 or 3 short options (1 to 5 words), taken from the prompt where possible. No "Other" option; the person can type their own.`
22
23export const REFINE_SYSTEM = `You rewrite a person's prompt for an AI coding and research assistant in the style they picked.
24
25Return exactly this shape and nothing else:
26<prompt>
27the refined prompt
28</prompt>
29<changes>
30- one short line per meaningful change
31</changes>
32
33The style for this rewrite comes with the prompt. Follow it exactly; it overrides every rule below where they differ.
34- Keep every fact, name, path, number, link and tool from the draft and the answers.
35- Never invent facts, names or numbers. Where something important is unknown, say so as a question for the assistant to ask, not a guess.
36- Write in the person's voice (I, we, my), as if they wrote it.
37- 2 to 5 changes, each under 12 words, saying what you made clearer, added, or cut.`
38
39export type Gap = { q: string; options: string[] }
40export type QA = { q: string; a: string | null }
41
42const stripFences = (raw: string) =>
43  raw
44    .trim()
45    .replace(/^```[a-z]*\n?/i, '')
46    .replace(/\n?```$/, '')
47    .trim()
48
49// Reads the gap check's reply. Throws on anything that is not the JSON asked
50// for; the caller turns that into zero questions.
51export const parseGaps = (raw: string): Gap[] => {
52  const t = stripFences(raw)
53  const a = t.indexOf('{')
54  const b = t.lastIndexOf('}')
55  if (a < 0 || b <= a) throw new Error('no JSON object in the reply')
56  const json = JSON.parse(t.slice(a, b + 1)) as { questions?: unknown }
57  if (!Array.isArray(json.questions)) throw new Error('no questions list in the reply')
58  const out: Gap[] = []
59  for (const item of json.questions) {
60    if (out.length >= MAX_QUESTIONS) break
61    const { q, options } = (item ?? {}) as { q?: unknown; options?: unknown }
62    if (typeof q !== 'string' || !q.trim() || !Array.isArray(options)) continue
63    const opts: string[] = []
64    for (const o of options) {
65      const label = typeof o === 'string' ? o.trim() : ''
66      if (!label || /^other$/i.test(label) || opts.includes(label)) continue
67      if (opts.length < 3) opts.push(label)
68    }
69    if (opts.length < 2) continue
70    const question = q.trim().endsWith('?') ? q.trim() : `${q.trim()}?`
71    out.push({ q: question, options: opts })
72  }
73  return out
74}
75
76// Reads the refine reply: the prompt between its tags, and the change bullets.
77export const parseRefined = (raw: string): { text: string; changes: string[] } => {
78  const t = stripFences(raw)
79  const body = /<prompt>\s*([\s\S]*?)\s*<\/prompt>/i.exec(t)?.[1]?.trim()
80  if (!body) throw new Error('the reply had no <prompt> block')
81  const list = /<changes>([\s\S]*?)<\/changes>/i.exec(t)?.[1] ?? ''
82  const changes = list
83    .split('\n')
84    .map(line => line.replace(/^\s*[-*•]\s*/, '').trim())
85    .filter(Boolean)
86  return { text: body, changes }
87}
88
89// The chips the Refine button offers, like a keyboard's rewrite suggestions.
90// `ask` asks gap questions first; `guide` asks for the person's own direction.
91export const STYLES = [
92  {
93    key: 'clearer',
94    label: 'Clearer',
95    how: 'Open with the task in one plain sentence, then make the rest clearer and more specific. Keep it close to the draft\'s length, at most a third longer. Add no steps, checks or requirements the draft does not ask for. Plain prose and short bullets.',
96  },
97  { key: 'shorter', label: 'Shorter', how: 'Cut it to the fewest words that keep every fact and instruction. It must come out shorter than the draft.' },
98  {
99    key: 'elaborate',
100    label: 'Elaborate',
101    how: 'Improve it by making it fuller: turn vague parts into concrete asks, spell out the steps and details the draft implies, and where it leaves something important unsaid, add a line asking the assistant to check or ask me. Keep the draft\'s own shape and flow. No headings and no goal, constraints or done-criteria sections; that is a different style.',
102  },
103  {
104    key: 'structured',
105    label: 'Structured',
106    how: 'Start with the line THE JOB and write it as four parts under these exact headings, each on its own line: THE JOB, THE WHY, THE GUARDRAILS, DONE MEANS. Nothing before THE JOB. Where it says nothing for a part, write "unknown: ask me".',
107  },
108  { key: 'ask', label: 'Ask me first', how: 'Open with the task in one plain sentence, then make it clearer and more specific using my answers.' },
109  { key: 'guide', label: 'My guidance', how: 'Rewrite it the way my guidance says; the guidance decides length and shape.' },
110] as const
111
112export type StyleKey = (typeof STYLES)[number]['key']
113
114export const refinePrompt = (draft: string, how: string, qa: QA[] = [], guidance?: string) => {
115  let p = `Here is my prompt. Rewrite it.\n\n<draft>\n${draft}\n</draft>\n\nStyle for this rewrite: ${how}`
116  if (qa.length) {
117    const lines = qa.map(x => `Q: ${x.q}\nA: ${x.a ?? SKIPPED}`).join('\n\n')
118    p += `\n\nI answered these questions first. Use the answers; leave out what was skipped.\n\n<answers>\n${lines}\n</answers>`
119  }
120  if (guidance) p += `\n\nMy guidance: ${guidance}`
121  return p
122}
123
124// Word-wraps text to a width, keeping each line's indent; a word longer than
125// the width is left whole for the Text's truncation to cut.
126export function wrapLines(text: string, width: number): string[] {
127  const out: string[] = []
128  for (const para of text.split('\n')) {
129    const words = para.split(/\s+/).filter(Boolean)
130    if (!words.length) {
131      out.push('')
132      continue
133    }
134    const indent = /^\s*/.exec(para)?.[0] ?? ''
135    let line = ''
136    for (const w of words) {
137      if (line && indent.length + line.length + 1 + w.length > width) {
138        out.push(indent + line)
139        line = w
140      } else {
141        line = line ? `${line} ${w}` : w
142      }
143    }
144    out.push(indent + line)
145  }
146  while (out.length && out[out.length - 1] === '') out.pop()
147  return out
148}
149
150// How many prompt lines and change bullets the ready band shows within
151// maxRows; a cut prompt spends one row on its "+N more lines" note.
152export function layout(maxRows: number, lines: number, changes: number) {
153  const room = Math.max(2, maxRows - CHROME_ROWS)
154  let body = Math.max(1, Math.min(MAX_BODY_ROWS, lines, room))
155  if (body < lines && body + 1 > room) body = room - 1
156  const more = lines - body
157  const shownChanges = Math.max(0, Math.min(MAX_CHANGES, changes, room - body - (more > 0 ? 1 : 0)))
158  return { body, more, changes: shownChanges }
159}
160
types/index.d.ts 14 lines
1export type Refined = { text: string; changes: string[]; pass: number; style: string }
2
3export type Phase =
4  | { kind: 'idle' }
5  | { kind: 'picking'; source: string; pass: number; back?: Refined }
6  | { kind: 'working'; step: string; run: string; back?: Refined }
7  | { kind: 'ready'; refined: Refined }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'prompt-refiner': { phase: Phase; isHidden: boolean; isExpanded: boolean }
12  }
13}
14