SLOPSHOPPER

prompt-upgrade

Turns a rough prompt into a clear one: up to three quick questions, a rewrite, and a side-by-side review before anything runs.

newpanecommandtoastpromptmodel
v0.1.0no licenseupdated 2026-10-16iansutherland75/claude-mods/mods/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 │ ┃ ━━━━━━━━━━━━━━────────────────────────────── ⏺ 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) │ ┃ Your rough prompt: Type it, then press Enter ⎿ 3 pass, 1 fail │ ┃ │ ┃ cancel ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /better │ ⎿ prompt-upgrade: Type your rough prompt in the pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt Upgrade
PROMPT UPGRADE AUTO OFF ━━━━━━━━━━━━━━────────────────────────────────────────── ● Your prompt ── ○ Questions ── ○ Rewrite ── ○ Review YOUR PROMPT ──────────────────────────────────────────── Your rough prompt: Type it, then press Enter cancel
README

claude-mods

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.

ModWhat it does
prompt-upgrade/better <prompt> asks up to three quick questions, rewrites the prompt and shows the original and the rewrite side by side. Nothing runs until you press Run. /better on sends what you type to it first.
work-report/report opens a pane with the tasks Claude finished for you and an estimate of the hours saved and what that time is worth, for today, the last 7 days, the last 30 days and the year. /report settings shows and changes the minutes per task and the hourly rate. /report backfill fills in past days from this machine's session history (needs /usr/bin/python3). Building software is estimated from the lines of code written (/report settings buildrate 25), and a By project section shows hours per repo. /report add "QBR builder" 160 2026-05-01 adds hours for older work (/report list, /report remove 1). Counts are kept per machine.
ai-employee-kitClean View: while Claude works, tool calls, diffs and command output are hidden and a plain-English checklist of the job sits above the prompt (header, steps, progress meters, Needs you / Stuck / Stopped / All done). /simple on / /simple off, or the "Clean View" button in the footer. In the terminal Claude must lay out its steps first; in the desktop app its own task list is the plan.

Set up a machine

The clone lives at ~/claude-mods on every machine. Install each mod once per machine at user scope. After that it loads in every local session: the desktop app's Code tab and the terminal.

git clone https://github.com/iansutherland75/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods
claude plugin install prompt-upgrade@ian-mods --scope user
claude plugin install work-report@ian-mods --scope user
claude plugin install ai-employee-kit@ian-mods --scope user

Run these with Claude Code 2.1.295 or newer. If claude --version shows an older build, run brew upgrade claude-code first.

Keep machines in step

The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.

  1. git pull in ~/claude-mods.
  2. Run /reload-plugins in any open session. New sessions pick it up on their own.

A new mod needs one extra step on each machine: claude plugin install <mod>@ian-mods --scope user.

Add a mod

  1. Put it in mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests.
  2. Add an entry to .claude-plugin/marketplace.json with "source": "./mods/<mod-name>".
  3. Check it: claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.
  4. Commit, push, then install it on each machine.

Not covered

Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.

