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

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.
| Mod | What 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-kit | Clean 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. |
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.
The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.
git pull in ~/claude-mods./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.
mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests..claude-plugin/marketplace.json with "source": "./mods/<mod-name>".claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.
hooks/register.tsx 1059 lines1import 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}
1059hooks/logic.ts 279 lines1// 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}
279types/index.d.ts 55 lines1export 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