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…

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-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. |
| filetree | Fork 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-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When 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). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side 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-optimizer | Before 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-refiner | A 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 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.
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 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:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;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.
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-barhooks/register.tsx 390 lines1import { 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}
390hooks/text.ts 160 lines1// 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}
160types/index.d.ts 14 lines1export 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