Turns a rough prompt into a great one before it is sent: asks up to three quick questions, rewrites it, and shows the original and the upgrade side by side…

A Claude Code plugin marketplace.
| Plugin | What it does |
|---|---|
clean-view | Hides tool calls, diffs and command output, and shows a calm checklist of Claude's plan above the prompt, with a row for each helper Claude starts. /simple on or /simple off switches it. Also holds Agent Dock: pick a Team Size from 1 to 100 and every request is split across that many helpers, each shown live in a pane. /dock opens or folds it, /dock 10 sets the size. |
status-bar | One colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort. |
model-routing | Routes every delegated agent to the cheapest model tier that can do the job well, at an explicit effort. Refuses agents with no model, fills in effort by tier, blocks spawns from haiku agents and past two levels, and keeps a routing guide in Claude's instructions. |
mods-menu | A ☰ Mods button beside the Clean View toggle. It opens a small menu listing your mods with their controls: Clean View on or off, the Agent Dock and its Team Size, and the mods that are always on. /mods opens or closes it. |
prompt-upgrade | Turns a rough prompt into a great one before it is sent. /better <prompt> opens a pane that asks a few quick multiple-choice questions, rewrites the prompt, and shows your original and the upgrade side by side; nothing runs until you press Run. /better on checks every typed prompt first: one that already has what it needs is sent as typed, one that falls short opens the pane, and the same prompt sent twice goes straight through. Press Enter twice to send a prompt as typed. A Detail setting (Auto, Low, Medium or High; /better level auto) sets the questions and how long and deep the upgrade is: Low, Medium and High ask up to 3, 5 or 7; Auto also turns automatic mode on, asks as many questions as needed (up to 9) and, if you don't answer within 30 seconds or press Do it for me, fills in the rest and sends the upgrade itself. /better history shows the last 20. |
At the prompt of a terminal session:
/plugin install clean-view --marketplace ataege/claude-skills
/plugin install status-bar --marketplace ataege/claude-skills
/plugin install model-routing --marketplace ataege/claude-skills
/plugin install mods-menu --marketplace ataege/claude-skills
/plugin install prompt-upgrade --marketplace ataege/claude-skills
Answer y to add the marketplace, then pick a scope with Enter.
From a shell:
claude plugin install clean-view --marketplace ataege/claude-skills
claude plugin install status-bar --marketplace ataege/claude-skills
claude plugin install model-routing --marketplace ataege/claude-skills
claude plugin install mods-menu --marketplace ataege/claude-skills
claude plugin install prompt-upgrade --marketplace ataege/claude-skills
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ataege/claude-skills, then Add plugin.
Each plugin lives in plugins/<name>. Check one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { registerPromptUpgrade } from './prompt-upgrade'
4
5// Each mod registers itself here.
6export const register: Register = on => {
7 registerPromptUpgrade(on)
8}
9hooks/prompt-upgrade.tsx 1432 lines1// Prompt Upgrade: turns a rough prompt into a great one, in a pane, before anything is sent.
2import { atom, read, update } from 'claude-code'
3import type { ElementConstructor, Elements, EngineInterface, On, RenderElement, RenderInput, TextProps } from 'claude-code'
4
5import type { PromptUpgradeDetail, PromptUpgradeEntry, PromptUpgradeFlow, PromptUpgradeSource, PromptUpgradeStep } from '../types'
6import {
7 AUTO_SENT_STATUS,
8 AUTO_SEND_MS,
9 BAD_LEVEL_TEXT,
10 CHECKING_STATUS,
11 COLORS,
12 COMMAND_TEXT,
13 DETAIL_LEVELS,
14 DETAIL_SPECS,
15 DROP_TEXT,
16 NARROW_WINDOW,
17 PANE_ID,
18 PANE_TITLE,
19 QUESTIONS_MODEL,
20 SENT_STATUS,
21 SENT_STATUS_MS,
22 STORE_AUTO,
23 STORE_DETAIL,
24 STORE_HISTORY,
25 TITLE_TEXT,
26 ageLabel,
27 answeredCount,
28 answeredPairs,
29 buildStats,
30 cardLayout,
31 cardLines,
32 chipText,
33 currentStep,
34 decideSubmit,
35 detailOrDefault,
36 headingParts,
37 isSelectInline,
38 idleFlow,
39 MIN_WIDTH,
40 normalizePrompt,
41 openCount,
42 packRows,
43 padEnd,
44 parseBetterArgs,
45 parseDetail,
46 parseQuestions,
47 parseRewrite,
48 questionsPrompt,
49 questionsSystem,
50 readingFlow,
51 replyTail,
52 restoreHistory,
53 rewriteModel,
54 rewritePrompt,
55 rewriteSystem,
56 shorten,
57 statLayout,
58 stepStates,
59 titleRuleParts,
60 trackerPieces,
61 trimHistory,
62 truncate,
63 unansweredQuestions,
64 center,
65 wrapText,
66} from './prompt-upgrade-logic'
67
68// ---------------------------------------------------------------------------
69// State
70// ---------------------------------------------------------------------------
71
72const flowAtom = atom({ plugin: 'prompt-upgrade', key: 'flow' } as const, idleFlow(0))
73const historyAtom = atom({ plugin: 'prompt-upgrade', key: 'history' } as const, [])
74const autoAtom = atom({ plugin: 'prompt-upgrade', key: 'isAutoOn' } as const, false)
75const detailAtom = atom({ plugin: 'prompt-upgrade', key: 'detailLevel' } as const, 'medium' as PromptUpgradeDetail)
76const lastReplyAtom = atom({ plugin: 'prompt-upgrade', key: 'lastReply' } as const, '')
77const lastInterceptedAtom = atom({ plugin: 'prompt-upgrade', key: 'lastIntercepted' } as const, '')
78
79type Engine = EngineInterface
80type TextElement = ElementConstructor<TextProps>
81type BoxElement = Elements['terminal']['Box']
82type ButtonElement = Elements['terminal']['Button']
83type InputElement = Elements['terminal']['Input']
84type SelectElement = Elements['terminal']['Select']
85type PaneInput = RenderInput<'Pane'>
86
87const PROBLEM_TEXT = 'Prompt Upgrade hit a problem. Your prompt is safe. Try /better again.'
88const PANE_COLUMNS = 100
89const PANE_ROWS = 30
90
91// ---------------------------------------------------------------------------
92// Engine helpers
93// ---------------------------------------------------------------------------
94
95function showToast($: Engine, text: string): void {
96 try {
97 $.ui.toast(text)
98 } catch {
99 // A toast that cannot show is no reason to fail.
100 }
101}
102
103/** Sets the status line, or clears it with undefined. */
104function setStatus($: Engine, text: string | undefined): void {
105 try {
106 $.ui.status(text)
107 } catch {
108 // A status line that cannot show is no reason to fail.
109 }
110}
111
112/** Runs slow work after the current dispatch ends, so a model call is not tied to a hook's budget. */
113function runInBackground($: Engine, task: () => Promise<void>): void {
114 $.clock.after(0, () => {
115 void task().catch(() => undefined)
116 })
117}
118
119async function changeFlow($: Engine, change: (flow: PromptUpgradeFlow) => PromptUpgradeFlow): Promise<PromptUpgradeFlow> {
120 return update($, flowAtom, change)
121}
122
123/** Applies a change only while the flow is still the call that asked for it. */
124async function changeIfCurrent(
125 $: Engine,
126 requestId: number,
127 change: (flow: PromptUpgradeFlow) => PromptUpgradeFlow,
128): Promise<boolean> {
129 let isApplied = false
130 await update($, flowAtom, flow => {
131 isApplied = flow.requestId === requestId
132 return isApplied ? change(flow) : flow
133 })
134 return isApplied
135}
136
137async function openPane($: Engine): Promise<boolean> {
138 const opened = await $.ui.open({
139 id: PANE_ID,
140 title: PANE_TITLE,
141 focus: true,
142 closeOnEscape: true,
143 columns: PANE_COLUMNS,
144 rows: PANE_ROWS,
145 })
146 return opened.isPlaced
147}
148
149async function closePane($: Engine): Promise<void> {
150 try {
151 await $.ui.close({ id: PANE_ID })
152 } catch {
153 // Already closed.
154 }
155}
156
157/** What the prompt box holds now; empty when it cannot be read. */
158async function readPromptBox($: Engine): Promise<string> {
159 try {
160 return (await $.prompt.read()).text
161 } catch {
162 return ''
163 }
164}
165
166/** Puts the person's words back in the prompt box, unless they are already there. */
167async function restorePrompt($: Engine, text: string): Promise<void> {
168 if (text.trim() === '' || (await readPromptBox($)) === text) return
169 try {
170 await $.prompt.fill({ text, mode: 'replace' })
171 } catch {
172 // The words are still in the history and the pane.
173 }
174}
175
176/** Refills the box with the person's words after a drop, but only when the box is empty. */
177async function refillIfEmpty($: Engine, text: string): Promise<void> {
178 if ((await readPromptBox($)).trim() !== '') return
179 try {
180 await $.prompt.fill({ text, mode: 'replace' })
181 } catch {
182 // The check goes on without the words in the box.
183 }
184}
185
186/** Empties the box before a send, but only when it still holds the original, so new typing is never wiped. */
187async function emptyIfHolding($: Engine, original: string): Promise<void> {
188 if (original.trim() === '' || (await readPromptBox($)) !== original) return
189 try {
190 await $.prompt.fill({ text: '', mode: 'replace' })
191 } catch {
192 // A box that cannot be emptied is no reason to stop the send.
193 }
194}
195
196async function saveHistory($: Engine, history: readonly PromptUpgradeEntry[]): Promise<void> {
197 try {
198 await $.store.set(STORE_HISTORY, history)
199 } catch {
200 // The history still holds for this session.
201 }
202}
203
204/** Ends the flow: the next model answer for it is ignored. */
205async function resetFlow($: Engine): Promise<PromptUpgradeFlow> {
206 return changeFlow($, flow => idleFlow(flow.requestId + 1))
207}
208
209// ---------------------------------------------------------------------------
210// The flow
211// ---------------------------------------------------------------------------
212
213/**
214 * Starts on a rough prompt at the current detail level. A command opens the pane and checks the prompt;
215 * automatic mode checks first and opens the pane only if there is something to show.
216 * Returns false when the pane could not open.
217 */
218async function beginPrompt($: Engine, original: string, startedBy: PromptUpgradeSource = 'command'): Promise<boolean> {
219 const requestId = (await read($, flowAtom)).requestId + 1
220 const detailLevel = await read($, detailAtom)
221 await changeFlow($, () => readingFlow(requestId, original, detailLevel, startedBy))
222
223 if (startedBy === 'auto') {
224 setStatus($, CHECKING_STATUS)
225 // Once the drop is done: the words go back in the box, then the check runs.
226 runInBackground($, async () => {
227 if ((await read($, flowAtom)).requestId !== requestId) return
228 await refillIfEmpty($, original)
229 await runCheck($, requestId)
230 })
231 return true
232 }
233 if (!(await openPane($))) {
234 await resetFlow($)
235 await closePane($)
236 await restorePrompt($, original)
237 return false
238 }
239 await enterAsking($, requestId)
240 return true
241}
242
243/** The prompt check: moves to Asking and runs it in the background. */
244async function enterAsking($: Engine, requestId: number): Promise<void> {
245 const isApplied = await changeIfCurrent($, requestId, flow => ({ ...flow, stage: 'asking', note: null }))
246 if (isApplied) runInBackground($, () => runCheck($, requestId))
247}
248
249async function failStep($: Engine, requestId: number, step: PromptUpgradeStep): Promise<boolean> {
250 return changeIfCurrent($, requestId, flow => ({
251 ...flow,
252 stage: 'error',
253 failedStep: step,
254 typingFor: null,
255 autoSendAt: null,
256 // After a failure, Try again goes on to the normal review.
257 isSendingAutomatically: false,
258 }))
259}
260
261/** Automatic mode, after its check: clears the status line and opens the pane. Puts the words back when it cannot open. */
262async function openAfterCheck($: Engine, requestId: number): Promise<boolean> {
263 setStatus($, undefined)
264 const flow = await read($, flowAtom)
265 if (flow.requestId !== requestId) return false
266 if (await openPane($)) return true
267
268 await resetFlow($)
269 await closePane($)
270 await restorePrompt($, flow.original)
271 showToast($, NARROW_WINDOW)
272 return false
273}
274
275/** Automatic mode, prompt already good: sends the original as typed, with no pane. */
276async function sendCheckedOriginal($: Engine, requestId: number): Promise<void> {
277 const flow = await read($, flowAtom)
278 setStatus($, undefined)
279 const isApplied = await changeIfCurrent($, requestId, current => idleFlow(current.requestId + 1))
280 if (!isApplied) return
281
282 await emptyIfHolding($, flow.original)
283 submitAsUser($, flow.original)
284 // A short note that clears itself.
285 setStatus($, SENT_STATUS)
286 $.clock.after(SENT_STATUS_MS, () => setStatus($, undefined))
287}
288
289/** The prompt check: is it ready, and if not, which questions fill the gaps. */
290async function runCheck($: Engine, requestId: number): Promise<void> {
291 const flow = await read($, flowAtom)
292 if (flow.requestId !== requestId || (flow.stage !== 'asking' && flow.stage !== 'checking')) return
293 // Automatic mode checks with the pane closed; a command or a retry has it open.
294 const isAutoCheck = flow.stage === 'checking'
295 const level = detailOrDefault(flow.detailLevel)
296
297 try {
298 const reply = await $.model.complete({
299 ...QUESTIONS_MODEL,
300 system: questionsSystem(level),
301 prompt: questionsPrompt(flow.original, await read($, lastReplyAtom)),
302 })
303 if ((await read($, flowAtom)).requestId !== requestId) return
304
305 const parsed = reply.isAnswered ? parseQuestions(reply.text, level) : undefined
306 if (parsed === undefined || !parsed.isOk) return await failCheck($, requestId, isAutoCheck)
307
308 if (parsed.isReady) {
309 if (isAutoCheck) return await sendCheckedOriginal($, requestId)
310 await changeIfCurrent($, requestId, current => ({ ...current, stage: 'ready', questions: [], answers: [] }))
311 return
312 }
313
314 // Not ready but nothing to ask: go straight to the rewrite.
315 if (parsed.questions.length === 0) {
316 const isApplied = await changeIfCurrent($, requestId, current => ({ ...current, stage: 'rewriting', questions: [], answers: [] }))
317 if (!isApplied || (isAutoCheck && !(await openAfterCheck($, requestId)))) return
318 await runRewrite($, requestId)
319 return
320 }
321 // At Auto, a countdown to "fill the rest and send" starts with the questions.
322 const autoSendAt = level === 'auto' ? (await $.clock.now()) + AUTO_SEND_MS : null
323 const isApplied = await changeIfCurrent($, requestId, current => ({
324 ...current,
325 stage: 'questions',
326 questions: parsed.questions,
327 answers: parsed.questions.map(() => ({ status: 'open' as const, text: '' })),
328 typingFor: null,
329 autoSendAt,
330 }))
331 if (isApplied && autoSendAt !== null) scheduleAutoSend($, requestId, autoSendAt)
332 if (isApplied && isAutoCheck) await openAfterCheck($, requestId)
333 } catch {
334 await failCheck($, requestId, isAutoCheck)
335 }
336}
337
338/** The check failed: the calm error card, with the pane opened first in automatic mode. */
339async function failCheck($: Engine, requestId: number, isAutoCheck: boolean): Promise<void> {
340 const isApplied = await failStep($, requestId, 'asking')
341 if (isApplied && isAutoCheck) await openAfterCheck($, requestId)
342}
343
344/** The rewrite call: moves to Rewriting and runs it in the background. */
345async function enterRewriting($: Engine, requestId: number): Promise<void> {
346 const isApplied = await changeIfCurrent($, requestId, flow =>
347 flow.stage === 'questions' ? { ...flow, stage: 'rewriting', typingFor: null, note: null, autoSendAt: null } : flow,
348 )
349 if (isApplied) runInBackground($, () => runRewrite($, requestId))
350}
351
352async function runRewrite($: Engine, requestId: number): Promise<void> {
353 const flow = await read($, flowAtom)
354 if (flow.requestId !== requestId || flow.stage !== 'rewriting') return
355
356 try {
357 const pairs = answeredPairs(flow.questions, flow.answers)
358 const level = detailOrDefault(flow.detailLevel)
359 const reply = await $.model.complete({
360 ...rewriteModel(level),
361 system: rewriteSystem(level),
362 // Questions left open are chosen by the model (only "fill the rest" leaves any open).
363 prompt: rewritePrompt(flow.original, pairs, unansweredQuestions(flow.questions, flow.answers)),
364 })
365 if ((await read($, flowAtom)).requestId !== requestId) return
366
367 const parsed = reply.isAnswered ? parseRewrite(reply.text) : undefined
368 if (parsed === undefined || !parsed.isOk) {
369 await failStep($, requestId, 'rewriting')
370 return
371 }
372
373 const now = await $.clock.now()
374 const stats = buildStats(flow.original, parsed.prompt, parsed.added)
375 const entry: PromptUpgradeEntry = {
376 id: `${now}-${requestId}`,
377 original: flow.original,
378 upgraded: parsed.prompt,
379 added: parsed.added,
380 ...stats,
381 answers: pairs,
382 detailLevel: level,
383 ...(flow.isSendingAutomatically ? { sentAutomatically: true } : {}),
384 at: now,
385 }
386 if (flow.isSendingAutomatically) {
387 await sendAutomatically($, requestId, entry)
388 return
389 }
390 const isApplied = await changeIfCurrent($, requestId, current => ({
391 ...current,
392 stage: 'review',
393 upgraded: parsed.prompt,
394 added: parsed.added,
395 entryId: entry.id,
396 note: null,
397 }))
398 if (!isApplied) return
399
400 const history = await update($, historyAtom, list => trimHistory(list, entry))
401 await saveHistory($, history)
402 } catch {
403 await failStep($, requestId, 'rewriting')
404 }
405}
406
407/** Fill and send, after the rewrite: sends the upgrade with no review, keeps it in the history and closes the pane. */
408async function sendAutomatically($: Engine, requestId: number, entry: PromptUpgradeEntry): Promise<void> {
409 const flow = await read($, flowAtom)
410 const isApplied = await changeIfCurrent($, requestId, current => idleFlow(current.requestId + 1))
411 if (!isApplied) return
412
413 const history = await update($, historyAtom, list => trimHistory(list, entry))
414 await saveHistory($, history)
415 await closePane($)
416 await emptyIfHolding($, flow.original)
417 submitAsUser($, entry.upgraded)
418 // A short note that clears itself.
419 setStatus($, AUTO_SENT_STATUS)
420 $.clock.after(SENT_STATUS_MS, () => setStatus($, undefined))
421}
422
423/** Schedules the end of the Auto countdown; a timer for an old request or an answered countdown does nothing. */
424function scheduleAutoSend($: Engine, requestId: number, dueAt: number): void {
425 void (async () => {
426 const remaining = Math.max(0, dueAt - (await $.clock.now()))
427 $.clock.after(remaining, () => {
428 void (async () => {
429 const flow = await read($, flowAtom)
430 if (flow.requestId !== requestId || flow.autoSendAt !== dueAt || flow.stage !== 'questions') return
431 await fillAndSend($)
432 })().catch(() => undefined)
433 })
434 })().catch(() => undefined)
435}
436
437/** The Auto countdown ending, or "Do it for me": the rewrite chooses for the open questions, then it sends. */
438async function fillAndSend($: Engine): Promise<void> {
439 const flow = await read($, flowAtom)
440 if (flow.stage !== 'questions') return
441
442 await changeIfCurrent($, flow.requestId, current => ({
443 ...current,
444 stage: 'rewriting',
445 typingFor: null,
446 note: null,
447 autoSendAt: null,
448 isSendingAutomatically: true,
449 }))
450 runInBackground($, () => runRewrite($, flow.requestId))
451}
452
453/** The person's first press in the pane ends the Auto countdown; the flow goes on as a normal upgrade. */
454async function cancelCountdown($: Engine): Promise<void> {
455 if ((await read($, flowAtom)).autoSendAt === null) return
456 await changeFlow($, flow => (flow.autoSendAt === null ? flow : { ...flow, autoSendAt: null }))
457}
458
459/** Try again after an error: repeats the call that failed. */
460async function tryAgain($: Engine): Promise<void> {
461 const flow = await read($, flowAtom)
462 if (flow.stage !== 'error') return
463
464 const requestId = flow.requestId + 1
465 if (flow.failedStep === 'asking') {
466 await changeFlow($, current => ({ ...current, requestId, stage: 'asking' }))
467 runInBackground($, () => runCheck($, requestId))
468 } else {
469 await changeFlow($, current => ({ ...current, requestId, stage: 'rewriting' }))
470 runInBackground($, () => runRewrite($, requestId))
471 }
472}
473
474/** The "already looks good" card's Upgrade anyway: the rewrite with no questions. */
475async function upgradeAnyway($: Engine): Promise<void> {
476 const flow = await read($, flowAtom)
477 if (flow.stage !== 'ready') return
478
479 const requestId = flow.requestId + 1
480 await changeFlow($, current => ({ ...current, requestId, stage: 'rewriting', questions: [], answers: [], note: null }))
481 runInBackground($, () => runRewrite($, requestId))
482}
483
484/** Review's Rewrite at <Level>: the same answers, rewritten at the level now picked. */
485async function rewriteAtNewLevel($: Engine): Promise<void> {
486 const flow = await read($, flowAtom)
487 const pickedLevel = await read($, detailAtom)
488 if (flow.stage !== 'review' || pickedLevel === detailOrDefault(flow.detailLevel)) return
489
490 const requestId = flow.requestId + 1
491 await changeFlow($, current => ({ ...current, requestId, stage: 'rewriting', detailLevel: pickedLevel, note: null }))
492 runInBackground($, () => runRewrite($, requestId))
493}
494
495/** Saves a detail level in the store and publishes it for drawing. Returns false for a bad value. */
496/** Turns automatic mode on or off, saved and published for other mods. */
497async function setAutoMode($: Engine, isOn: boolean): Promise<void> {
498 await $.store.set(STORE_AUTO, isOn)
499 await update($, autoAtom, () => isOn)
500}
501
502/** Saves the detail level. Auto means "do everything for me", so it also turns automatic mode on. */
503async function setDetail($: Engine, value: string): Promise<boolean> {
504 const level = parseDetail(value)
505 if (level === undefined) return false
506 await $.store.set(STORE_DETAIL, level)
507 await update($, detailAtom, () => level)
508 if (level === 'auto') await setAutoMode($, true)
509 return true
510}
511
512/** Sets one question's answer; when none are left open the rewrite starts. */
513async function answerQuestion($: Engine, index: number, status: 'answered' | 'skipped', text: string): Promise<void> {
514 const flow = await changeFlow($, current =>
515 current.stage !== 'questions' || current.answers[index] === undefined
516 ? current
517 : {
518 ...current,
519 typingFor: current.typingFor === index ? null : current.typingFor,
520 autoSendAt: null,
521 answers: current.answers.map((answer, position) => (position === index ? { status, text } : answer)),
522 },
523 )
524 if (flow.stage === 'questions' && openCount(flow.answers) === 0) await enterRewriting($, flow.requestId)
525}
526
527/** "Skip the rest, upgrade now". */
528async function skipTheRest($: Engine): Promise<void> {
529 const flow = await changeFlow($, current =>
530 current.stage !== 'questions'
531 ? current
532 : {
533 ...current,
534 autoSendAt: null,
535 answers: current.answers.map(answer => (answer.status === 'open' ? { status: 'skipped' as const, text: '' } : answer)),
536 },
537 )
538 if (flow.stage === 'questions') await enterRewriting($, flow.requestId)
539}
540
541async function toggleOther($: Engine, index: number): Promise<void> {
542 await changeFlow($, flow =>
543 flow.stage === 'questions' ? { ...flow, typingFor: flow.typingFor === index ? null : index, autoSendAt: null } : flow,
544 )
545}
546
547/** Submits text as the person's own words; if that fails the text goes back in the prompt box. */
548function submitAsUser($: Engine, text: string): void {
549 void $.prompt.submit({ text, asUser: true }).catch(async () => {
550 await restorePrompt($, text)
551 showToast($, "Couldn't send that. Your text is back in the prompt box.")
552 })
553}
554
555/** Sends text as the person's own words, closing the pane first. */
556async function sendText($: Engine, text: string): Promise<void> {
557 const { original } = await read($, flowAtom)
558 await resetFlow($)
559 await closePane($)
560 await emptyIfHolding($, original)
561 submitAsUser($, text)
562}
563
564async function runUpgrade($: Engine): Promise<void> {
565 const flow = await read($, flowAtom)
566 const text = (flow.stage === 'editing' ? flow.draft : flow.upgraded).trim()
567 if (flow.stage !== 'review' && flow.stage !== 'editing') return
568 if (text === '') {
569 await changeFlow($, current => ({ ...current, note: { text: 'There is nothing to run yet.', tone: 'warn' } }))
570 return
571 }
572 await sendText($, text)
573}
574
575async function sendOriginal($: Engine): Promise<void> {
576 const flow = await read($, flowAtom)
577 if (flow.original.trim() === '') return
578 await sendText($, flow.original)
579}
580
581/** Cancel, or the pane's own close: the original goes back in the prompt box and the flow resets. */
582async function cancelFlow($: Engine, isClosing: boolean): Promise<void> {
583 const flow = await read($, flowAtom)
584 await resetFlow($)
585 if (flow.stage !== 'idle' && flow.stage !== 'history') await restorePrompt($, flow.original)
586 if (isClosing) await closePane($)
587}
588
589/** Keeps edited text as the upgrade, in the flow and in the history. */
590async function applyEdit($: Engine, text: string): Promise<void> {
591 const flow = await read($, flowAtom)
592 const upgraded = text.trim() === '' ? flow.upgraded : text
593 await changeFlow($, current => ({ ...current, upgraded, draft: upgraded }))
594
595 const stats = buildStats(flow.original, upgraded, flow.added)
596 const history = await update($, historyAtom, list =>
597 list.map(entry => (entry.id === flow.entryId ? { ...entry, upgraded, ...stats } : entry)),
598 )
599 await saveHistory($, history)
600}
601
602async function finishEdit($: Engine): Promise<void> {
603 const flow = await read($, flowAtom)
604 if (flow.stage !== 'editing') return
605 await applyEdit($, flow.draft)
606 await changeFlow($, current => ({ ...current, stage: 'review' }))
607}
608
609async function copyUpgrade($: Engine, surface: PaneInput['surface']): Promise<void> {
610 const flow = await read($, flowAtom)
611 const result = await $.ui.copy({ text: flow.upgraded, surface })
612 await changeFlow($, current => ({
613 ...current,
614 note: result.isCopied ? { text: 'Copied the upgrade.', tone: 'good' } : { text: 'Could not copy here.', tone: 'warn' },
615 }))
616}
617
618/** Opens a saved upgrade's review. */
619async function reopenEntry($: Engine, entryId: string): Promise<void> {
620 const entry = (await read($, historyAtom)).find(saved => saved.id === entryId)
621 if (entry === undefined) return
622 await changeFlow($, flow => ({
623 ...idleFlow(flow.requestId + 1),
624 stage: 'review',
625 original: entry.original,
626 detailLevel: detailOrDefault(entry.detailLevel),
627 // The saved answers come back, so Rewrite at <Level> can use them.
628 questions: entry.answers.map(pair => ({ question: pair.question, options: [pair.answer] })),
629 answers: entry.answers.map(pair => ({ status: 'answered' as const, text: pair.answer })),
630 upgraded: entry.upgraded,
631 added: entry.added,
632 entryId: entry.id,
633 }))
634}
635
636async function showHistory($: Engine): Promise<boolean> {
637 await changeFlow($, flow => ({ ...idleFlow(flow.requestId + 1), stage: 'history' }))
638 if (await openPane($)) return true
639 await resetFlow($)
640 await closePane($)
641 return false
642}
643
644async function showTyping($: Engine): Promise<boolean> {
645 await changeFlow($, flow => ({ ...idleFlow(flow.requestId + 1), stage: 'typing' }))
646 if (await openPane($)) return true
647 await resetFlow($)
648 await closePane($)
649 return false
650}
651
652/** Starts a rough prompt in the background and says so in one toast when the window is too narrow. */
653function startInBackground($: Engine, original: string, startedBy: PromptUpgradeSource = 'command'): void {
654 void (async () => {
655 if (!(await beginPrompt($, original, startedBy))) showToast($, NARROW_WINDOW)
656 })().catch(() => undefined)
657}
658
659// ---------------------------------------------------------------------------
660// Drawing: small pieces
661// ---------------------------------------------------------------------------
662
663type Ui = {
664 Box: BoxElement
665 Text: TextElement
666 Button: ButtonElement
667 Input: InputElement | undefined
668 Select: SelectElement | undefined
669 width: number
670 /** The level picked in the dropdown right now. */
671 detailLevel: PromptUpgradeDetail
672}
673
674type ChipKind = 'gold' | 'slate' | 'quiet'
675type ChipSpec = { key: string; label: string; kind: ChipKind; onPress: () => void }
676
677function chipElement(ui: Ui, spec: ChipSpec, room: number): RenderElement {
678 const { Button, Text } = ui
679 const text = chipText(truncate(spec.label, Math.max(1, room - 2)))
680 const face =
681 spec.kind === 'gold' ? (
682 <Text backgroundColor={COLORS.gold} color={COLORS.ink} weight="bold">{text}</Text>
683 ) : spec.kind === 'slate' ? (
684 <Text backgroundColor={COLORS.slate} color={COLORS.text}>{text}</Text>
685 ) : (
686 <Text color={COLORS.muted}>{text}</Text>
687 )
688
689 return (
690 <Button key={spec.key} plain onPress={spec.onPress}>
691 {face}
692 </Button>
693 )
694}
695
696/** Chips in as many rows as the width needs, so none wraps. */
697function chipRows(ui: Ui, specs: readonly ChipSpec[], room: number, indent = 0): RenderElement[] {
698 const { Box } = ui
699 const widths = specs.map(spec => Math.min(room, spec.label.length + 2))
700 return packRows(widths, 1, room).map((row, rowIndex) => (
701 <Box key={`chips-${indent}-${rowIndex}-${row.join('-')}`} flexDirection="row" gap={1} paddingLeft={indent}>
702 {row.map(index => {
703 const spec = specs[index]
704 return spec === undefined ? null : chipElement(ui, spec, room)
705 })}
706 </Box>
707 ))
708}
709
710function headingRow(ui: Ui, label: string, width: number = ui.width): RenderElement {
711 const { Text } = ui
712 const parts = headingParts(label, width)
713 return (
714 <Text wrap="truncate-end">
715 <Text color={COLORS.muted}>{`${parts.label} `}</Text>
716 <Text color={COLORS.hairline}>{parts.rule}</Text>
717 </Text>
718 )
719}
720
721function plainRow(ui: Ui, text: string, color: string = COLORS.text, width: number = ui.width): RenderElement {
722 const { Text } = ui
723 return <Text color={color} wrap="truncate-end">{truncate(text, width) || ' '}</Text>
724}
725
726/** A framed card holding wrapped lines. */
727function cardBox(ui: Ui, name: string, text: string, outerWidth: number, borderColor: string): RenderElement {
728 const { Box, Text } = ui
729 const lines = cardLines(text, Math.max(1, outerWidth - 4))
730 return (
731 <Box key={name} borderStyle="round" borderColor={borderColor} width={outerWidth} paddingX={1} flexDirection="column">
732 {lines.map(line => (
733 <Text color={COLORS.text} wrap="truncate-end">{line === '' ? ' ' : line}</Text>
734 ))}
735 </Box>
736 )
737}
738
739/** The detail dropdown: Low, Medium or High. A pick is saved; drawing it saves nothing. */
740function detailSelect($: Engine, ui: Ui): RenderElement | null {
741 const { Select } = ui
742 if (Select === undefined) return null
743 return (
744 <Select
745 key="detail-level"
746 label="Detail"
747 value={ui.detailLevel}
748 options={DETAIL_LEVELS.map(level => ({ value: level, label: DETAIL_SPECS[level].optionLabel }))}
749 onSelect={value => void (async () => {
750 await cancelCountdown($)
751 await setDetail($, value)
752 })().catch(() => undefined)}
753 />
754 )
755}
756
757/** The title and its gold rule; the dropdown sits on the title's row, or under it when narrow. */
758function masthead($: Engine, ui: Ui, hasDetail: boolean): RenderElement[] {
759 const { Box, Text } = ui
760 const rule = titleRuleParts(TITLE_TEXT, ui.width)
761 const title = <Text weight="bold" color={COLORS.text}>{TITLE_TEXT}</Text>
762 const select = hasDetail ? detailSelect($, ui) : null
763 const ruleRow = (
764 <Text wrap="truncate-end">
765 <Text color={COLORS.gold}>{rule.gold}</Text>
766 <Text color={COLORS.hairline}>{rule.hairline}</Text>
767 </Text>
768 )
769 if (select === null) return [title, ruleRow]
770 if (!isSelectInline(ui.width)) return [title, select, ruleRow]
771
772 return [
773 <Box key="title-row" flexDirection="row" justifyContent="space-between" width={ui.width}>
774 {title}
775 {select}
776 </Box>,
777 ruleRow,
778 ]
779}
780
781function tracker(ui: Ui, flow: PromptUpgradeFlow): RenderElement | null {
782 const { Text } = ui
783 const step = currentStep(flow.stage, flow.failedStep)
784 if (step === null) return null
785
786 const pieces = trackerPieces(stepStates(step), ui.width)
787 return (
788 <Text wrap="truncate-end">
789 {pieces.map(piece => (
790 <Text
791 color={
792 piece.kind === 'done'
793 ? COLORS.green
794 : piece.kind === 'current'
795 ? COLORS.gold
796 : piece.kind === 'separator'
797 ? COLORS.hairline
798 : COLORS.muted
799 }
800 weight={piece.kind === 'current' ? 'bold' : undefined}
801 >
802 {piece.text}
803 </Text>
804 ))}
805 </Text>
806 )
807}
808
809/** Under the tracker, when automatic mode left the words in the prompt box: a second Enter there sends them as typed. */
810function enterHint(ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
811 const isShownOn = flow.stage === 'questions' || flow.stage === 'ready' || flow.stage === 'error'
812 if (flow.startedBy !== 'auto' || !isShownOn) return []
813 return [plainRow(ui, 'Press Enter in the prompt box to send it as typed.', COLORS.muted)]
814}
815
816function cancelChip(onCancel: () => void): ChipSpec {
817 return { key: 'cancel', label: 'Cancel', kind: 'quiet', onPress: onCancel }
818}
819
820// ---------------------------------------------------------------------------
821// Drawing: the screens
822// ---------------------------------------------------------------------------
823
824/** Step 1, and the quiet part of steps 2 and 3: the prompt in a frame and a line on what is happening. */
825function drawWorking($: Engine, ui: Ui, flow: PromptUpgradeFlow, statusLine: string): RenderElement[] {
826 const rows: RenderElement[] = [
827 headingRow(ui, 'Your prompt'),
828 cardBox(ui, 'original-card', flow.original, ui.width, COLORS.hairline),
829 plainRow(ui, statusLine, COLORS.muted),
830 ]
831 const answered = answeredPairs(flow.questions, flow.answers)
832 if (answered.length > 0) {
833 rows.push(headingRow(ui, 'What you told me'))
834 for (const pair of answered) rows.push(plainRow(ui, `${pair.question} → ${pair.answer}`, COLORS.text))
835 }
836 rows.push(...chipRows(ui, [cancelChip(() => void cancelFlow($, true))], ui.width))
837
838 return rows
839}
840
841function drawTyping($: Engine, ui: Ui): RenderElement[] {
842 const { Input, Box } = ui
843 const rows: RenderElement[] = [headingRow(ui, 'Your prompt'), plainRow(ui, 'Type a rough prompt. Press Enter and I will upgrade it.', COLORS.muted)]
844 if (Input !== undefined) {
845 rows.push(
846 <Box flexDirection="row">
847 <Input
848 key="typed-prompt"
849 label="Prompt"
850 placeholder="what you want"
851 submitLabel="Upgrade"
852 autoFocus
853 onSubmit={value => {
854 if (value.trim() === '') return
855 void beginPrompt($, value.trim()).then(isPlaced => {
856 if (!isPlaced) showToast($, NARROW_WINDOW)
857 }).catch(() => undefined)
858 }}
859 />
860 </Box>,
861 )
862 }
863 rows.push(...chipRows(ui, [cancelChip(() => void cancelFlow($, true))], ui.width))
864
865 return rows
866}
867
868function drawQuestions($: Engine, ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
869 const { Text, Box, Input } = ui
870 const total = flow.questions.length
871 const answered = answeredCount(flow.answers)
872 const isAutoLevel = detailOrDefault(flow.detailLevel) === 'auto'
873 const rows: RenderElement[] = [
874 headingRow(ui, 'A few quick questions'),
875 plainRow(ui, `${answered} of ${total} answered`, COLORS.muted),
876 ]
877 // The countdown is quiet and static: no digits, no redraw each second.
878 if (flow.autoSendAt !== null) {
879 rows.push(plainRow(ui, "I'll fill in the rest and send it in 30 seconds unless you answer.", COLORS.muted))
880 }
881
882 flow.questions.forEach((question, index) => {
883 const answer = flow.answers[index] ?? { status: 'open' as const, text: '' }
884 const badge =
885 answer.status === 'answered' ? (
886 <Text backgroundColor={COLORS.green} color={COLORS.ink} weight="bold">{' ✓ '}</Text>
887 ) : answer.status === 'skipped' ? (
888 <Text backgroundColor={COLORS.hairline} color={COLORS.muted}>{' – '}</Text>
889 ) : (
890 <Text backgroundColor={COLORS.gold} color={COLORS.ink} weight="bold">{` ${index + 1} `}</Text>
891 )
892 const lines = wrapText(question.question, Math.max(1, ui.width - 4))
893 rows.push(<Text> </Text>)
894 rows.push(
895 <Text wrap="truncate-end">
896 {badge}
897 <Text color={COLORS.text} weight="bold">{` ${lines[0] ?? ''}`}</Text>
898 </Text>,
899 )
900 for (const line of lines.slice(1)) rows.push(plainRow(ui, ` ${line}`, COLORS.text))
901 if (answer.status === 'answered') rows.push(plainRow(ui, ` → ${answer.text}`, COLORS.green))
902 if (answer.status === 'skipped') rows.push(plainRow(ui, ' skipped', COLORS.muted))
903
904 const isOptionPicked = (option: string) => answer.status === 'answered' && answer.text === option
905 const isOtherPicked = answer.status === 'answered' && !question.options.includes(answer.text)
906 const specs: ChipSpec[] = [
907 ...question.options.map((option, position): ChipSpec => ({
908 key: `q${index}-opt-${position}`,
909 label: option,
910 kind: isOptionPicked(option) ? 'gold' : 'slate',
911 onPress: () => void answerQuestion($, index, 'answered', option).catch(() => undefined),
912 })),
913 {
914 key: `q${index}-other`,
915 label: isOtherPicked ? truncate(answer.text, 30) : 'Other (type it)',
916 kind: isOtherPicked || flow.typingFor === index ? 'gold' : 'slate',
917 onPress: () => void toggleOther($, index).catch(() => undefined),
918 },
919 {
920 key: `q${index}-skip`,
921 label: 'Skip',
922 kind: 'quiet',
923 onPress: () => void answerQuestion($, index, 'skipped', '').catch(() => undefined),
924 },
925 ]
926 rows.push(...chipRows(ui, specs, Math.max(8, ui.width - 4), 4))
927
928 if (flow.typingFor === index && Input !== undefined) {
929 rows.push(
930 <Box flexDirection="row" paddingLeft={4}>
931 <Input
932 key={`q${index}-other-input`}
933 label="Answer"
934 placeholder="type it"
935 submitLabel="Save"
936 autoFocus
937 onSubmit={value => {
938 const text = value.trim()
939 if (text !== '') void answerQuestion($, index, 'answered', text).catch(() => undefined)
940 }}
941 />
942 </Box>,
943 )
944 }
945 })
946
947 rows.push(<Text> </Text>)
948 rows.push(
949 ...chipRows(
950 ui,
951 [
952 ...(isAutoLevel
953 ? [{ key: 'do-it-for-me', label: 'Do it for me', kind: 'gold' as const, onPress: () => void fillAndSend($).catch(() => undefined) }]
954 : []),
955 {
956 key: 'skip-rest',
957 label: 'Skip the rest, upgrade now',
958 kind: isAutoLevel ? ('slate' as const) : ('gold' as const),
959 onPress: () => void skipTheRest($).catch(() => undefined),
960 },
961 cancelChip(() => void cancelFlow($, true)),
962 ],
963 ui.width,
964 ),
965 )
966 return rows
967}
968
969/** The review's numbers: three rounded tiles of heavy digits, or one line when the pane is narrow. */
970function drawStats(ui: Ui, flow: PromptUpgradeFlow): RenderElement {
971 const { Box, Text } = ui
972 const layout = statLayout(buildStats(flow.original, flow.upgraded, flow.added), ui.width)
973 if (layout.mode === 'line') return plainRow(ui, layout.text, COLORS.text)
974
975 return (
976 <Box flexDirection="row" gap={1}>
977 {layout.tiles.map((tile, index) => (
978 <Box key={`stat-${index}`} borderStyle="round" borderColor={COLORS.hairline} paddingX={1} flexDirection="column">
979 {tile.rows.map(row => (
980 <Text color={tile.tone === 'good' ? COLORS.green : COLORS.text} weight="bold">{center(row, tile.innerWidth)}</Text>
981 ))}
982 <Text color={COLORS.muted}>{center(tile.label, tile.innerWidth)}</Text>
983 </Box>
984 ))}
985 </Box>
986 )
987}
988
989function drawNote(ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
990 if (flow.note === null) return []
991 return [plainRow(ui, flow.note.text, flow.note.tone === 'good' ? COLORS.green : COLORS.amber)]
992}
993
994function drawAdded(ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
995 const { Text } = ui
996 const rows: RenderElement[] = [headingRow(ui, 'What I added')]
997 if (flow.added.length === 0) {
998 rows.push(plainRow(ui, 'Nothing new was needed.', COLORS.muted))
999 return rows
1000 }
1001 for (const item of flow.added) {
1002 const lines = wrapText(item, Math.max(1, ui.width - 2))
1003 lines.forEach((line, index) => {
1004 rows.push(
1005 <Text wrap="truncate-end">
1006 <Text color={COLORS.green}>{index === 0 ? '+ ' : ' '}</Text>
1007 <Text color={COLORS.text}>{line}</Text>
1008 </Text>,
1009 )
1010 })
1011 }
1012 return rows
1013}
1014
1015/** My prompt on the left, the upgrade on the right with a gold outline; stacked when narrow. */
1016function drawCards(ui: Ui, flow: PromptUpgradeFlow): RenderElement {
1017 const { Box } = ui
1018 const layout = cardLayout(ui.width)
1019 const mine = (
1020 <Box key="original-column" flexDirection="column" width={layout.cardWidth}>
1021 {headingRow(ui, 'Your prompt', layout.cardWidth)}
1022 {cardBox(ui, 'original-card', flow.original, layout.cardWidth, COLORS.hairline)}
1023 </Box>
1024 )
1025 const upgrade = (
1026 <Box key="upgrade-column" flexDirection="column" width={layout.cardWidth}>
1027 {headingRow(ui, 'The upgrade', layout.cardWidth)}
1028 {cardBox(ui, 'upgrade-card', flow.upgraded, layout.cardWidth, COLORS.gold)}
1029 </Box>
1030 )
1031 return layout.isStacked ? (
1032 <Box flexDirection="column" width={ui.width}>
1033 {upgrade}
1034 {mine}
1035 </Box>
1036 ) : (
1037 <Box flexDirection="row" gap={1} width={ui.width}>
1038 {mine}
1039 {upgrade}
1040 </Box>
1041 )
1042}
1043
1044function drawReview($: Engine, ui: Ui, e: PaneInput, flow: PromptUpgradeFlow): RenderElement[] {
1045 const usedLevel = detailOrDefault(flow.detailLevel)
1046 // The picked level differs from the one this upgrade was written at: offer a rewrite.
1047 const rewriteChips: ChipSpec[] =
1048 ui.detailLevel === usedLevel
1049 ? []
1050 : [
1051 {
1052 key: 'rewrite-level',
1053 label: `Rewrite at ${DETAIL_SPECS[ui.detailLevel].label}`,
1054 kind: 'gold',
1055 onPress: () => void rewriteAtNewLevel($).catch(() => undefined),
1056 },
1057 ]
1058 return [
1059 drawStats(ui, flow),
1060 ...chipRows(
1061 ui,
1062 [
1063 { key: 'run', label: 'Run it', kind: 'gold', onPress: () => void runUpgrade($).catch(() => undefined) },
1064 ...rewriteChips,
1065 {
1066 key: 'edit',
1067 label: 'Edit',
1068 kind: 'slate',
1069 onPress: () => void changeFlow($, current => ({ ...current, stage: 'editing', draft: current.upgraded, note: null })).catch(() => undefined),
1070 },
1071 { key: 'copy', label: 'Copy', kind: 'slate', onPress: () => void copyUpgrade($, e.surface).catch(() => undefined) },
1072 cancelChip(() => void cancelFlow($, true)),
1073 ],
1074 ui.width,
1075 ),
1076 plainRow(ui, `Written at ${DETAIL_SPECS[usedLevel].label} detail`, COLORS.muted),
1077 ...drawNote(ui, flow),
1078 ...drawAdded(ui, flow),
1079 drawCards(ui, flow),
1080 ]
1081}
1082
1083function drawEditing($: Engine, ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
1084 const { Box, Input } = ui
1085 const rows: RenderElement[] = [
1086 ...chipRows(
1087 ui,
1088 [
1089 { key: 'run', label: 'Run it', kind: 'gold', onPress: () => void runUpgrade($).catch(() => undefined) },
1090 { key: 'edit-back', label: 'Back', kind: 'slate', onPress: () => void finishEdit($).catch(() => undefined) },
1091 cancelChip(() => void cancelFlow($, true)),
1092 ],
1093 ui.width,
1094 ),
1095 ...drawNote(ui, flow),
1096 headingRow(ui, 'Edit the upgrade'),
1097 plainRow(ui, 'Change the text, then press Enter to run it.', COLORS.muted),
1098 ]
1099 if (Input !== undefined) {
1100 rows.push(
1101 <Box flexDirection="row">
1102 <Input
1103 key="edit-input"
1104 label="Upgrade"
1105 value={flow.draft}
1106 placeholder="the upgraded prompt"
1107 submitLabel="Run"
1108 autoFocus
1109 onInput={value => {
1110 void changeFlow($, current => (current.stage === 'editing' ? { ...current, draft: value } : current)).catch(() => undefined)
1111 }}
1112 onSubmit={value => {
1113 void (async () => {
1114 await changeFlow($, current => (current.stage === 'editing' ? { ...current, draft: value } : current))
1115 await runUpgrade($)
1116 })().catch(() => undefined)
1117 }}
1118 />
1119 </Box>,
1120 )
1121 }
1122 rows.push(headingRow(ui, 'Your prompt'))
1123 rows.push(cardBox(ui, 'original-card', flow.original, ui.width, COLORS.hairline))
1124
1125 return rows
1126}
1127
1128function drawError($: Engine, ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
1129 const { Box, Text } = ui
1130 const doing = flow.failedStep === 'asking' ? 'reading your prompt' : 'writing the upgrade'
1131 const lines = wrapText(`Something went wrong while ${doing}.`, Math.max(1, ui.width - 4))
1132
1133 return [
1134 <Box key="error-card" borderStyle="round" borderColor={COLORS.amber} width={ui.width} paddingX={1} flexDirection="column">
1135 {lines.map(line => (
1136 <Text color={COLORS.amber} wrap="truncate-end">{line}</Text>
1137 ))}
1138 <Text color={COLORS.green}>Your prompt is safe.</Text>
1139 </Box>,
1140 ...chipRows(
1141 ui,
1142 [
1143 { key: 'retry', label: 'Try again', kind: 'gold', onPress: () => void tryAgain($).catch(() => undefined) },
1144 { key: 'send-original', label: 'Send my original', kind: 'slate', onPress: () => void sendOriginal($).catch(() => undefined) },
1145 cancelChip(() => void cancelFlow($, true)),
1146 ],
1147 ui.width,
1148 ),
1149 headingRow(ui, 'Your prompt'),
1150 cardBox(ui, 'original-card', flow.original, ui.width, COLORS.hairline),
1151 ]
1152}
1153
1154/** The prompt check found nothing missing: a calm card with the choice to send it or upgrade anyway. */
1155function drawReady($: Engine, ui: Ui, flow: PromptUpgradeFlow): RenderElement[] {
1156 const { Box, Text } = ui
1157 const lines = wrapText('This already looks good. It has what it needs, so it can go as typed.', Math.max(1, ui.width - 4))
1158 return [
1159 <Box key="ready-card" borderStyle="round" borderColor={COLORS.green} width={ui.width} paddingX={1} flexDirection="column">
1160 {lines.map(line => (
1161 <Text color={COLORS.green} wrap="truncate-end">{line}</Text>
1162 ))}
1163 </Box>,
1164 ...chipRows(
1165 ui,
1166 [
1167 { key: 'send-as-typed', label: 'Send as typed', kind: 'gold', onPress: () => void sendOriginal($).catch(() => undefined) },
1168 { key: 'upgrade-anyway', label: 'Upgrade anyway', kind: 'slate', onPress: () => void upgradeAnyway($).catch(() => undefined) },
1169 cancelChip(() => void cancelFlow($, true)),
1170 ],
1171 ui.width,
1172 ),
1173 headingRow(ui, 'Your prompt'),
1174 cardBox(ui, 'original-card', flow.original, ui.width, COLORS.hairline),
1175 ]
1176}
1177
1178function drawHistory($: Engine, ui: Ui, history: readonly PromptUpgradeEntry[], now: number): RenderElement[] {
1179 const { Button, Text } = ui
1180 const rows: RenderElement[] = [headingRow(ui, 'Your last upgrades')]
1181 if (history.length === 0) {
1182 rows.push(plainRow(ui, 'No upgrades yet. Type /better and a rough prompt.', COLORS.muted))
1183 }
1184 const ageWidth = 9
1185 const tagWidth = 5
1186 for (const entry of history) {
1187 const age = padEnd(ageLabel(now - entry.at), ageWidth)
1188 const tag = padEnd(DETAIL_SPECS[detailOrDefault(entry.detailLevel)].tag, tagWidth)
1189 const snippet = truncate(shorten(entry.original, 200), Math.max(1, ui.width - ageWidth - tagWidth))
1190 rows.push(
1191 <Button key={`history-${entry.id}`} plain onPress={() => void reopenEntry($, entry.id).catch(() => undefined)}>
1192 <Text color={COLORS.muted}>{age}</Text>
1193 <Text color={COLORS.gold}>{tag}</Text>
1194 <Text color={COLORS.text}>{snippet}</Text>
1195 </Button>,
1196 )
1197 }
1198 rows.push(<Text> </Text>)
1199 rows.push(...chipRows(ui, [{ key: 'close', label: 'Close', kind: 'slate', onPress: () => void cancelFlow($, true).catch(() => undefined) }], ui.width))
1200hooks/prompt-upgrade-logic.ts 802 lines1// Prompt Upgrade's pure logic: which prompts are caught, reading model text, numbers, row fitting.
2import type {
3 PromptUpgradeAnswer,
4 PromptUpgradeDetail,
5 PromptUpgradeEntry,
6 PromptUpgradeFlow,
7 PromptUpgradeQa,
8 PromptUpgradeQuestion,
9 PromptUpgradeSource,
10 PromptUpgradeStage,
11 PromptUpgradeStep,
12} from '../types'
13
14// ---------------------------------------------------------------------------
15// Names, colors and limits
16// ---------------------------------------------------------------------------
17
18export const PLUGIN_NAME = 'prompt-upgrade'
19export const PANE_ID = 'prompt-upgrade'
20export const PANE_TITLE = 'Prompt Upgrade'
21export const TITLE_TEXT = 'PROMPT UPGRADE'
22export const STORE_AUTO = 'prompt-upgrade.auto'
23export const STORE_HISTORY = 'prompt-upgrade.history'
24export const STORE_DETAIL = 'prompt-upgrade.detail'
25
26export const COLORS = {
27 gold: '#F2C46D',
28 hairline: '#262B33',
29 muted: '#7D8590',
30 text: '#E6EDF3',
31 green: '#3DD68C',
32 amber: '#E3B341',
33 ink: '#0D1117',
34 slate: '#262B33',
35} as const
36
37export const MIN_OPTIONS = 2
38export const MAX_ADDED = 8
39/** How much of Claude's last reply the prompt check gets to see. */
40export const LAST_REPLY_LENGTH = 1500
41/** How long the "sent as typed" line stays on screen. */
42export const SENT_STATUS_MS = 3000
43/** How long Auto waits for answers before it fills the rest and sends. */
44export const AUTO_SEND_MS = 30_000
45export const HISTORY_LIMIT = 20
46export const OPTION_MAX_LENGTH = 48
47/** Below this many columns the two review cards stack. */
48export const STACK_BELOW = 70
49/** The narrowest width the pane draws for; anything less is drawn as this. */
50export const MIN_WIDTH = 30
51/** A card shows at most this many lines; Copy and Run always use the full text. */
52export const CARD_MAX_LINES = 60
53
54// The default is 1024 tokens; the check's JSON and long rewrites need more room.
55export const QUESTIONS_MODEL = { model: 'haiku', effort: 'low', timeoutMs: 20_000, maxTokens: 1500 } as const
56export const REWRITE_MODEL = { model: 'sonnet', effort: 'medium', timeoutMs: 60_000 } as const
57
58// ---------------------------------------------------------------------------
59// Detail levels
60// ---------------------------------------------------------------------------
61
62export const DEFAULT_DETAIL: PromptUpgradeDetail = 'medium'
63export const DETAIL_LEVELS: readonly PromptUpgradeDetail[] = ['auto', 'low', 'medium', 'high']
64
65type DetailSpec = {
66 label: string
67 /** The dropdown's option text. */
68 optionLabel: string
69 /** The short tag in the history. */
70 tag: string
71 maxQuestions: number
72 maxOptions: number
73 rewriteMaxTokens: number
74 /** The level's rules, added after the four rewrite rules. */
75 rewriteRules: readonly string[]
76 /** An extra line for the check, after its own question limits. */
77 checkRule?: string
78}
79
80export const DETAIL_SPECS: Readonly<Record<PromptUpgradeDetail, DetailSpec>> = {
81 auto: {
82 label: 'Auto',
83 optionLabel: 'Auto: decides for you',
84 tag: 'AUTO',
85 maxQuestions: 9,
86 maxOptions: 4,
87 rewriteMaxTokens: 6000,
88 rewriteRules: [
89 '- Match the length and depth to the size and risk of the task: a small, clear task gets one short paragraph; a large or risky one gets short labeled sections with constraints, what is out of scope and what done looks like.',
90 '- The "added" list has 1 to 8 items.',
91 ],
92 checkRule: 'Ask only as many questions as the gaps need, at most 9.',
93 },
94 low: {
95 label: 'Low',
96 optionLabel: 'Low: quick and short',
97 tag: 'LOW',
98 maxQuestions: 3,
99 maxOptions: 3,
100 rewriteMaxTokens: 1500,
101 rewriteRules: [
102 '- Length: one short paragraph, about 40 to 80 words.',
103 '- At this level the first rule adds only the goal and the format.',
104 '- The "added" list has 1 to 3 items.',
105 ],
106 },
107 medium: {
108 label: 'Medium',
109 optionLabel: 'Medium: balanced',
110 tag: 'MED',
111 maxQuestions: 5,
112 maxOptions: 4,
113 rewriteMaxTokens: 3000,
114 rewriteRules: [
115 '- Length: one or two paragraphs, about 80 to 150 words.',
116 '- At this level the first rule adds the goal, the audience, the format and what "done" looks like.',
117 '- The "added" list has 3 to 5 items.',
118 ],
119 },
120 high: {
121 label: 'High',
122 optionLabel: 'High: thorough',
123 tag: 'HIGH',
124 maxQuestions: 7,
125 maxOptions: 4,
126 rewriteMaxTokens: 6000,
127 rewriteRules: [
128 '- Length: short labeled sections, about 150 to 300 words.',
129 '- At this level the first rule adds everything in the medium level, plus constraints, tone, must-haves and what is out of scope.',
130 '- Leave a [placeholder] for every unknown.',
131 '- The "added" list has 5 to 8 items.',
132 ],
133 },
134}
135
136/** Reads a level from anything; undefined when it is not auto, low, medium or high. */
137export function parseDetail(value: unknown): PromptUpgradeDetail | undefined {
138 if (typeof value !== 'string') return undefined
139 const word = value.trim().toLowerCase()
140 return DETAIL_LEVELS.find(level => level === word)
141}
142
143/** A level that may be missing (an old save) read as a level; medium when unknown. */
144export function detailOrDefault(value: unknown): PromptUpgradeDetail {
145 return parseDetail(value) ?? DEFAULT_DETAIL
146}
147
148export function rewriteModel(level: PromptUpgradeDetail) {
149 return { ...REWRITE_MODEL, maxTokens: DETAIL_SPECS[level].rewriteMaxTokens }
150}
151
152export const BAD_LEVEL_TEXT = 'Detail is auto, low, medium or high, e.g. /better level auto.'
153
154export const CHECKING_STATUS = 'Checking your prompt… Press Enter again to send it as typed.'
155export const SENT_STATUS = 'Sent as typed: it already looked good'
156export const AUTO_SENT_STATUS = 'Sent the upgrade.'
157
158export const DROP_TEXT = 'Prompt Upgrade is reading your prompt.'
159export const COMMAND_TEXT = 'Prompt Upgrade is reading your prompt.'
160export const NARROW_WINDOW =
161 'The window is too narrow for Prompt Upgrade, so your words are back in the prompt box.'
162
163// ---------------------------------------------------------------------------
164// Which prompts are caught
165// ---------------------------------------------------------------------------
166
167/** Replies that are never worth upgrading. */
168const KNOWN_REPLIES: ReadonlySet<string> = new Set([
169 'yes',
170 'no',
171 'ok',
172 'okay',
173 'sure',
174 'thanks',
175 'thank you',
176 'continue',
177 'keep going',
178 'go on',
179 'go ahead',
180 'do it',
181 'sounds good',
182 'lgtm',
183 'stop',
184])
185
186/** The facts about a submission that decide whether it is caught. */
187export type SubmitFacts = {
188 text: string
189 /** `e.origin.kind`. */
190 originKind: string
191 hasCommand: boolean
192 isMeta: boolean
193 attachmentCount: number
194 /** True when typed while Claude was working. */
195 hasTurn: boolean
196}
197
198export type SubmitDecision =
199 | { kind: 'pass' }
200 /** Goes on with the ">" taken off. */
201 | { kind: 'strip'; text: string }
202 | { kind: 'intercept'; text: string }
203
204export function wordCount(text: string): number {
205 const words = text.trim().split(/\s+/).filter(word => word !== '')
206 return words.length
207}
208
209/** A very short answer such as "yes" or "keep going". */
210export function isShortReply(text: string): boolean {
211 const plain = text
212 .trim()
213 .toLowerCase()
214 .replace(/[.!?,;:]+$/g, '')
215 .replace(/\s+/g, ' ')
216 if (plain === '') return true
217
218 return wordCount(plain) <= 3 || KNOWN_REPLIES.has(plain)
219}
220
221/** Decides what the automatic mode does with one submission. */
222export function decideSubmit(facts: SubmitFacts, isAuto: boolean): SubmitDecision {
223 if (!isAuto) return { kind: 'pass' }
224 // Only the person's own Enter in the prompt box. This also keeps the mod's own sends from looping back.
225 if (facts.originKind !== 'composer') return { kind: 'pass' }
226 if (facts.hasCommand || facts.isMeta || facts.attachmentCount > 0 || facts.hasTurn) return { kind: 'pass' }
227
228 const text = facts.text.trim()
229 if (text === '' || text.startsWith('/')) return { kind: 'pass' }
230 if (text.startsWith('>')) {
231 const stripped = text.replace(/^>\s?/, '')
232 return stripped.trim() === '' ? { kind: 'pass' } : { kind: 'strip', text: stripped }
233 }
234 if (isShortReply(text)) return { kind: 'pass' }
235
236 return { kind: 'intercept', text }
237}
238
239/** A prompt as compared for "the same prompt twice": trimmed, spaces collapsed, lowercase. */
240export function normalizePrompt(text: string): string {
241 return text.trim().replace(/\s+/g, ' ').toLowerCase()
242}
243
244/** The end of a reply, kept as context for the prompt check. */
245export function replyTail(answer: string): string {
246 const trimmed = answer.trim()
247 return trimmed.length <= LAST_REPLY_LENGTH ? trimmed : trimmed.slice(trimmed.length - LAST_REPLY_LENGTH)
248}
249
250export type BetterAction =
251 | { kind: 'empty' }
252 | { kind: 'on' }
253 | { kind: 'off' }
254 | { kind: 'history' }
255 /** `level` is the detail level, or undefined when the value is not one. */
256 | { kind: 'level'; level: PromptUpgradeDetail | undefined }
257 | { kind: 'prompt'; text: string }
258
259/** Reads what was typed after /better. */
260export function parseBetterArgs(args: string): BetterAction {
261 const text = args.trim()
262 const word = text.toLowerCase()
263 if (text === '') return { kind: 'empty' }
264 if (word === 'on') return { kind: 'on' }
265 if (word === 'off') return { kind: 'off' }
266 if (word === 'history') return { kind: 'history' }
267 // "level" alone or with one word is the setting; a longer sentence is still a prompt.
268 const words = text.split(/\s+/)
269 if (words[0]?.toLowerCase() === 'level' && words.length <= 2) return { kind: 'level', level: parseDetail(words[1]) }
270
271 return { kind: 'prompt', text }
272}
273
274// ---------------------------------------------------------------------------
275// Reading what the model said
276// ---------------------------------------------------------------------------
277
278/** Finds JSON in model text: bare, in a code fence, or inside stray prose. */
279export function extractJson(text: string): unknown {
280 const trimmed = text.trim()
281 const candidates: string[] = [trimmed]
282
283 const fenced = trimmed.match(/```(?:json|JSON)?\s*([\s\S]*?)```/)
284 if (fenced?.[1] !== undefined) candidates.push(fenced[1].trim())
285
286 const firstBrace = trimmed.indexOf('{')
287 const lastBrace = trimmed.lastIndexOf('}')
288 if (firstBrace !== -1 && lastBrace > firstBrace) candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
289
290 const firstBracket = trimmed.indexOf('[')
291 const lastBracket = trimmed.lastIndexOf(']')
292 if (firstBracket !== -1 && lastBracket > firstBracket) candidates.push(trimmed.slice(firstBracket, lastBracket + 1))
293
294 for (const candidate of candidates) {
295 try {
296 return JSON.parse(candidate)
297 } catch {
298 // Try the next reading.
299 }
300 }
301 return undefined
302}
303
304function isRecord(value: unknown): value is Record<string, unknown> {
305 return typeof value === 'object' && value !== null && !Array.isArray(value)
306}
307
308/** A short single-line label. */
309export function shorten(text: string, limit: number): string {
310 const flat = text.replace(/\s+/g, ' ').trim()
311 return flat.length <= limit ? flat : `${flat.slice(0, Math.max(0, limit - 1)).trimEnd()}…`
312}
313
314export type ParsedQuestions =
315 | { isOk: true; isReady: boolean; missing: string[]; questions: PromptUpgradeQuestion[] }
316 | { isOk: false }
317
318/** Reads the prompt check's reply, capped at the level's questions and options. */
319export function parseQuestions(text: string, level: PromptUpgradeDetail = DEFAULT_DETAIL): ParsedQuestions {
320 const { maxQuestions, maxOptions } = DETAIL_SPECS[level]
321 const json = extractJson(text)
322 const rawList = Array.isArray(json) ? json : isRecord(json) ? json.questions : undefined
323 if (!Array.isArray(rawList)) return { isOk: false }
324
325 const questions: PromptUpgradeQuestion[] = []
326 for (const raw of rawList) {
327 if (!isRecord(raw) || typeof raw.question !== 'string' || !Array.isArray(raw.options)) continue
328 const question = raw.question.replace(/\s+/g, ' ').trim()
329 const seen = new Set<string>()
330 const options: string[] = []
331 for (const option of raw.options) {
332 if (typeof option !== 'string') continue
333 const label = shorten(option, OPTION_MAX_LENGTH)
334 if (label === '' || seen.has(label.toLowerCase())) continue
335 seen.add(label.toLowerCase())
336 options.push(label)
337 }
338 if (question === '' || options.length < MIN_OPTIONS) continue
339 questions.push({ question, options: options.slice(0, maxOptions) })
340 if (questions.length === maxQuestions) break
341 }
342
343 const missing =
344 isRecord(json) && Array.isArray(json.missing)
345 ? json.missing
346 .filter((gap): gap is string => typeof gap === 'string')
347 .map(gap => shorten(gap, OPTION_MAX_LENGTH))
348 .filter(gap => gap !== '')
349 : []
350 // A reply with no "ready" field is the old shape: no questions means ready.
351 const isReady = isRecord(json) && typeof json.ready === 'boolean' ? json.ready : questions.length === 0
352 return { isOk: true, isReady, missing, questions: isReady ? [] : questions }
353}
354
355export type ParsedRewrite = { isOk: true; prompt: string; added: string[] } | { isOk: false }
356
357/** Reads the rewrite reply: the upgraded prompt and the short list of what was added. */
358export function parseRewrite(text: string): ParsedRewrite {
359 const json = extractJson(text)
360 if (!isRecord(json) || typeof json.prompt !== 'string') return { isOk: false }
361
362 const prompt = json.prompt.trim()
363 if (prompt === '') return { isOk: false }
364
365 const rawAdded = Array.isArray(json.added) ? json.added : []
366 const added = rawAdded
367 .filter((item): item is string => typeof item === 'string')
368 .map(item => item.replace(/\s+/g, ' ').trim())
369 .filter(item => item !== '')
370 .slice(0, MAX_ADDED)
371
372 return { isOk: true, prompt, added }
373}
374
375// ---------------------------------------------------------------------------
376// What the model is asked
377// ---------------------------------------------------------------------------
378
379export function questionsSystem(level: PromptUpgradeDetail): string {
380 const { maxQuestions, maxOptions, checkRule } = DETAIL_SPECS[level]
381 return [
382 'You check a rough prompt before it is sent to an AI assistant, and find out what it is missing.',
383 'A prompt is ready when it already has what its task needs: a goal, who it is for, the format, what done looks like, and any inputs or constraints the task depends on.',
384 "You also get Claude's last message. A short reply that answers or approves what Claude just asked counts as ready: \"yes do it\" after \"Shall I build it?\" is ready, while \"fix it\" with nothing before it is not.",
385 'If the prompt is ready, reply with ready true and no questions.',
386 `If it is not, list each gap in "missing" in a few words, then ask only as many multiple-choice questions as the gaps need: at least 1 and at most ${maxQuestions}.`,
387 `Each question has ${MIN_OPTIONS} to ${maxOptions} short options of a few words each.`,
388 ...(checkRule === undefined ? [] : [checkRule]),
389 'Never ask something the prompt already says. Never invent facts.',
390 'Reply with strict JSON only, with no prose and no code fences, in this shape:',
391 '{"ready":false,"missing":["short gap","..."],"questions":[{"question":"...","options":["...","..."]}]}',
392 'When ready, reply {"ready":true,"missing":[],"questions":[]}.',
393 ].join('\n')
394}
395
396export function rewriteSystem(level: PromptUpgradeDetail): string {
397 return [
398 'You rewrite a rough prompt into a great one before it is sent to an AI assistant.',
399 'The rules below are written by the person whose prompt it is: "I", "my" and "me" mean that person.',
400 '',
401 'Rules:',
402 '- Keep exactly what I meant. Add a clear goal, who it is for, the format, and what "done" looks like.',
403 '- Use what I answered. Leave out what I skipped.',
404 '- Never invent facts, names, numbers or prices. Where something is unknown, leave a [placeholder] in square brackets.',
405 '- Write it as one request I could have typed myself, in plain language.',
406 '',
407 `Detail level: ${DETAIL_SPECS[level].label}.`,
408 ...DETAIL_SPECS[level].rewriteRules,
409 '',
410 'Reply with strict JSON only, with no prose and no code fences, in this shape:',
411 '{"prompt":"the upgraded prompt","added":["short note on one detail you added","..."]}',
412 'The "added" list names, in a few words each, the details you added that the rough prompt did not have.',
413 ].join('\n')
414}
415
416/** The check's request: Claude's last message (when there is one) and the rough prompt. */
417export function questionsPrompt(original: string, lastReply = ''): string {
418 const context = lastReply.trim() === '' ? '(none: this starts the conversation)' : `"""\n${lastReply.trim()}\n"""`
419 return `Claude's last message:\n${context}\n\nRough prompt:\n"""\n${original}\n"""`
420}
421
422/** The answered questions only; skipped ones are left out. */
423export function answeredPairs(
424 questions: readonly PromptUpgradeQuestion[],
425 answers: readonly PromptUpgradeAnswer[],
426): PromptUpgradeQa[] {
427 const pairs: PromptUpgradeQa[] = []
428 questions.forEach((question, index) => {
429 const answer = answers[index]
430 if (answer !== undefined && answer.status === 'answered' && answer.text.trim() !== '') {
431 pairs.push({ question: question.question, answer: answer.text.trim() })
432 }
433 })
434 return pairs
435}
436
437/** The questions still open: not answered and not skipped. */
438export function unansweredQuestions(
439 questions: readonly PromptUpgradeQuestion[],
440 answers: readonly PromptUpgradeAnswer[],
441): PromptUpgradeQuestion[] {
442 return questions.filter((_, index) => answers[index]?.status === 'open')
443}
444
445/** Added to the rewrite when the rest is filled in for the person. */
446export const CHOOSE_FOR_UNANSWERED =
447 'For each question I did not answer, choose the option that best fits the task. If choosing would mean inventing a fact, leave a [placeholder] instead.'
448
449export function rewritePrompt(
450 original: string,
451 pairs: readonly PromptUpgradeQa[],
452 unanswered: readonly PromptUpgradeQuestion[] = [],
453): string {
454 const answered =
455 pairs.length === 0
456 ? 'No questions were answered.'
457 : pairs.map(pair => `Q: ${pair.question}\nA: ${pair.answer}`).join('\n\n')
458 const request = `Rough prompt:\n"""\n${original}\n"""\n\nWhat I answered:\n${answered}`
459 if (unanswered.length === 0) return request
460
461 const open = unanswered.map(question => `Q: ${question.question}\nOptions: ${question.options.join(' / ')}`).join('\n\n')
462 return `${request}\n\nQuestions I did not answer:\n${open}\n\n${CHOOSE_FOR_UNANSWERED}`
463}
464
465// ---------------------------------------------------------------------------
466// Flow state helpers
467// ---------------------------------------------------------------------------
468
469export function idleFlow(requestId: number): PromptUpgradeFlow {
470 return {
471 stage: 'idle',
472 requestId,
473 original: '',
474 detailLevel: DEFAULT_DETAIL,
475 startedBy: 'command',
476 questions: [],
477 answers: [],
478 typingFor: null,
479 upgraded: '',
480 added: [],
481 draft: '',
482 entryId: null,
483 failedStep: 'asking',
484 note: null,
485 autoSendAt: null,
486 isSendingAutomatically: false,
487 }
488}
489
490/** A flow that starts on a prompt, fixed at the given detail level. Automatic mode starts at the check. */
491export function readingFlow(
492 requestId: number,
493 original: string,
494 detailLevel: PromptUpgradeDetail,
495 startedBy: PromptUpgradeSource,
496): PromptUpgradeFlow {
497 return { ...idleFlow(requestId), stage: startedBy === 'auto' ? 'checking' : 'reading', original, detailLevel, startedBy }
498}
499
500export function openCount(answers: readonly PromptUpgradeAnswer[]): number {
501 return answers.filter(answer => answer.status === 'open').length
502}
503
504export function answeredCount(answers: readonly PromptUpgradeAnswer[]): number {
505 return answers.filter(answer => answer.status === 'answered').length
506}
507
508export type Stats = { wordsBefore: number; wordsAfter: number; detailsAdded: number }
509
510export function buildStats(original: string, upgraded: string, added: readonly string[]): Stats {
511 return { wordsBefore: wordCount(original), wordsAfter: wordCount(upgraded), detailsAdded: added.length }
512}
513
514/** Newest first, at most 20; an entry with an existing id replaces that one. */
515export function trimHistory(list: readonly PromptUpgradeEntry[], entry: PromptUpgradeEntry): PromptUpgradeEntry[] {
516 return [entry, ...list.filter(other => other.id !== entry.id)].slice(0, HISTORY_LIMIT)
517}
518
519/** Keeps only well-formed entries from whatever the store held. */
520export function restoreHistory(saved: unknown): PromptUpgradeEntry[] {
521 if (!Array.isArray(saved)) return []
522 const entries = saved.filter(
523 (item): item is PromptUpgradeEntry =>
524 isRecord(item) &&
525 typeof item.id === 'string' &&
526 typeof item.original === 'string' &&
527 typeof item.upgraded === 'string' &&
528 Array.isArray(item.added),
529 )
530 // Entries saved before levels existed read as medium.
531 return entries.map(entry => ({ ...entry, detailLevel: detailOrDefault(entry.detailLevel) })).slice(0, HISTORY_LIMIT)
532}
533
534/** "just now", "5m ago", "3h ago" or "2d ago". */
535export function ageLabel(milliseconds: number): string {
536 const seconds = Math.max(0, Math.floor(milliseconds / 1000))
537 if (seconds < 60) return 'just now'
538 const minutes = Math.floor(seconds / 60)
539 if (minutes < 60) return `${minutes}m ago`
540 const hours = Math.floor(minutes / 60)
541 if (hours < 24) return `${hours}h ago`
542
543 return `${Math.floor(hours / 24)}d ago`
544}
545
546// ---------------------------------------------------------------------------
547// The tracker across the top
548// ---------------------------------------------------------------------------
549
550export type StepState = 'done' | 'current' | 'upcoming'
551
552const STEP_NAMES = ['Your prompt', 'Questions', 'Rewrite', 'Review'] as const
553const STEP_SHORT_NAMES = ['Prompt', 'Ask', 'Write', 'Review'] as const
554
555/** Which of the four steps the stage is on, or null for screens without a tracker. */
556export function currentStep(stage: PromptUpgradeStage, failedStep: PromptUpgradeStep): number | null {
557 switch (stage) {
558 case 'typing':
559 case 'reading':
560 case 'checking':
561 case 'ready':
562 return 0
563 case 'asking':
564 case 'questions':
565 return 1
566 case 'rewriting':
567 return 2
568 case 'review':
569 case 'editing':
570 return 3
571 case 'error':
572 return failedStep === 'asking' ? 1 : 2
573 default:
574 return null
575 }
576}
577
578export function stepStates(current: number): StepState[] {
579 return STEP_NAMES.map((_, index) => (index < current ? 'done' : index === current ? 'current' : 'upcoming'))
580}
581
582export type TrackerPiece = { text: string; kind: StepState | 'separator' }
583
584/** The tracker as colored pieces, in the longest form that fits the width. */
585export function trackerPieces(states: readonly StepState[], width: number): TrackerPiece[] {
586 const build = (names: readonly string[], separator: string): TrackerPiece[] => {
587 const pieces: TrackerPiece[] = []
588 names.forEach((name, index) => {
589 if (index > 0) pieces.push({ text: separator, kind: 'separator' })
590 const state = states[index] ?? 'upcoming'
591 pieces.push({ text: `${state === 'done' ? '✓' : state === 'current' ? '●' : '○'} ${name}`, kind: state })
592 })
593 return pieces
594 }
595 const length = (pieces: readonly TrackerPiece[]) => pieces.reduce((total, piece) => total + piece.text.length, 0)
596
597 for (const [names, separator] of [
598 [STEP_NAMES, ' ─ '],
599 [STEP_NAMES, ' ─ '],
600 [STEP_SHORT_NAMES, ' ─ '],
601 [STEP_SHORT_NAMES, ' '],
602 ] as const) {
603 const pieces = build(names, separator)
604 if (length(pieces) <= width) return pieces
605 }
606 return build(STEP_SHORT_NAMES, ' ')
607}
608
609// ---------------------------------------------------------------------------
610// Fitting rows to the pane's width
611// ---------------------------------------------------------------------------
612
613/** Cuts a line to the width, ending with an ellipsis when it was cut. */
614export function truncate(text: string, width: number): string {
615 if (width <= 0) return ''
616 if (text.length <= width) return text
617 return width === 1 ? '…' : `${text.slice(0, width - 1)}…`
618}
619
620/** Pads a line with spaces at the end up to the width. */
621export function padEnd(text: string, width: number): string {
622 return text.length >= width ? text : text + ' '.repeat(width - text.length)
623}
624
625/** Centers a line in the width. */
626export function center(text: string, width: number): string {
627 if (text.length >= width) return text
628 const left = Math.floor((width - text.length) / 2)
629 return ' '.repeat(left) + text + ' '.repeat(width - text.length - left)
630}
631
632/** Breaks text into lines no wider than the width, keeping its own line breaks. */
633export function wrapText(text: string, width: number): string[] {
634 const room = Math.max(1, width)
635 const lines: string[] = []
636
637 for (const paragraph of text.replace(/\r\n?/g, '\n').split('\n')) {
638 if (paragraph.trim() === '') {
639 lines.push('')
640 continue
641 }
642 let line = ''
643 for (const word of paragraph.trim().split(/\s+/)) {
644 let rest = word
645 // A word longer than the row is broken across rows.
646 while (rest.length > room) {
647 if (line !== '') {
648 lines.push(line)
649 line = ''
650 }
651 lines.push(rest.slice(0, room))
652 rest = rest.slice(room)
653 }
654 if (line === '') line = rest
655 else if (line.length + 1 + rest.length <= room) line = `${line} ${rest}`
656 else {
657 lines.push(line)
658 line = rest
659 }
660 }
661 if (line !== '') lines.push(line)
662 }
663 return lines
664}
665
666/** The lines a card shows: wrapped, and cut with a note past the limit. */
667export function cardLines(text: string, width: number, limit: number = CARD_MAX_LINES): string[] {
668 const lines = wrapText(text, width)
669 if (lines.length <= limit) return lines
670
671 return [...lines.slice(0, limit), truncate(`… ${lines.length - limit} more lines`, width)]
672}
673
674/** Groups items into rows so each row (items plus gaps) fits the width. Returns indexes. */
675export function packRows(widths: readonly number[], gap: number, width: number): number[][] {
676 const rows: number[][] = []
677 let row: number[] = []
678 let used = 0
679
680 widths.forEach((itemWidth, index) => {
681 const needed = row.length === 0 ? itemWidth : used + gap + itemWidth
682 if (row.length > 0 && needed > width) {
683 rows.push(row)
684 row = [index]
685 used = itemWidth
686 } else {
687 row.push(index)
688 used = needed
689 }
690 })
691 if (row.length > 0) rows.push(row)
692
693 return rows
694}
695
696/** A chip's text: the label with one space on each side. */
697export function chipText(label: string): string {
698 return ` ${label} `
699}
700
701/** A section heading as its label and the hairline that fills the rest of the row. */
702export function headingParts(label: string, width: number): { label: string; rule: string } {
703 const shown = truncate(label.toUpperCase(), Math.max(1, width - 2))
704 return { label: shown, rule: '─'.repeat(Math.max(0, width - shown.length - 1)) }
705}
706
707/** The title's gold rule (as wide as the title) and the hairline that carries on to the edge. */
708export function titleRuleParts(title: string, width: number): { gold: string; hairline: string } {
709 const goldWidth = Math.min(title.length, width)
710 return { gold: '━'.repeat(goldWidth), hairline: '─'.repeat(Math.max(0, width - goldWidth)) }
711}
712
713// ---------------------------------------------------------------------------
714// The detail dropdown
715// ---------------------------------------------------------------------------
716
717/** Columns the dropdown is planned for: its label, the longest option and its arrow and padding. */
718export const SELECT_LABEL = 'Detail'
719export const SELECT_WIDTH =
720 SELECT_LABEL.length + 2 + Math.max(...DETAIL_LEVELS.map(level => DETAIL_SPECS[level].optionLabel.length)) + 4
721
722/** The dropdown sits on the title's row when both fit with room to spare, else on its own line. */
723export function isSelectInline(width: number): boolean {
724 return width >= Math.max(60, TITLE_TEXT.length + 1 + SELECT_WIDTH)
725}
726
727export type CardLayout = { isStacked: boolean; cardWidth: number }
728
729/** Two cards side by side, or stacked below about 70 columns. */
730export function cardLayout(width: number): CardLayout {
731 if (width < STACK_BELOW) return { isStacked: true, cardWidth: width }
732 return { isStacked: false, cardWidth: Math.floor((width - 1) / 2) }
733}
734
735// ---------------------------------------------------------------------------
736// Big numbers: three rows tall, in heavy box-drawing characters
737// ---------------------------------------------------------------------------
738
739const BIG_DIGITS: Readonly<Record<string, readonly [string, string, string]>> = {
740 '0': ['┏━┓', '┃ ┃', '┗━┛'],
741 '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
742 '2': ['╺━┓', '┏━┛', '┗━╸'],
743 '3': ['╺━┓', '╺━┫', '╺━┛'],
744 '4': ['╻ ╻', '┗━┫', ' ╹'],
745 '5': ['┏━╸', '┗━┓', '╺━┛'],
746 '6': ['┏━╸', '┣━┓', '┗━┛'],
747 '7': ['╺━┓', ' ┃', ' ╹'],
748 '8': ['┏━┓', '┣━┫', '┗━┛'],
749 '9': ['┏━┓', '┗━┫', '╺━┛'],
750}
751
752const BIGGEST_NUMBER = 99_999
753
754/** A number as three rows of heavy digits, one space between digits. */
755export function bigNumber(value: number): [string, string, string] {
756 const digits = String(Math.max(0, Math.min(BIGGEST_NUMBER, Math.floor(value))))
757 const rows: [string, string, string] = ['', '', '']
758 digits.split('').forEach((digit, index) => {
759 const glyph = BIG_DIGITS[digit] ?? BIG_DIGITS['0']!
760 for (let row = 0; row < 3; row += 1) rows[row] += (index > 0 ? ' ' : '') + glyph[row]
761 })
762 return rows
763}
764
765export type StatTile = { label: string; rows: [string, string, string]; innerWidth: number; tone: 'text' | 'good' }
766
767export type StatLayout = { mode: 'tiles'; tiles: StatTile[] } | { mode: 'line'; text: string }
768
769/** The review's numbers as three tiles, or one plain line when the pane is too narrow for tiles. */
770export function statLayout(stats: Stats, width: number): StatLayout {
771 const build = (labels: readonly [string, string, string]): StatTile[] => {
772 const values = [stats.wordsBefore, stats.wordsAfter, stats.detailsAdded]
773 return values.map((value, index): StatTile => {
774 const rows = bigNumber(value)
775 const label = labels[index] ?? ''
776 return {
777 label,
778 rows,
779 innerWidth: Math.max(label.length, rows[0].length),
780 tone: index === 2 ? 'good' : 'text',
781 }
782 })
783 }
784 // Each tile is its content plus a border and one space on each side; tiles sit one space apart.
785 const total = (tiles: readonly StatTile[]) => tiles.reduce((sum, tile) => sum + tile.innerWidth + 4, 0) + tiles.length - 1
786
787 for (const labels of [
788 ['WORDS BEFORE', 'WORDS AFTER', 'DETAILS ADDED'],
789 ['BEFORE', 'AFTER', 'ADDED'],
790 ] as const) {
791 const tiles = build(labels)
792 if (total(tiles) <= width) return { mode: 'tiles', tiles }
793 }
794 return {
795 mode: 'line',
796 text: truncate(
797 `${stats.wordsBefore} words → ${stats.wordsAfter} words, ${stats.detailsAdded} details added`,
798 width,
799 ),
800 }
801}
802types/index.d.ts 117 lines1// Prompt Upgrade's shared state: the step the pane is on, and the saved upgrades.
2
3/** Where the upgrade flow stands; drives which screen the pane draws. */
4export type PromptUpgradeStage =
5 | 'idle'
6 | 'typing'
7 | 'reading'
8 /** Automatic mode's check, run before the pane opens. */
9 | 'checking'
10 | 'asking'
11 /** The check found the prompt already good. */
12 | 'ready'
13 | 'questions'
14 | 'rewriting'
15 | 'review'
16 | 'editing'
17 | 'error'
18 | 'history'
19
20/** How much the upgrade adds: auto (decides for itself), low, medium (the default) or high. */
21export type PromptUpgradeDetail = 'auto' | 'low' | 'medium' | 'high'
22
23/** What started the flow: a prompt caught by automatic mode, or the /better command. */
24export type PromptUpgradeSource = 'auto' | 'command'
25
26/** One multiple-choice question the fast model asked. */
27export type PromptUpgradeQuestion = {
28 question: string
29 /** Two to four short options. */
30 options: string[]
31}
32
33/** How one question was handled. `text` is the chosen or typed answer. */
34export type PromptUpgradeAnswer = {
35 status: 'open' | 'answered' | 'skipped'
36 text: string
37}
38
39/** A short inline message in the pane (for example after Copy). */
40export type PromptUpgradeNote = {
41 text: string
42 tone: 'good' | 'warn'
43}
44
45/** The model call that failed, so Try again knows what to repeat. */
46export type PromptUpgradeStep = 'asking' | 'rewriting'
47
48/** Everything the pane draws from. Written only by presses, commands and events. */
49export type PromptUpgradeFlow = {
50 stage: PromptUpgradeStage
51 /** Bumped on every new call, so a stale model answer is ignored. */
52 requestId: number
53 /** The person's words, exactly as typed (without a leading ">"). */
54 original: string
55 /** The detail level this upgrade was started at; a change in the dropdown never touches it. */
56 detailLevel: PromptUpgradeDetail
57 startedBy: PromptUpgradeSource
58 questions: PromptUpgradeQuestion[]
59 /** One entry per question, same order. */
60 answers: PromptUpgradeAnswer[]
61 /** Index of the question whose "Other" box is open, or null. */
62 typingFor: number | null
63 upgraded: string
64 added: string[]
65 /** The text in the edit box while editing. */
66 draft: string
67 /** The history entry of the review on screen, or null. */
68 entryId: string | null
69 failedStep: PromptUpgradeStep
70 note: PromptUpgradeNote | null
71 /** When the Auto countdown ends (milliseconds since the epoch), or null when none is running. */
72 autoSendAt: number | null
73 /** True while the rewrite is for "fill the rest and send it", so it is sent with no review. */
74 isSendingAutomatically: boolean
75}
76
77/** One question and the answer given, as kept in the history. */
78export type PromptUpgradeQa = {
79 question: string
80 answer: string
81}
82
83/** One finished upgrade, as kept in the history (the last 20). */
84export type PromptUpgradeEntry = {
85 id: string
86 original: string
87 upgraded: string
88 added: string[]
89 wordsBefore: number
90 wordsAfter: number
91 detailsAdded: number
92 answers: PromptUpgradeQa[]
93 /** The detail level it was written at; older entries have none and read as medium. */
94 detailLevel: PromptUpgradeDetail
95 /** True when the upgrade was sent on its own (Auto's countdown or "Do it for me"), with no review. */
96 sentAutomatically?: boolean
97 /** Milliseconds since the epoch, from the engine's clock. */
98 at: number
99}
100
101declare module 'claude-code' {
102 interface PluginState {
103 'prompt-upgrade': {
104 flow: PromptUpgradeFlow
105 history: PromptUpgradeEntry[]
106 /** True while automatic mode is on; other mods (the status bar) read it. */
107 isAutoOn: boolean
108 /** The detail level picked in the dropdown. */
109 detailLevel: PromptUpgradeDetail
110 /** The end of Claude's last main-loop reply, kept as context for the prompt check. */
111 lastReply: string
112 /** The last prompt automatic mode caught, normalized; sending it again passes it through. */
113 lastIntercepted: string
114 }
115 }
116}
117