SLOPSHOPPER

prompt-upgrade

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…

newpanecommandtoaststatusprompt
★ 1v0.1.0no licenseupdated 2026-10-14ataege/claude-skills/plugins/prompt-upgrade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-upgrade
│ ┃ prompt-upgrade ✕ › fix the failing auth test and add an audit log call │ ┃ PROMPT UPGRADE │ ┃ Detail: Medium: balanced ▾ ⏺ Read(src/auth.ts) │ ┃ ━━━━━━━━━━━━━━────────────────────────────── ⎿ Read 6 lines │ ┃ ● Your prompt ─ ○ Questions ─ ○ Rewrite ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ YOUR PROMPT ──────────────────────────────── ⏺ Bash(bun test) │ ┃ Type a rough prompt. Press Enter and I will ⎿ 3 pass, 1 fail │ ┃ Prompt: what you want ⏎ Upgrade │ ┃ cancel ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /better │ ⎿ prompt-upgrade: Type your prompt in the Prompt Upgrade pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prompt-upgrade
PROMPT UPGRADE Detail: Medium: balanced ▾ ━━━━━━━━━━━━━━────────────────────────────────────────── ● Your prompt ─ ○ Questions ─ ○ Rewrite ─ ○ Review YOUR PROMPT ──────────────────────────────────────────── Type a rough prompt. Press Enter and I will upgrade it. Prompt: what you want ⏎ Upgrade cancel
README

claude-skills

A Claude Code plugin marketplace.

PluginWhat it does
clean-viewHides 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-barOne colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort.
model-routingRoutes 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-menuA ☰ 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-upgradeTurns 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.

Install

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.

Developing

Each plugin lives in plugins/<name>. Check one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
Source 4 files
hooks/register.tsx 9 lines
1import 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}
9
hooks/prompt-upgrade.tsx 1432 lines
1// 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))
1200
hooks/prompt-upgrade-logic.ts 802 lines
1// 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}
802
types/index.d.ts 117 lines
1// 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