Source 3 files
hooks/register.tsx 1059 lines
1import type { Elements, EngineInterface, Register, RenderSurface } from 'claude-code'
2
3import type { Answer, Flow, HistoryEntry, Outcome, View } from '../types'
4import {
5  QUESTION_MODEL,
6  QUESTION_SYSTEM,
7  REWRITE_MODEL,
8  REWRITE_SYSTEM,
9  answerText,
10  bigNumber,
11  chipRows,
12  classify,
13  clip,
14  describeFailure,
15  parseQuestions,
16  parseRewrite,
17  questionPrompt,
18  rewritePrompt,
19  trackerParts,
20  wordCount,
21  wrap,
22} from './logic'
23import type { Failure } from './logic'
24
25type D = EngineInterface
26
27const PANE = 'prompt-upgrade'
28const TITLE = 'Prompt Upgrade'
29const HEADING = 'PROMPT UPGRADE'
30
31const GOLD = '#F2C46D'
32const LINE = '#262B33'
33const MUTED = '#7D8590'
34const TEXT = '#E6EDF3'
35const GREEN = '#3DD68C'
36const AMBER = '#E3B341'
37const INK = '#0D1117'
38
39const NARROW = 'Prompt Upgrade needs a wider window, so your words are back in the prompt box.'
40const TAKEN = 'Prompt Upgrade has your prompt. Nothing runs until you press Run.'
41const FAILED = 'Prompt Upgrade hit a problem and stopped. Nothing was sent.'
42
43// ---------------------------------------------------------------- state
44
45const FLOW = { plugin: 'prompt-upgrade', key: 'flow' } as const
46const VIEW = { plugin: 'prompt-upgrade', key: 'view' } as const
47const AUTO = { plugin: 'prompt-upgrade', key: 'auto' } as const
48const HISTORY = { plugin: 'prompt-upgrade', key: 'history' } as const
49
50// Reads while drawing subscribe the drawing; writes come only from events.
51const getFlow = async ($: D): Promise<Flow | null> => (await $.state.get(FLOW)).value ?? null
52const getView = async ($: D): Promise<View> => (await $.state.get(VIEW)).value ?? 'flow'
53const getAuto = async ($: D): Promise<boolean> => (await $.state.get(AUTO)).value ?? false
54const getHistory = async ($: D): Promise<HistoryEntry[]> => (await $.state.get(HISTORY)).value ?? []
55
56// Read, apply, write with ifVersion; again on a miss, so two presses both land.
57const changeFlow = async ($: D, change: (flow: Flow | null) => Flow | null): Promise<Flow | null> => {
58  for (let tries = 0; tries < 20; tries++) {
59    const now = await $.state.get(FLOW)
60    const next = change(now.value ?? null)
61    const result = await $.state.set(FLOW, next, { ifVersion: now.version })
62    if (result.isSet) return next
63  }
64  throw new Error('flow kept changing')
65}
66
67const changeHistory = async ($: D, change: (list: HistoryEntry[]) => HistoryEntry[]): Promise<HistoryEntry[]> => {
68  for (let tries = 0; tries < 20; tries++) {
69    const now = await $.state.get(HISTORY)
70    const next = change(now.value ?? [])
71    const result = await $.state.set(HISTORY, next, { ifVersion: now.version })
72    if (result.isSet) return next
73  }
74  throw new Error('history kept changing')
75}
76
77const setView = async ($: D, view: View) => {
78  await $.state.set(VIEW, view)
79}
80
81
82const blank = (id: string, original: string): Flow => ({
83  id,
84  original,
85  step: 'compose',
86  questions: [],
87  answers: [],
88  otherOpen: null,
89  upgraded: '',
90  added: [],
91  pending: null,
92  runId: 0,
93  error: null,
94  isEditing: false,
95  copyNote: null,
96})
97
98let counter = 0
99const newId = async ($: D): Promise<string> =>
100  `${await $.clock.now()}-${(counter++).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
101
102const isWaiting = (
103  flow: Flow | null,
104  id: string,
105  runId: number,
106  kind: 'questions' | 'rewrite',
107): flow is Flow => flow !== null && flow.id === id && flow.runId === runId && flow.pending === kind
108
109const RANK: Record<Outcome, number> = { ready: 0, cancelled: 1, copied: 2, ran: 3 }
110
111const saveHistory = async ($: D, change: (list: HistoryEntry[]) => HistoryEntry[]) => {
112  const list = await changeHistory($, list => change(list).slice(0, 20))
113  await $.store.set('history', list)
114}
115
116const markOutcome = ($: D, id: string, outcome: Outcome) =>
117  saveHistory($, list =>
118    list.map(entry =>
119      entry.id === id && RANK[outcome] > RANK[entry.outcome] ? { ...entry, outcome } : entry,
120    ),
121  )
122
123const setAuto = async ($: D, isOn: boolean) => {
124  await $.state.set(AUTO, isOn)
125  await $.store.set('auto', isOn)
126}
127
128// ---------------------------------------------------------------- model calls
129
130type Reply = { ok: true; text: string } | { ok: false; message: string }
131
132const complete = async ($: D, request: Parameters<D['model']['complete']>[0]): Promise<Reply> => {
133  try {
134    const reply = await $.model.complete(request)
135    if (reply.isAnswered) return { ok: true, text: reply.text }
136
137    return { ok: false, message: describeFailure(reply as Failure) }
138  } catch {
139    return { ok: false, message: 'The model call could not be made.' }
140  }
141}
142
143const startFlow = async ($: D, original: string): Promise<Flow> => {
144  const flow: Flow = { ...blank(await newId($), original), step: 'reading', pending: 'questions', runId: 1 }
145  await setView($, 'flow')
146  await changeFlow($, () => flow)
147
148  return flow
149}
150
151const askQuestions = async ($: D, id: string, runId: number): Promise<void> => {
152  const flow = await getFlow($)
153  if (!isWaiting(flow, id, runId, 'questions')) return
154
155  const reply = await complete($, {
156    model: QUESTION_MODEL,
157    system: QUESTION_SYSTEM,
158    prompt: questionPrompt(flow.original),
159    maxTokens: 800,
160    effort: 'low',
161    timeoutMs: 30_000,
162  })
163
164  const next = await changeFlow($, current => {
165    if (!isWaiting(current, id, runId, 'questions')) return current
166    if (!reply.ok) {
167      return { ...current, step: 'error', pending: null, error: { at: 'questions', message: reply.message } }
168    }
169    const questions = parseQuestions(reply.text) ?? []
170    if (questions.length === 0) {
171      return { ...current, step: 'rewrite', pending: 'rewrite', runId: runId + 1 }
172    }
173
174    return { ...current, step: 'questions', pending: null, questions, answers: questions.map(() => null) }
175  })
176
177  if (isWaiting(next, id, runId + 1, 'rewrite')) await runRewrite($, id, runId + 1)
178}
179
180const startRewrite = async ($: D): Promise<void> => {
181  const hit = { id: '', runId: 0 }
182  await changeFlow($, current => {
183    if (current === null || (current.step !== 'questions' && current.step !== 'error')) return current
184    hit.id = current.id
185    hit.runId = current.runId + 1
186
187    return { ...current, step: 'rewrite', pending: 'rewrite', runId: hit.runId, otherOpen: null, error: null }
188  })
189  if (hit.id !== '') await runRewrite($, hit.id, hit.runId)
190}
191
192const runRewrite = async ($: D, id: string, runId: number): Promise<void> => {
193  const flow = await getFlow($)
194  if (!isWaiting(flow, id, runId, 'rewrite')) return
195
196  const reply = await complete($, {
197    model: REWRITE_MODEL,
198    system: REWRITE_SYSTEM,
199    prompt: rewritePrompt(flow.original, flow.questions, flow.answers),
200    maxTokens: 2000,
201    timeoutMs: 90_000,
202  })
203  const parsed = reply.ok ? parseRewrite(reply.text) : null
204  const at = await $.clock.now()
205
206  const next = await changeFlow($, current => {
207    if (!isWaiting(current, id, runId, 'rewrite')) return current
208    if (!reply.ok) {
209      return { ...current, step: 'error', pending: null, error: { at: 'rewrite', message: reply.message } }
210    }
211    if (parsed === null) {
212      return {
213        ...current,
214        step: 'error',
215        pending: null,
216        error: { at: 'rewrite', message: 'The rewrite came back in a form I could not read.' },
217      }
218    }
219
220    return { ...current, step: 'review', pending: null, upgraded: parsed.prompt, added: parsed.added }
221  })
222
223  if (parsed !== null && next !== null && next.id === id && next.step === 'review') {
224    const entry: HistoryEntry = {
225      id,
226      at,
227      original: next.original,
228      upgraded: parsed.prompt,
229      added: parsed.added,
230      outcome: 'ready',
231    }
232    await saveHistory($, list => [entry, ...list.filter(one => one.id !== id)])
233  }
234}
235
236// Picks a step back up after the module reloaded: the pending model call
237// died with the old module, so it is made again under a new run id.
238const resume = async ($: D): Promise<void> => {
239  const flow = await getFlow($)
240  if (flow === null) return
241
242  const panes = await $.ui.panes()
243  if (!panes.some(pane => pane.id === PANE)) void $.ui.open({ id: PANE, title: TITLE })
244
245  if (flow.pending === null) return
246  const next = await changeFlow($, current =>
247    current !== null && current.id === flow.id && current.pending !== null
248      ? { ...current, runId: current.runId + 1 }
249      : current,
250  )
251  if (next === null || next.pending === null) return
252  if (next.pending === 'questions') await askQuestions($, next.id, next.runId)
253  else await runRewrite($, next.id, next.runId)
254}
255
256// ---------------------------------------------------------------- actions
257
258const closePane = async ($: D) => {
259  await changeFlow($, () => null)
260  try {
261    await $.ui.close({ id: PANE })
262  } catch {
263    // already closed
264  }
265}
266
267// Sends the upgrade as the person's own words. Its origin is this plugin,
268// which the prompt.submit hook always lets through, so it cannot loop back.
269const runIt = async ($: D, text: string) => {
270  const body = text.trim()
271  if (body === '') return
272  const flow = await getFlow($)
273  if (flow !== null) await markOutcome($, flow.id, 'ran')
274  await closePane($)
275  await $.prompt.submit({ text: body, asUser: true })
276}
277
278const cancel = async ($: D) => {
279  const flow = await getFlow($)
280  await closePane($)
281  if (flow === null || flow.original.trim() === '') return
282  await markOutcome($, flow.id, 'cancelled')
283  await $.prompt.fill({ text: flow.original, mode: 'replace' })
284}
285
286const answer = async ($: D, id: string, index: number, value: Answer) => {
287  const next = await changeFlow($, current =>
288    current !== null && current.id === id && current.step === 'questions'
289      ? {
290          ...current,
291          answers: current.answers.map((one, i) => (i === index ? value : one)),
292          otherOpen: current.otherOpen === index ? null : current.otherOpen,
293        }
294      : current,
295  )
296  if (next !== null && next.id === id && next.step === 'questions' && next.answers.every(one => one !== null)) {
297    await startRewrite($)
298  }
299}
300
301const retry = async ($: D) => {
302  const flow = await getFlow($)
303  if (flow === null || flow.error === null) return
304  if (flow.error.at === 'rewrite') return startRewrite($)
305
306  const next = await changeFlow($, current =>
307    current !== null && current.id === flow.id && current.step === 'error'
308      ? { ...current, step: 'reading', pending: 'questions', runId: current.runId + 1, error: null }
309      : current,
310  )
311  if (next !== null && next.pending === 'questions') await askQuestions($, next.id, next.runId)
312}
313
314const copy = async ($: D, surface: RenderSurface) => {
315  const flow = await getFlow($)
316  if (flow === null) return
317  const result = await $.ui.copy({ text: flow.upgraded, surface })
318  await changeFlow($, current =>
319    current !== null && current.id === flow.id
320      ? { ...current, copyNote: result.isCopied ? 'copied' : 'failed' }
321      : current,
322  )
323  if (result.isCopied) await markOutcome($, flow.id, 'copied')
324}
325
326const patch = ($: D, change: Partial<Flow>) =>
327  changeFlow($, current => (current === null ? current : { ...current, ...change }))
328
329const reopen = async ($: D, entry: HistoryEntry) => {
330  await changeFlow($, () => ({
331    ...blank(entry.id, entry.original),
332    step: 'review',
333    upgraded: entry.upgraded,
334    added: entry.added,
335  }))
336  await setView($, 'flow')
337}
338
339// A handler that never throws into the surface.
340const act = (work: () => Promise<unknown>) => () => {
341  void work().catch(() => undefined)
342}
343
344// Opens the pane. On a terminal too narrow for it, closes it again and puts
345// the person's words back in the prompt box. Any other reason it waits
346// undrawn (no surface attached yet, one that places nothing) keeps it open,
347// since a surface that attaches later draws it; the reason goes in the
348// transcript so a pane that never shows says why. Resolves whether the flow
349// should go on.
350const openPane = async ($: D, restore: string): Promise<boolean> => {
351  let opened: Awaited<ReturnType<D['ui']['open']>>
352  try {
353    opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: 32 })
354  } catch (error) {
355    $.ui.log(`Prompt Upgrade could not open its pane: ${error instanceof Error ? error.message : String(error)}`)
356    throw error
357  }
358  if (opened.isPlaced) return true
359  $.ui.log(`Prompt Upgrade pane is waiting: ${opened.reason}`)
360  if (!/column/i.test(opened.reason)) return true
361  await closePane($)
362  if (restore.trim() !== '') await $.prompt.fill({ text: restore, mode: 'replace' })
363  $.ui.toast(NARROW)
364
365  return false
366}
367
368// ---------------------------------------------------------------- drawing
369
370type Kit = {
371  Box: Elements['terminal']['Box']
372  Text: Elements['terminal']['Text']
373  Button: Elements['terminal']['Button']
374  Input: Elements['terminal']['Input'] | undefined
375}
376
377type Chip = { key: string; label: string; tone: 'gold' | 'slate' | 'quiet'; onPress: () => void }
378
379const chipWidth = (chip: Chip) => (chip.tone === 'quiet' ? chip.label.length : chip.label.length + 2)
380
381const drawChip = (kit: Kit, chip: Chip) => {
382  const { Button, Text } = kit
383  if (chip.tone === 'quiet') {
384    return (
385      <Button key={chip.key} plain onPress={chip.onPress}>
386        <Text color={MUTED}>{chip.label}</Text>
387      </Button>
388    )
389  }
390
391  return (
392    <Button key={chip.key} plain onPress={chip.onPress}>
393      <Text
394        backgroundColor={chip.tone === 'gold' ? GOLD : LINE}
395        color={chip.tone === 'gold' ? INK : TEXT}
396        bold={chip.tone === 'gold'}
397      >
398        {` ${chip.label} `}
399      </Text>
400    </Button>
401  )
402}
403
404const drawChips = (kit: Kit, chips: Chip[], width: number) => {
405  const { Box } = kit
406  const fitted = chips.map(chip => ({ ...chip, label: clip(chip.label, Math.max(1, width - 2)) }))
407
408  return (
409    <Box flexDirection="column" width={width}>
410      {chipRows(fitted, chipWidth, width).map(row => (
411        <Box flexDirection="row" gap={1}>
412          {row.map(chip => drawChip(kit, chip))}
413        </Box>
414      ))}
415    </Box>
416  )
417}
418
419const drawHeader = (kit: Kit, width: number, isAuto: boolean) => {
420  const { Box, Text } = kit
421  const gold = Math.min(HEADING.length, width)
422  const badge = isAuto ? 'AUTO ON' : 'AUTO OFF'
423  const hasBadge = width >= HEADING.length + badge.length + 2
424
425  return (
426    <Box flexDirection="column" width={width}>
427      <Box flexDirection="row" justifyContent="space-between" width={width}>
428        <Text bold color={TEXT} wrap="truncate">
429          {clip(HEADING, width)}
430        </Text>
431        {hasBadge && <Text color={isAuto ? GREEN : MUTED}>{badge}</Text>}
432      </Box>
433      <Box flexDirection="row" width={width}>
434        <Text color={GOLD}>{'━'.repeat(gold)}</Text>
435        <Text color={LINE}>{'─'.repeat(Math.max(0, width - gold))}</Text>
436      </Box>
437    </Box>
438  )
439}
440
441const drawSection = (kit: Kit, width: number, label: string, color = MUTED) => {
442  const { Box, Text } = kit
443  const shown = clip(label.toUpperCase(), width)
444
445  return (
446    <Box flexDirection="row" width={width} marginTop={1}>
447      <Text color={color}>{shown}</Text>
448      <Text color={LINE}>{shown.length < width ? ` ${'─'.repeat(width - shown.length - 1)}` : ''}</Text>
449    </Box>
450  )
451}
452
453const drawTracker = (kit: Kit, width: number, current: number) => {
454  const { Box, Text } = kit
455  const parts = trackerParts(current, width)
456  const colorOf = (state: 'done' | 'now' | 'todo') => (state === 'done' ? GREEN : state === 'now' ? GOLD : MUTED)
457
458  return (
459    <Box flexDirection="row" width={width} marginTop={1}>
460      {parts.map((part, i) => (
461        <Box flexDirection="row">
462          {i > 0 && <Text color={LINE}>{' ── '}</Text>}
463          <Text color={colorOf(part.state)} bold={part.state === 'now'}>
464            {part.label === '' ? part.mark : `${part.mark} ${part.label}`}
465          </Text>
466        </Box>
467      ))}
468    </Box>
469  )
470}
471
472const drawCard = (
473  kit: Kit,
474  width: number,
475  text: string,
476  options: { heading?: string; headingColor?: string; border?: string; maxLines?: number; color?: string } = {},
477) => {
478  const { Box, Text } = kit
479  const inner = Math.max(1, width - 4)
480  let lines = wrap(text, inner)
481  if (options.maxLines !== undefined && lines.length > options.maxLines) {
482    lines = [...lines.slice(0, options.maxLines - 1), clip(`${lines[options.maxLines - 1] ?? ''} …`, inner)]
483  }
484
485  return (
486    <Box borderStyle="round" borderColor={options.border ?? LINE} paddingX={1} width={width} flexDirection="column">
487      {options.heading !== undefined && (
488        <Text color={options.headingColor ?? MUTED} bold wrap="truncate">
489          {clip(options.heading, inner)}
490        </Text>
491      )}
492      {lines.map(line => (
493        <Text color={options.color ?? TEXT} wrap="truncate">
494          {line === '' ? ' ' : line}
495        </Text>
496      ))}
497    </Box>
498  )
499}
500
501const drawNumbers = (kit: Kit, width: number, flow: Flow) => {
502  const { Box, Text } = kit
503  const before = wordCount(flow.original)
504  const after = wordCount(flow.upgraded)
505  const added = flow.added.length
506  const tiles = [
507    { value: before, label: 'WORDS BEFORE', color: TEXT },
508    { value: after, label: 'WORDS AFTER', color: GOLD },
509    { value: added, label: 'DETAILS ADDED', color: GREEN },
510  ].map(tile => {
511    const glyph = bigNumber(tile.value)
512    const inner = Math.max(glyph[0].length, tile.label.length)
513
514    return { ...tile, glyph, inner, outer: inner + 4 }
515  })
516  const total = tiles.reduce((n, tile) => n + tile.outer, 0) + (tiles.length - 1)
517
518  if (total > width) {
519    return (
520      <Box flexDirection="row" width={width} marginTop={1}>
521        <Text color={TEXT} wrap="truncate">
522          {clip(`${before} words before  ${after} after  ${added} details added`, width)}
523        </Text>
524      </Box>
525    )
526  }
527
528  return (
529    <Box flexDirection="row" gap={1} marginTop={1} width={width}>
530      {tiles.map(tile => (
531        <Box borderStyle="round" borderColor={LINE} paddingX={1} flexDirection="column" width={tile.outer}>
532          {tile.glyph.map(row => (
533            <Text color={tile.color} bold>
534              {row}
535            </Text>
536          ))}
537          <Text color={MUTED}>{tile.label}</Text>
538        </Box>
539      ))}
540    </Box>
541  )
542}
543
544const stepIndex = (flow: Flow): number => {
545  if (flow.step === 'compose' || flow.step === 'reading') return 0
546  if (flow.step === 'questions') return 1
547  if (flow.step === 'rewrite') return 2
548  if (flow.step === 'review') return 3
549
550  return flow.error?.at === 'rewrite' ? 2 : 1
551}
552
553type Ctx = { kit: Kit; width: number; surface: RenderSurface }
554
555const cancelChip = ($: D): Chip => ({ key: 'cancel', label: 'Cancel', tone: 'quiet', onPress: act(() => cancel($)) })
556
557const drawCompose = ($: D, { kit, width }: Ctx) => {
558  const { Box, Text, Input } = kit
559
560  return (
561    <Box flexDirection="column" width={width}>
562      {drawSection(kit, width, 'Your prompt')}
563      {Input !== undefined ? (
564        <Input
565          key="compose"
566          label="Your rough prompt"
567          placeholder="Type it, then press Enter"
568          autoFocus
569          onSubmit={value => {
570            if (value.trim() === '') return
571            act(async () => {
572              const started = await startFlow($, value.trim())
573              await askQuestions($, started.id, started.runId)
574            })()
575          }}
576        />
577      ) : (
578        <Text color={MUTED} wrap="truncate">
579          {clip('Type /better followed by your prompt.', width)}
580        </Text>
581      )}
582      <Box marginTop={1}>{drawChips(kit, [cancelChip($)], width)}</Box>
583    </Box>
584  )
585}
586
587const drawReading = ($: D, { kit, width }: Ctx, flow: Flow) => {
588  const { Box, Text } = kit
589
590  return (
591    <Box flexDirection="column" width={width}>
592      {drawSection(kit, width, 'Your prompt')}
593      {drawCard(kit, width, flow.original)}
594      {wrap('Reading your prompt and choosing the questions that would help most.', width).map(line => (
595        <Text color={MUTED}>{line}</Text>
596      ))}
597      <Box marginTop={1}>{drawChips(kit, [cancelChip($)], width)}</Box>
598    </Box>
599  )
600}
601
602const drawQuestions = ($: D, { kit, width }: Ctx, flow: Flow) => {
603  const { Box, Text, Input } = kit
604  const done = flow.answers.filter(one => one !== null).length
605  const total = flow.questions.length
606  const indent = 4
607  const room = Math.max(8, width - indent)
608
609  return (
610    <Box flexDirection="column" width={width}>
611      {drawSection(kit, width, 'Your prompt')}
612      {drawCard(kit, width, flow.original, { maxLines: 3 })}
613      <Box flexDirection="column" marginTop={1} width={width}>
614        <Text color={done === total ? GREEN : MUTED} wrap="truncate">
615          {clip(`${done} of ${total} answered`, width)}
616        </Text>
617        {drawChips(
618          kit,
619          [
620            { key: 'skip-rest', label: 'Skip the rest, upgrade now', tone: 'gold', onPress: act(() => startRewrite($)) },
621            cancelChip($),
622          ],
623          width,
624        )}
625      </Box>
626      {drawSection(kit, width, 'Questions')}
627      {flow.questions.map((question, i) => {
628        const given = flow.answers[i] ?? null
629        const lines = wrap(question.question, room)
630        const options: Chip[] = question.options.map((option, j) => ({
631          key: `q${i}-o${j}`,
632          label: option,
633          tone: given?.kind === 'option' && given.text === option ? 'gold' : 'slate',
634          onPress: act(() => answer($, flow.id, i, { kind: 'option', text: option })),
635        }))
636        const other: Chip = {
637          key: `q${i}-other`,
638          label: given?.kind === 'other' ? `Other: ${given.text}` : 'Other (type it)',
639          tone: given?.kind === 'other' ? 'gold' : 'slate',
640          onPress: act(() => patch($, { otherOpen: i })),
641        }
642        const skip: Chip = {
643          key: `q${i}-skip`,
644          label: given?.kind === 'skip' ? 'Skipped' : 'Skip',
645          tone: 'quiet',
646          onPress: act(() => answer($, flow.id, i, { kind: 'skip' })),
647        }
648
649        return (
650          <Box flexDirection="column" width={width} marginTop={1}>
651            <Box flexDirection="row" width={width}>
652              <Text backgroundColor={given === null ? GOLD : GREEN} color={INK} bold>
653                {given === null ? ` ${i + 1} ` : ' ✓ '}
654              </Text>
655              <Text color={TEXT}> {lines[0] ?? ''}</Text>
656            </Box>
657            {lines.slice(1).map(line => (
658              <Text color={TEXT}>{`${' '.repeat(indent)}${line}`}</Text>
659            ))}
660            <Box paddingLeft={indent} width={width} marginTop={1}>
661              {drawChips(kit, [...options, other, skip], room)}
662            </Box>
663            {flow.otherOpen === i && Input !== undefined && (
664              <Box paddingLeft={indent} width={width}>
665                <Input
666                  key={`other-${i}`}
667                  label="Your answer"
668                  placeholder="Type it, then press Enter"
669                  autoFocus
670                  onSubmit={value => {
671                    if (value.trim() === '') return
672                    act(() => answer($, flow.id, i, { kind: 'other', text: value.trim().slice(0, 200) }))()
673                  }}
674                />
675              </Box>
676            )}
677          </Box>
678        )
679      })}
680    </Box>
681  )
682}
683
684const drawRewrite = ($: D, { kit, width }: Ctx, flow: Flow) => {
685  const { Box, Text } = kit
686  const pairs = flow.questions.flatMap((question, i) => {
687    const text = answerText(flow.answers[i])
688
689    return text === null ? [] : [`${question.question} ${text}`]
690  })
691
692  return (
693    <Box flexDirection="column" width={width}>
694      {drawSection(kit, width, 'Your prompt')}
695      {drawCard(kit, width, flow.original, { maxLines: 3 })}
696      {pairs.length > 0 && drawSection(kit, width, 'Your answers')}
697      {pairs.flatMap(pair => wrap(pair, width - 2).map((line, j) => <Text color={TEXT}>{`${j === 0 ? '· ' : '  '}${line}`}</Text>))}
698      <Box marginTop={1} flexDirection="column" width={width}>
699        {wrap('Writing the upgrade from your prompt and answers. Your prompt is safe.', width).map(line => (
700          <Text color={MUTED}>{line}</Text>
701        ))}
702      </Box>
703      <Box marginTop={1}>{drawChips(kit, [cancelChip($)], width)}</Box>
704    </Box>
705  )
706}
707
708const drawError = ($: D, { kit, width }: Ctx, flow: Flow) => {
709  const { Box, Text } = kit
710  const inner = Math.max(1, width - 4)
711
712  return (
713    <Box flexDirection="column" width={width}>
714      <Box marginTop={1} width={width}>
715        <Box borderStyle="round" borderColor={AMBER} paddingX={1} width={width} flexDirection="column">
716          <Text color={AMBER} bold wrap="truncate">
717            {clip('THAT DID NOT WORK', inner)}
718          </Text>
719          {wrap(flow.error?.message ?? 'The model call failed.', inner).map(line => (
720            <Text color={TEXT}>{line}</Text>
721          ))}
722          {wrap('Your prompt is safe. Nothing was sent.', inner).map(line => (
723            <Text color={GREEN}>{line}</Text>
724          ))}
725        </Box>
726      </Box>
727      <Box marginTop={1}>
728        {drawChips(
729          kit,
730          [
731            { key: 'retry', label: 'Try again', tone: 'gold', onPress: act(() => retry($)) },
732            { key: 'send-original', label: 'Send my original', tone: 'slate', onPress: act(() => runIt($, flow.original)) },
733            cancelChip($),
734          ],
735          width,
736        )}
737      </Box>
738    </Box>
739  )
740}
741
742const drawReview = ($: D, { kit, width, surface }: Ctx, flow: Flow) => {
743  const { Box, Text, Input } = kit
744  const isSplit = width >= 64
745  const left = isSplit ? Math.floor((width - 1) / 2) : width
746  const right = isSplit ? width - 1 - left : width
747
748  const buttons: Chip[] = flow.isEditing
749    ? [
750        { key: 'back', label: 'Back', tone: 'slate', onPress: act(() => patch($, { isEditing: false })) },
751        cancelChip($),
752      ]
753    : [
754        { key: 'run', label: 'Run it', tone: 'gold', onPress: act(() => runIt($, flow.upgraded)) },
755        ...(Input !== undefined
756          ? [{ key: 'edit', label: 'Edit', tone: 'slate' as const, onPress: act(() => patch($, { isEditing: true })) }]
757          : []),
758        { key: 'copy', label: 'Copy', tone: 'slate', onPress: act(() => copy($, surface)) },
759        cancelChip($),
760      ]
761
762  const note =
763    flow.copyNote === 'copied'
764      ? { text: 'Copied to your clipboard.', color: GREEN }
765      : flow.copyNote === 'failed'
766        ? { text: 'Could not reach the clipboard here. Select the text instead.', color: AMBER }
767        : flow.isEditing
768          ? { text: 'Change the text below. Enter runs it.', color: MUTED }
769          : null
770
771  const mine = drawCard(kit, left, flow.original, { heading: 'YOURS' })
772  const theirs =
773    flow.isEditing && Input !== undefined ? (
774      <Box flexDirection="column" width={right}>
775        <Input
776          key="edit-text"
777          label="Upgraded prompt"
778          value={flow.upgraded}
779          submitLabel="Run it"
780          autoFocus
781          onSubmit={value => act(() => runIt($, value))()}
782        />
783      </Box>
784    ) : (
785      drawCard(kit, right, flow.upgraded, { heading: 'UPGRADE', headingColor: GOLD, border: GOLD })
786    )
787
788  return (
789    <Box flexDirection="column" width={width}>
790      {drawNumbers(kit, width, flow)}
791      <Box marginTop={1}>{drawChips(kit, buttons, width)}</Box>
792      {note !== null && (
793        <Text color={note.color} wrap="truncate">
794          {clip(note.text, width)}
795        </Text>
796      )}
797      {flow.added.length > 0 && drawSection(kit, width, 'What I added')}
798      {flow.added.flatMap(item =>
799        wrap(item, width - 2).map((line, j) => (
800          <Box flexDirection="row" width={width}>
801            <Text color={GREEN}>{j === 0 ? '+ ' : '  '}</Text>
802            <Text color={TEXT}>{line}</Text>
803          </Box>
804        )),
805      )}
806      {isSplit ? (
807        <Box flexDirection="row" gap={1} marginTop={1} width={width}>
808          {mine}
809          {theirs}
810        </Box>
811      ) : (
812        <Box flexDirection="column" marginTop={1} width={width}>
813          {mine}
814          {theirs}
815        </Box>
816      )}
817    </Box>
818  )
819}
820
821const OUTCOME_LABEL: Record<Outcome, { text: string; color: string }> = {
822  ready: { text: 'NOT SENT', color: MUTED },
823  cancelled: { text: 'CANCELLED', color: MUTED },
824  copied: { text: 'COPIED', color: TEXT },
825  ran: { text: 'RAN', color: GREEN },
826}
827
828const stamp = (at: number) => {
829  const date = new Date(at)
830  const two = (n: number) => String(n).padStart(2, '0')
831
832  return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`
833}
834
835const drawHistory = ($: D, { kit, width }: Ctx, list: HistoryEntry[]) => {
836  const { Box, Button, Text } = kit
837  const room = Math.max(4, width - 11 - 1 - 1 - 9)
838
839  return (
840    <Box flexDirection="column" width={width}>
841      {drawSection(kit, width, list.length === 1 ? 'Last upgrade' : `Last ${list.length} upgrades`)}
842      {list.length === 0 && (
843        <Text color={MUTED} wrap="truncate">
844          {clip('No upgrades yet. Try /better followed by a rough prompt.', width)}
845        </Text>
846      )}
847      {list.map(entry => {
848        const label = OUTCOME_LABEL[entry.outcome]
849        const first = entry.original.replace(/\s+/g, ' ').trim()
850
851        return (
852          <Button key={`hist-${entry.id}`} plain onPress={act(() => reopen($, entry))}>
853            <Text color={MUTED}>{`${stamp(entry.at)} `}</Text>
854            <Text color={TEXT}>{`${clip(first, room).padEnd(room)} `}</Text>
855            <Text color={label.color}>{label.text.padStart(9)}</Text>
856          </Button>
857        )
858      })}
859      <Box marginTop={1}>
860        {drawChips(
861          kit,
862          [{ key: 'close', label: 'Close', tone: 'quiet', onPress: act(() => $.ui.close({ id: PANE })) }],
863          width,
864        )}
865      </Box>
866    </Box>
867  )
868}
869
870// ---------------------------------------------------------------- hooks
871
872export const register: Register = on => {
873  on('session.start', async ($, e, next) => {
874    try {
875      await $.command.register({
876        name: 'better',
877        description: 'Upgrade a rough prompt before it runs. Also: on, off, history',
878        argumentHint: '[prompt | on | off | history]',
879      })
880    } catch {
881      // registration refused; the rest still works
882    }
883    try {
884      const auto = await $.store.get('auto')
885      if (typeof auto === 'boolean') await $.state.set(AUTO, auto)
886      const kept = await $.store.get('history')
887      if (Array.isArray(kept)) await $.state.set(HISTORY, (kept as HistoryEntry[]).slice(0, 20))
888      void resume($).catch(() => undefined)
889    } catch {
890      // nothing kept yet
891    }
892
893    return next(e)
894  })
895
896  on('command.run', { command: 'better' }, async ($, e) => {
897    try {
898      const args = e.args.trim()
899      const word = args.toLowerCase()
900
901      if (word === 'on' || word === 'off') {
902        await setAuto($, word === 'on')
903
904        return {
905          text:
906            word === 'on'
907              ? 'Automatic mode is on. What you type in the prompt box goes to the pane first. Start a line with > to send it straight through.'
908              : 'Automatic mode is off. Prompts go straight to Claude.',
909        }
910      }
911
912      if (word === 'history') {
913        const list = await getHistory($)
914        await setView($, 'history')
915        void openPane($, '').catch(() => undefined)
916
917        return {
918          text:
919            list.length === 0
920              ? 'No upgrades yet. The pane is open.'
921              : list.length === 1
922                ? 'Your last upgrade is in the pane.'
923                : `Your last ${list.length} upgrades are in the pane.`,
924        }
925      }
926
927      if (args === '') {
928        await setView($, 'flow')
929        await changeFlow($, () => blank('compose', ''))
930        void openPane($, '').catch(() => undefined)
931
932        return { text: 'Type your rough prompt in the pane.' }
933      }
934
935      const flow = await startFlow($, args)
936      void openPane($, args)
937        .then(goOn => (goOn ? askQuestions($, flow.id, flow.runId) : undefined))
938        .catch(() => undefined)
939
940      return { text: 'Upgrading your prompt in the pane. Nothing runs until you press Run.' }
941    } catch {
942      return { text: FAILED }
943    }
944  }).catch(() => ({ text: FAILED }))
945
946  on('prompt.submit', async ($, e, next) => {
947    const isAuto = await getAuto($)
948    if (!isAuto) return next(e)
949
950    // A flow counts as open only while its pane is: one the person closed by
951    // hand is dropped here so it never holds automatic mode up.
952    const open = await getFlow($)
953    const isUp = open !== null && (await $.ui.panes()).some(pane => pane.id === PANE)
954    if (open !== null && !isUp) await changeFlow($, () => null)
955    const decision = classify(
956      {
957        text: e.text,
958        originKind: e.origin.kind,
959        hasAttachments: (e.attachments?.length ?? 0) > 0,
960        isMidTurn: e.turnId !== undefined,
961      },
962      isAuto,
963      isUp,
964    )
965    if (decision.kind === 'pass') return next(e)
966    if (decision.kind === 'strip') return next({ ...e, text: decision.text })
967
968    const flow = await startFlow($, e.text)
969    const isPlaced = await openPane($, '')
970    if (!isPlaced) {
971      // The box may still be clearing from the submit; fill it just after.
972      $.clock.after(150, () => {
973        void $.prompt.fill({ text: e.text, mode: 'replace' }).catch(() => undefined)
974      })
975
976      return { drop: NARROW }
977    }
978    void askQuestions($, flow.id, flow.runId).catch(() => undefined)
979
980    return { drop: TAKEN }
981  }).catch(($, e, next) => next(e))
982
983  // The person closing the pane by hand counts as Cancel: their words go back.
984  on('ui.close', async ($, e, next) => {
985    if (e.id === PANE && e.origin.kind === 'person') {
986      const flow = await getFlow($)
987      if (flow !== null) {
988        await changeFlow($, () => null)
989        if (flow.original.trim() !== '') {
990          await markOutcome($, flow.id, 'cancelled')
991          await $.prompt.fill({ text: flow.original, mode: 'replace' })
992        }
993      }
994    }
995
996    return next(e)
997  }).catch(($, e, next) => next(e))
998
999  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1000    const table = $.ui.resolve(e)
1001    const kit: Kit = {
1002      Box: table.Box,
1003      Text: table.Text,
1004      Button: table.Button,
1005      Input: 'Input' in table ? table.Input : undefined,
1006    }
1007    const width = Math.max(20, e.props.bodyColumns)
1008    const ctx: Ctx = { kit, width, surface: e.surface }
1009    const { Box, Text } = kit
1010
1011    const view = await getView($)
1012    const isAuto = await getAuto($)
1013    const flow = await getFlow($)
1014
1015    if (view === 'history') {
1016      const list = await getHistory($)
1017
1018      return (
1019        <Box flexDirection="column" width={width}>
1020          {drawHeader(kit, width, isAuto)}
1021          {drawHistory($, ctx, list)}
1022        </Box>
1023      )
1024    }
1025
1026    if (flow === null) {
1027      return (
1028        <Box flexDirection="column" width={width}>
1029          {drawHeader(kit, width, isAuto)}
1030          <Text color={MUTED} wrap="truncate">
1031            {clip('Nothing to upgrade. Try /better followed by a rough prompt.', width)}
1032          </Text>
1033        </Box>
1034      )
1035    }
1036
1037    const body =
1038      flow.step === 'compose'
1039        ? drawCompose($, ctx)
1040        : flow.step === 'reading'
1041          ? drawReading($, ctx, flow)
1042          : flow.step === 'questions'
1043            ? drawQuestions($, ctx, flow)
1044            : flow.step === 'rewrite'
1045              ? drawRewrite($, ctx, flow)
1046              : flow.step === 'review'
1047                ? drawReview($, ctx, flow)
1048                : drawError($, ctx, flow)
1049
1050    return (
1051      <Box flexDirection="column" width={width}>
1052        {drawHeader(kit, width, isAuto)}
1053        {drawTracker(kit, width, stepIndex(flow))}
1054        {body}
1055      </Box>
1056    )
1057  })
1058}
1059
hooks/logic.ts 279 lines
1// Pure helpers: no `$`, no state. Shared by the hooks module and the tests.
2
3import type { Answer, Question } from '../types'
4
5export const QUESTION_MODEL = 'haiku'
6export const REWRITE_MODEL = 'sonnet'
7
8// What the prompt box hands us, reduced to what the intercept rules read.
9export type Submitted = {
10  text: string
11  originKind: string
12  hasAttachments: boolean
13  isMidTurn: boolean
14}
15
16export type Decision =
17  | { kind: 'pass' }
18  | { kind: 'strip'; text: string }
19  | { kind: 'intercept' }
20
21const SHORT_REPLIES = new Set([
22  'y', 'n', 'yes', 'no', 'yep', 'yeah', 'nope', 'ok', 'okay', 'k', 'sure',
23  'thanks', 'thank you', 'ty', 'cool', 'great', 'nice', 'perfect', 'done',
24  'continue', 'keep going', 'go on', 'go ahead', 'do it', 'proceed', 'next',
25  'stop', 'wait', 'carry on', 'sounds good', 'looks good', 'lgtm', 'yes please',
26  'no thanks', 'ship it', 'try again', 'again', 'retry', 'undo', 'revert',
27])
28
29export const wordCount = (text: string): number =>
30  text.trim().split(/\s+/).filter(Boolean).length
31
32export const isShortReply = (text: string): boolean => {
33  const plain = text.trim().toLowerCase().replace(/[.!?,;:]+$/g, '').trim()
34
35  return SHORT_REPLIES.has(plain) || wordCount(plain) <= 2
36}
37
38// Which prompts the automatic mode sends to the pane, and which go straight
39// through. `hasOpenFlow` is true while an upgrade is already in the pane.
40export const classify = (
41  sent: Submitted,
42  isAuto: boolean,
43  hasOpenFlow: boolean,
44): Decision => {
45  if (!isAuto || sent.originKind !== 'composer') return { kind: 'pass' }
46
47  const text = sent.text.trim()
48
49  if (text.startsWith('>')) return { kind: 'strip', text: text.slice(1).trimStart() }
50  if (text.startsWith('/')) return { kind: 'pass' }
51  if (sent.hasAttachments || sent.isMidTurn) return { kind: 'pass' }
52  if (text === '' || isShortReply(text)) return { kind: 'pass' }
53  if (hasOpenFlow) return { kind: 'pass' }
54
55  return { kind: 'intercept' }
56}
57
58// The first JSON object in a model reply, fences and stray prose allowed.
59export const extractJson = (text: string): unknown => {
60  const start = text.indexOf('{')
61  const end = text.lastIndexOf('}')
62  if (start === -1 || end <= start) return undefined
63  try {
64    return JSON.parse(text.slice(start, end + 1))
65  } catch {
66    return undefined
67  }
68}
69
70const asText = (value: unknown, max: number): string =>
71  typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : ''
72
73// Up to three questions of two to four options each; [] when none would help,
74// null when the reply could not be read at all.
75export const parseQuestions = (text: string): Question[] | null => {
76  const data = extractJson(text)
77  if (typeof data !== 'object' || data === null) return null
78  const list = (data as { questions?: unknown }).questions
79  if (!Array.isArray(list)) return null
80
81  const questions: Question[] = []
82  for (const item of list) {
83    if (typeof item !== 'object' || item === null) continue
84    const raw = item as { question?: unknown; q?: unknown; options?: unknown }
85    const question = asText(raw.question ?? raw.q, 160)
86    const options = Array.isArray(raw.options)
87      ? [...new Set(raw.options.map(one => asText(one, 40)).filter(Boolean))].slice(0, 4)
88      : []
89    if (question !== '' && options.length >= 2) questions.push({ question, options })
90    if (questions.length === 3) break
91  }
92
93  return questions
94}
95
96export type Rewrite = { prompt: string; added: string[] }
97
98export const parseRewrite = (text: string): Rewrite | null => {
99  const data = extractJson(text)
100  if (typeof data === 'object' && data !== null) {
101    const raw = data as { prompt?: unknown; added?: unknown }
102    const prompt = typeof raw.prompt === 'string' ? raw.prompt.trim() : ''
103    if (prompt !== '') {
104      const added = Array.isArray(raw.added)
105        ? raw.added.map(one => asText(one, 120)).filter(Boolean).slice(0, 6)
106        : []
107
108      return { prompt, added }
109    }
110
111    return null
112  }
113
114  const plain = text.trim()
115
116  return plain === '' ? null : { prompt: plain, added: [] }
117}
118
119export const QUESTION_SYSTEM = [
120  'You help a person improve a rough prompt before they send it to Claude, an AI assistant that can also work in their files and tools.',
121  'Ask at most three multiple-choice questions: the ones whose answers would most improve the result.',
122  'Each question is under 12 words. Each has two to four options of one to five words.',
123  'Do not ask for things only the person can type, such as names, numbers or prices.',
124  'If no question would clearly help, ask none.',
125  'Reply with JSON only, no other text, in this shape: {"questions":[{"question":"...","options":["...","..."]}]}',
126  'With no questions, reply {"questions":[]}',
127].join('\n')
128
129export const questionPrompt = (original: string): string =>
130  `The rough prompt:\n<prompt>\n${original}\n</prompt>`
131
132export const REWRITE_SYSTEM = [
133  "You rewrite a person's rough prompt into a clear one before they send it to Claude.",
134  'Rules:',
135  '- Keep exactly what they meant. Do not change the task.',
136  '- Add a clear goal, who the result is for, the format, and what done looks like.',
137  '- Use their answers. Leave out anything they skipped.',
138  '- Never invent facts, names, numbers, prices, dates or file names. Where something needed is unknown, leave a placeholder in square brackets, like [audience] or [deadline].',
139  '- Write it as one request they could have typed themselves, in plain language, in the first person.',
140  'Reply with JSON only, no other text, in this shape: {"prompt":"the upgraded prompt","added":["a short note for each thing you added"]}',
141  'Keep each note under 10 words. At most six notes.',
142].join('\n')
143
144export const answerText = (answer: Answer | null | undefined): string | null =>
145  answer === null || answer === undefined || answer.kind === 'skip' ? null : answer.text
146
147export const rewritePrompt = (
148  original: string,
149  questions: readonly Question[],
150  answers: readonly (Answer | null)[],
151): string => {
152  const lines = questions.flatMap((one, i) => {
153    const text = answerText(answers[i])
154
155    return text === null ? [] : [`- ${one.question} ${text}`]
156  })
157
158  return [
159    `The rough prompt:\n<prompt>\n${original}\n</prompt>`,
160    lines.length === 0 ? 'They answered no questions.' : `Their answers:\n${lines.join('\n')}`,
161  ].join('\n\n')
162}
163
164export type Failure =
165  | { reason: 'api-error'; status: number | null; error: string }
166  | { reason: 'empty-reply' }
167  | { reason: 'aborted' }
168  | { reason: string }
169
170export const describeFailure = (failure: Failure): string => {
171  if (failure.reason === 'aborted') return 'The model took too long to answer.'
172  if (failure.reason === 'empty-reply') return 'The model sent back an empty reply.'
173  if (failure.reason === 'api-error' && 'error' in failure) {
174    if (failure.error === 'rate_limit') return 'The model is rate limited right now.'
175    if (failure.error === 'overloaded') return 'The model is overloaded right now.'
176    if (failure.error === 'authentication_failed') return 'The model call was not signed in.'
177
178    return `The model call failed (${failure.status ?? 'no response'}).`
179  }
180
181  return 'The model call failed.'
182}
183
184// Word wrap to an exact width: every line it returns fits in `width` cells.
185export const wrap = (text: string, width: number): string[] => {
186  const room = Math.max(1, width)
187  const lines: string[] = []
188  for (const paragraph of text.replace(/\r/g, '').split('\n')) {
189    let line = ''
190    for (const word of paragraph.split(/\s+/).filter(Boolean)) {
191      let rest = word
192      while (rest.length > room) {
193        if (line !== '') {
194          lines.push(line)
195          line = ''
196        }
197        lines.push(rest.slice(0, room))
198        rest = rest.slice(room)
199      }
200      if (line === '') line = rest
201      else if (line.length + 1 + rest.length <= room) line = `${line} ${rest}`
202      else {
203        lines.push(line)
204        line = rest
205      }
206    }
207    lines.push(line)
208  }
209
210  return lines
211}
212
213export const clip = (text: string, width: number): string =>
214  text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
215
216// Digits three rows tall in heavy box-drawing strokes.
217const GLYPHS: Record<string, [string, string, string]> = {
218  '0': ['┏━┓', '┃ ┃', '┗━┛'],
219  '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
220  '2': ['╺━┓', '┏━┛', '┗━╸'],
221  '3': ['╺━┓', ' ━┫', '╺━┛'],
222  '4': ['╻ ╻', '┗━┫', '  ╹'],
223  '5': ['┏━╸', '┗━┓', '╺━┛'],
224  '6': ['┏━╸', '┣━┓', '┗━┛'],
225  '7': ['╺━┓', '  ┃', '  ╹'],
226  '8': ['┏━┓', '┣━┫', '┗━┛'],
227  '9': ['┏━┓', '┗━┫', '╺━┛'],
228}
229
230export const bigNumber = (value: number): [string, string, string] => {
231  const digits = String(Math.max(0, Math.round(value))).split('')
232  const rows: [string, string, string] = ['', '', '']
233  for (const row of [0, 1, 2] as const) {
234    rows[row] = digits.map(d => (GLYPHS[d] ?? GLYPHS['0']!)[row]).join(' ')
235  }
236
237  return rows
238}
239
240// Lays chips out in rows that each fit `width`, `gap` cells apart.
241export const chipRows = <T>(items: readonly T[], widthOf: (item: T) => number, width: number, gap = 1): T[][] => {
242  const rows: T[][] = []
243  let row: T[] = []
244  let used = 0
245  for (const item of items) {
246    const w = widthOf(item)
247    if (row.length > 0 && used + gap + w > width) {
248      rows.push(row)
249      row = []
250      used = 0
251    }
252    used += (row.length > 0 ? gap : 0) + w
253    row.push(item)
254  }
255  if (row.length > 0) rows.push(row)
256
257  return rows
258}
259
260export const STEPS = ['Your prompt', 'Questions', 'Rewrite', 'Review'] as const
261
262// The tracker line's parts at a width: full labels when they fit, else the
263// current label alone, else numbers.
264export const trackerParts = (current: number, width: number): { mark: string; label: string; state: 'done' | 'now' | 'todo' }[] => {
265  const state = (i: number) => (i < current ? 'done' : i === current ? 'now' : 'todo') as 'done' | 'now' | 'todo'
266  const mark = (i: number) => (i < current ? '✓' : i === current ? '●' : '○')
267  const build = (labelOf: (i: number) => string) =>
268    STEPS.map((_, i) => ({ mark: mark(i), label: labelOf(i), state: state(i) }))
269  const size = (parts: { mark: string; label: string }[]) =>
270    parts.reduce((n, p) => n + p.mark.length + (p.label ? 1 + p.label.length : 0), 0) + (parts.length - 1) * 4
271
272  const full = build(i => STEPS[i] ?? '')
273  if (size(full) <= width) return full
274  const focused = build(i => (i === current ? (STEPS[i] ?? '') : String(i + 1)))
275  if (size(focused) <= width) return focused
276
277  return build(i => String(i + 1))
278}
279
types/index.d.ts 55 lines
1export type Question = { question: string; options: string[] }
2
3export type Answer =
4  | { kind: 'option'; text: string }
5  | { kind: 'other'; text: string }
6  | { kind: 'skip' }
7
8export type Step =
9  | 'compose'
10  | 'reading'
11  | 'questions'
12  | 'rewrite'
13  | 'review'
14  | 'error'
15
16export type Flow = {
17  id: string
18  original: string
19  step: Step
20  questions: Question[]
21  answers: (Answer | null)[]
22  otherOpen: number | null
23  upgraded: string
24  added: string[]
25  pending: 'questions' | 'rewrite' | null
26  runId: number
27  error: { at: 'questions' | 'rewrite'; message: string } | null
28  isEditing: boolean
29  copyNote: 'copied' | 'failed' | null
30}
31
32export type Outcome = 'ready' | 'ran' | 'copied' | 'cancelled'
33
34export type HistoryEntry = {
35  id: string
36  at: number
37  original: string
38  upgraded: string
39  added: string[]
40  outcome: Outcome
41}
42
43export type View = 'flow' | 'history'
44
45declare module 'claude-code' {
46  interface PluginState {
47    'prompt-upgrade': {
48      flow: Flow | null
49      view: View
50      auto: boolean
51      history: HistoryEntry[]
52    }
53  }
54}
55