A sidebar that reviews each prompt, tracks your prompting habits, and improves your draft before you send it

A Claude Code mod that helps you write better prompts. A sidebar keeps a trail of your prompts and Claude's answers. After each answer it reviews the prompt you sent, says the one change that would have helped most, and offers a better version you can use with one key.
It has two goals:

The screenshots use a made-up project, acme-shop.
At the prompt of a Claude Code terminal session:
/plugin install prompt-coach --marketplace OlivierAlter/prompt-coach
Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).
From a shell:
claude plugin install prompt-coach --marketplace OlivierAlter/prompt-coach
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, then Add marketplace with OlivierAlter/prompt-coach, then Add plugin.
→ next step, then a better prompt shown as a diff of yours, then the reasons. Short follow-ups like "yes" or "go on" are skipped, and you can still press Review it on one.
[placeholders] rather than guesses, and Undo puts your draft back.
y) or Not helpful (n), and the coach takes your recent ratings into account.History is kept per project; press p to show all projects. Habits are counted across all projects.
When the sidebar has focus (press ctrl+x tab to give it the keys):
| Key | Action |
|---|---|
u | Use the better prompt (fills the prompt box) |
c | Copy the better prompt |
i | Improve the draft in the prompt box |
h / r | Habits view / Review view |
j / k | Newer / older prompt |
v | Review it, or Retry a slow review |
y / n | Rate the review Helpful / Not helpful |
p | This project or all projects |
e | Expand a quoted prompt on a narrow sidebar |
The /prompt-coach command opens the sidebar. /prompt-coach habits opens the Habits view, and /prompt-coach clear clears the history.
| Setting | Default | |
|---|---|---|
model | sonnet | The model that reviews prompts and improves drafts: sonnet, haiku or opus |
autoReview | true | Review every answered prompt. Turn it off to review only when you press Review it |
Each review is one model call, about half a cent with Sonnet. Improve draft costs one call per press. The draft check as you type is free.
The coach sends your prompt, the end of Claude's answer, and a list of the tools Claude used to the model you chose, through your own Claude Code session. History is saved on your machine in Claude Code's plugin store and is sent nowhere else.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
The review logic is in hooks/coach.ts, the hooks and sidebar are in hooks/register.tsx, and the state contract is in types/index.d.ts. See ROADMAP.md for what has shipped and what is next.
hooks/register.tsx 947 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CoachEntry, HabitId } from '../types'
5import {
6 average,
7 buildImprovePrompt,
8 buildPrompt,
9 claudeMdPrompt,
10 cleanRewrite,
11 diffWords,
12 formatTokens,
13 habitName,
14 habitStats,
15 IMPROVE_SYSTEM,
16 isTrivial,
17 knownRules,
18 lintDraft,
19 oneLine,
20 parseReview,
21 placeholders,
22 relTime,
23 ruleSuggestions,
24 sessionLabel,
25 stepLine,
26 streak,
27 STREAK_TO_GRADUATE,
28 SYSTEM,
29 weakHabits,
30} from './coach'
31
32const PANE = 'prompt-coach'
33const TITLE = 'Prompt coach'
34const KEEP = 300
35const STORE_KEY = 'history'
36const DISMISSED_KEY = 'dismissedRules'
37const WELCOMED_KEY = 'welcomed'
38const GRADUATED_KEY = 'graduated'
39// A review running longer than this offers Retry.
40const SLOW_MS = 30_000
41// Below this many columns the sidebar stacks and shortens.
42const NARROW = 45
43
44const ENTRIES = atom({ plugin: 'prompt-coach', key: 'entries' } as const, [])
45const SELECTED = atom({ plugin: 'prompt-coach', key: 'selected' } as const, null)
46const VIEW = atom({ plugin: 'prompt-coach', key: 'view' } as const, 'review')
47const SCOPE = atom({ plugin: 'prompt-coach', key: 'scope' } as const, 'project')
48const DISMISSED = atom({ plugin: 'prompt-coach', key: 'dismissedRules' } as const, [])
49const DRAFT = atom({ plugin: 'prompt-coach', key: 'draft' } as const, null)
50const BOX = atom({ plugin: 'prompt-coach', key: 'box' } as const, { hasText: false, hints: [] })
51const PANE_OPEN = atom({ plugin: 'prompt-coach', key: 'paneOpen' } as const, false)
52const BAND_HIDDEN = atom({ plugin: 'prompt-coach', key: 'bandHidden' } as const, false)
53const EXPANDED = atom({ plugin: 'prompt-coach', key: 'expanded' } as const, false)
54const WELCOMED = atom({ plugin: 'prompt-coach', key: 'welcomed' } as const, false)
55const GRADUATED = atom({ plugin: 'prompt-coach', key: 'graduated' } as const, [])
56const TICK = atom({ plugin: 'prompt-coach', key: 'tick' } as const, 0)
57
58// The prompt of the turn now running, and what it ran. Module variables:
59// a reload mid-turn only loses that one turn's review.
60let pending: { prompt: string; tools: string[]; steps: string[] } | null = null
61let counter = 0
62// The session's directory and start, read at its start; entries carry both.
63let project = ''
64let sessionStart = 0
65// The latest attempt per entry: a retried review's older reply is dropped.
66const attempts = new Map<string, number>()
67// Redraws the elapsed time while a review runs.
68let ticker: Timer | null = null
69// What the box last held, so a keystroke that changes nothing writes nothing.
70let lastBoxKey = 'empty'
71
72// Set from the plugin's options each time register runs.
73let model = 'sonnet'
74let isAutoReview = true
75
76const scoreColor = (score: number) => (score >= 4 ? 'green' : 'yellow')
77const bar = (rate: number, width = 10) => {
78 const full = Math.round(rate * width)
79
80 return '█'.repeat(full) + '░'.repeat(width - full)
81}
82const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
83const projectName = (dir: string) => dir.split('/').filter(Boolean).pop() ?? dir
84const isCommandLine = (text: string) => text.startsWith('/') || text.startsWith('!')
85
86async function save($: EngineInterface, entries: CoachEntry[]) {
87 await $.store.set(
88 STORE_KEY,
89 entries.filter(e => e.status !== 'reviewing').slice(-KEEP),
90 )
91}
92
93async function openPane($: EngineInterface) {
94 const r = await $.ui.open({ id: PANE, title: TITLE })
95 await update($, PANE_OPEN, () => r.isPlaced)
96}
97
98async function tickOnce($: EngineInterface) {
99 const isReviewing = (await read($, ENTRIES)).some(e => e.status === 'reviewing')
100 if (!isReviewing) {
101 ticker?.cancel()
102 ticker = null
103
104 return
105 }
106 const now = await $.clock.now()
107 await update($, TICK, () => now)
108}
109
110function startTicker($: EngineInterface) {
111 if (!ticker) {
112 ticker = $.clock.every(1000, () =>
113 // A module the host unloaded can no longer write: stop asking.
114 void tickOnce($).catch(() => {
115 ticker?.cancel()
116 ticker = null
117 }),
118 )
119 }
120}
121
122async function review($: EngineInterface, id: string) {
123 const attempt = (attempts.get(id) ?? 0) + 1
124 attempts.set(id, attempt)
125 const startedAt = await $.clock.now()
126 const all = await update($, ENTRIES, list =>
127 list.map(e => (e.id === id ? { ...e, status: 'reviewing' as const, reviewStartedAt: startedAt } : e)),
128 )
129 await update($, TICK, () => startedAt)
130 startTicker($)
131 const at = all.findIndex(e => e.id === id)
132 const entry = all[at]
133 if (!entry) return
134 const earlier = all.slice(0, at)
135 const sameProject = earlier.filter(e => e.project === entry.project)
136 const graduated = await read($, GRADUATED)
137 const [focus] = weakHabits(earlier, 1, graduated)
138 let patch: Partial<CoachEntry>
139 let previousUnclear = ''
140 try {
141 const r = await $.model.complete({
142 model,
143 system: [{ text: SYSTEM, cache: true }],
144 prompt: buildPrompt(entry, sameProject, { focus, rules: knownRules(sameProject), ratings: all.filter(e => e.rating) }),
145 maxTokens: 1000,
146 effort: 'low',
147 timeoutMs: 90_000,
148 })
149 const parsed = r.isAnswered ? parseReview(r.text) : undefined
150 if (parsed) {
151 const { previousUnclear: note, ...rest } = parsed
152 previousUnclear = note
153 patch = { status: 'done', error: undefined, ...rest }
154 } else {
155 patch = {
156 status: 'failed',
157 error: r.isAnswered ? 'The coach replied in a shape it could not read.' : `No review: ${r.reason}`,
158 }
159 }
160 } catch (err) {
161 patch = { status: 'failed', error: errorText(err) }
162 }
163 // A retry started since: its reply is the one that counts.
164 if (attempts.get(id) !== attempt) return
165 const previous = sameProject[sameProject.length - 1]
166 const next = await update($, ENTRIES, list =>
167 list.map(e => {
168 if (e.id === id) return { ...e, ...patch, reviewStartedAt: undefined }
169 if (previousUnclear && previous && e.id === previous.id) return { ...e, followup: previousUnclear }
170
171 return e
172 }),
173 )
174 await save($, next)
175
176 // Kept the focus habit enough prompts in a row: it graduates.
177 if (focus && patch.habits?.[focus] === 'shown' && streak(next, focus) >= STREAK_TO_GRADUATE) {
178 const grads = await update($, GRADUATED, list => (list.includes(focus) ? list : [...list, focus]))
179 await $.store.set(GRADUATED_KEY, grads)
180 const [nextFocus] = weakHabits(next, 1, grads)
181 $.ui.toast(
182 `Coach: ${STREAK_TO_GRADUATE} in a row: "${habitName(focus)}" is a habit now.${nextFocus ? ` Next focus: ${habitName(nextFocus)}.` : ''}`,
183 )
184 }
185
186 // Say so once, when an instruction first repeats: it belongs in CLAUDE.md.
187 const given = new Set((patch.rules ?? []).map(r => r.toLowerCase()))
188 if (given.size > 0) {
189 const dismissed = await read($, DISMISSED)
190 const repeated = ruleSuggestions(next.filter(e => e.project === entry.project), dismissed).find(
191 s => s.count === 2 && given.has(s.text.toLowerCase()),
192 )
193 if (repeated) $.ui.toast(`Coach: you've asked "${repeated.text}" twice. Habits view can add it to CLAUDE.md.`)
194 }
195}
196
197async function rate($: EngineInterface, id: string, rating: 'up' | 'down') {
198 const next = await update($, ENTRIES, list =>
199 list.map(e => (e.id === id ? { ...e, rating: e.rating === rating ? undefined : rating } : e)),
200 )
201 await save($, next)
202}
203
204async function dismissRule($: EngineInterface, text: string) {
205 const next = await update($, DISMISSED, list => [...list, text.toLowerCase()])
206 await $.store.set(DISMISSED_KEY, next)
207}
208
209async function dismissWelcome($: EngineInterface) {
210 await update($, WELCOMED, () => true)
211 await $.store.set(WELCOMED_KEY, true)
212}
213
214// Puts a review's better prompt in the box; a draft it replaces can come back.
215async function useBetter($: EngineInterface, text: string) {
216 const box = await $.prompt.read()
217 const filled = await $.prompt.fill({ text })
218 const kept = box.text.trim()
219 if (filled.isFilled && kept !== '' && kept !== text.trim()) {
220 await update($, DRAFT, () => ({ phase: 'improved' as const, original: box.text, source: 'use' as const }))
221 }
222}
223
224async function improveDraft($: EngineInterface) {
225 if ((await read($, DRAFT))?.phase === 'improving') return
226 const box = await $.prompt.read()
227 const draft = box.text.trim()
228 if (draft === '' || isCommandLine(draft)) {
229 await update($, DRAFT, () => ({ phase: 'failed' as const, error: 'Type a prompt in the box first, then press Improve draft.' }))
230
231 return
232 }
233 await update($, DRAFT, () => ({ phase: 'improving' as const }))
234 const entries = await read($, ENTRIES)
235 const here = entries.filter(e => e.project === project)
236 const graduated = await read($, GRADUATED)
237 try {
238 const r = await $.model.complete({
239 model,
240 system: [{ text: IMPROVE_SYSTEM, cache: true }],
241 prompt: buildImprovePrompt(draft, here, weakHabits(entries, 2, graduated)),
242 maxTokens: 1500,
243 effort: 'low',
244 timeoutMs: 90_000,
245 })
246 const improved = r.isAnswered ? cleanRewrite(r.text) : ''
247 if (!r.isAnswered || improved === '') {
248 await update($, DRAFT, () => ({
249 phase: 'failed' as const,
250 error: r.isAnswered ? 'The coach returned an empty draft.' : `Could not improve the draft: ${r.reason}`,
251 }))
252
253 return
254 }
255 // Never overwrite what the person typed while the coach worked.
256 if ((await $.prompt.read()).text.trim() !== draft) {
257 await update($, DRAFT, () => ({ phase: 'failed' as const, error: 'Your draft changed meanwhile; press Improve draft again.' }))
258
259 return
260 }
261 const filled = await $.prompt.fill({ text: improved })
262 await update($, DRAFT, () =>
263 filled.isFilled
264 ? { phase: 'improved' as const, original: box.text, source: 'improve' as const }
265 : { phase: 'failed' as const, error: 'The prompt box could not take the draft.' },
266 )
267 } catch (err) {
268 await update($, DRAFT, () => ({ phase: 'failed' as const, error: errorText(err) }))
269 }
270}
271
272async function undoDraft($: EngineInterface) {
273 const draft = await read($, DRAFT)
274 if (draft?.phase === 'improved') await $.prompt.fill({ text: draft.original })
275 await update($, DRAFT, () => null)
276}
277
278async function noteBox($: EngineInterface, text: string) {
279 const t = text.trim()
280 const hasText = t !== '' && !isCommandLine(t)
281 const hints = hasText ? lintDraft(t) : []
282 const key = hasText ? `text:${hints.join('|')}` : 'empty'
283 if (key === lastBoxKey) return
284 lastBoxKey = key
285 await update($, BOX, () => ({ hasText, hints }))
286}
287
288export const register: Register = (on, options) => {
289 model = typeof options.model === 'string' && options.model.trim() !== '' ? options.model : 'sonnet'
290 isAutoReview = options.autoReview !== false
291
292 on('session.start', async ($, e, next) => {
293 const r = await next(e)
294 project = e.cwd
295 sessionStart = await $.clock.now()
296 await $.command.register({
297 name: 'prompt-coach',
298 description: 'Open the prompt coach (args: habits, clear)',
299 })
300 if ((await read($, ENTRIES)).length === 0) {
301 const saved = await $.store.get(STORE_KEY)
302 if (Array.isArray(saved)) await update($, ENTRIES, () => saved as CoachEntry[])
303 const dismissed = await $.store.get(DISMISSED_KEY)
304 if (Array.isArray(dismissed)) await update($, DISMISSED, () => dismissed.filter((d): d is string => typeof d === 'string'))
305 const graduated = await $.store.get(GRADUATED_KEY)
306 if (Array.isArray(graduated)) await update($, GRADUATED, () => graduated as HabitId[])
307 if ((await $.store.get(WELCOMED_KEY)) === true) await update($, WELCOMED, () => true)
308 }
309 // A reload drops the reviews the old module had in flight, and their
310 // entries would read "Reviewing…" for good: run the recent ones again.
311 const stuck = (await read($, ENTRIES)).filter(x => x.status === 'reviewing')
312 const retry = new Set(stuck.slice(-3).map(x => x.id))
313 if (stuck.length > retry.size) {
314 await update($, ENTRIES, list =>
315 list.map(x =>
316 x.status === 'reviewing' && !retry.has(x.id)
317 ? { ...x, status: 'failed' as const, error: 'The review was cut off by a reload. Press Review it.' }
318 : x,
319 ),
320 )
321 }
322 for (const id of retry) void review($, id)
323 if (e.isInteractive) void openPane($)
324
325 return r
326 })
327
328 on('command.run', { command: 'prompt-coach' }, async ($, e) => {
329 const arg = e.args.trim()
330 if (arg === 'clear') {
331 await update($, ENTRIES, () => [])
332 await update($, SELECTED, () => null)
333 await $.store.delete(STORE_KEY)
334
335 return { text: 'history cleared.' }
336 }
337 await update($, VIEW, () => (arg === 'habits' ? 'habits' : 'review'))
338 await update($, BAND_HIDDEN, () => false)
339 await openPane($)
340
341 return { text: arg === 'habits' ? 'showing your habits.' : 'opened.' }
342 })
343
344 on('ui.close', { id: PANE }, async ($, e, next) => {
345 await update($, PANE_OPEN, () => false)
346
347 return next(e)
348 })
349
350 // Typing reaches the box as prompt.edit; prompt.change carries the rest
351 // (history recall, a fill). The check runs after the key lands, unawaited.
352 on('prompt.edit', async ($, e, next) => {
353 const r = await next(e)
354 void noteBox($, r.text)
355
356 return r
357 })
358
359 on('prompt.change', async ($, e, next) => {
360 const r = await next(e)
361 await noteBox($, r.text)
362
363 return r
364 })
365
366 on('prompt.submit', async ($, e, next) => {
367 const isYours = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
368 if (isYours && !e.command && !e.isMeta && e.text.trim() !== '') {
369 // A prompt typed while a turn runs joins that turn's prompt.
370 if (e.turnId && pending) pending.prompt += `\n\n${e.text}`
371 else pending = { prompt: e.text, tools: [], steps: [] }
372 if (await read($, DRAFT)) await update($, DRAFT, () => null)
373 }
374 await noteBox($, '')
375
376 return next(e)
377 })
378
379 on('tool.call', async ($, e, next) => {
380 if (pending && !e.agentId) {
381 pending.tools.push(e.tool)
382 pending.steps.push(stepLine(e.tool, e as unknown as Record<string, unknown>))
383 }
384
385 return next(e)
386 })
387
388 on('turn.complete', async ($, e, next) => {
389 const r = await next(e)
390 if (e.agentId || !pending) return r
391 const turn = pending
392 pending = null
393 const interrupted = e.reason === 'aborted'
394 if (e.reason !== 'answer' && !interrupted) return r
395 // An interrupt before Claude did anything leaves nothing to learn from.
396 if (interrupted && turn.tools.length === 0 && e.answer.trim() === '') return r
397
398 const now = await $.clock.now()
399 if (!project) project = await $.session.cwd().catch(() => '')
400 const u = e.usage
401 const tokens = u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
402 const isSkipped = !isAutoReview || (!interrupted && isTrivial(turn.prompt, turn.tools.length))
403 const entry: CoachEntry = {
404 id: `${now}-${counter++}`,
405 at: now,
406 prompt: turn.prompt,
407 answer: e.answer,
408 tools: turn.tools,
409 steps: turn.steps,
410 durationMs: e.durationMs,
411 status: isSkipped ? 'skipped' : 'reviewing',
412 project,
413 ...(sessionStart ? { session: sessionStart } : {}),
414 ...(tokens > 0 ? { tokens } : {}),
415 ...(interrupted ? { interrupted: true } : {}),
416 }
417 const list = await update($, ENTRIES, l => [...l, entry].slice(-KEEP))
418 await update($, SELECTED, () => null)
419 if (isSkipped) await save($, list)
420 else void review($, entry.id)
421
422 return r
423 })
424
425 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
426 if (e.props.hasSurvey) return next(e)
427 const [entries, draft, box, isPaneOpen, isHidden, graduated] = await Promise.all([
428 read($, ENTRIES),
429 read($, DRAFT),
430 read($, BOX),
431 read($, PANE_OPEN),
432 read($, BAND_HIDDEN),
433 read($, GRADUATED),
434 ])
435 const { Box, Text, Button } = $.ui.resolve(e)
436
437 // What a press just did comes first, hidden or not: it holds the Undo.
438 if (draft?.phase === 'improving') return <Text dimColor>Coach: improving your draft…</Text>
439 if (draft?.phase === 'improved') {
440 return (
441 <Box flexDirection="row" gap={1}>
442 <Text color="green">
443 {draft.source === 'use' ? 'Coach: replaced your draft with the better prompt.' : 'Coach: draft improved.'}
444 </Text>
445 {draft.source === 'improve' ? <Text dimColor>Fill in any [placeholders], then send.</Text> : null}
446 <Button key="undo-draft" hotkey="u" onPress={() => void undoDraft($)}>
447 Undo
448 </Button>
449 </Box>
450 )
451 }
452 if (draft?.phase === 'failed') {
453 return (
454 <Box flexDirection="row" gap={1}>
455 <Text color="yellow">Coach: {draft.error}</Text>
456 <Button key="dismiss-draft" hotkey="x" onPress={() => void update($, DRAFT, () => null)}>
457 Dismiss
458 </Button>
459 </Box>
460 )
461 }
462 if (isHidden) return next(e)
463
464 const hide = (
465 <Button key="hide-band" plain dimColor onPress={() => void update($, BAND_HIDDEN, () => true)}>
466 ✕
467 </Button>
468 )
469
470 // A draft in the box: check it as it is typed, and offer to improve it.
471 if (box.hasText) {
472 return (
473 <Box flexDirection="row" gap={1}>
474 {box.hints.length > 0 ? (
475 <Text>
476 <Text dimColor>Draft:</Text> <Text color="yellow">{box.hints.join(' · ')}</Text>
477 </Text>
478 ) : (
479 <Text dimColor>Draft looks clear.</Text>
480 )}
481 <Button key="improve-draft" hotkey="i" onPress={() => void improveDraft($)}>
482 Improve draft
483 </Button>
484 {hide}
485 </Box>
486 )
487 }
488
489 const avg = average(entries)
490 const [focus] = weakHabits(entries, 1, graduated)
491 if (avg === undefined && !focus && isPaneOpen) return next(e)
492
493 return (
494 <Box flexDirection="row" gap={1}>
495 <Text dimColor>
496 Coach{avg === undefined ? '' : ` · avg ${avg.toFixed(1)}/5`}
497 {focus ? ' · focus: ' : ''}
498 </Text>
499 {focus ? (
500 <Text color="yellow">
501 {habitName(focus)}
502 <Text dimColor>
503 {' '}
504 {streak(entries, focus)}/{STREAK_TO_GRADUATE}
505 </Text>
506 </Text>
507 ) : null}
508 {isPaneOpen ? null : (
509 <Button key="open-coach" hotkey="o" dimColor onPress={() => void openPane($)}>
510 Open
511 </Button>
512 )}
513 {hide}
514 </Box>
515 )
516 })
517
518 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
519 const { Box, Text, Button } = $.ui.resolve(e)
520 const [all, selectedId, view, scope, dismissed, isExpanded, isWelcomed, graduated, tick] = await Promise.all([
521 read($, ENTRIES),
522 read($, SELECTED),
523 read($, VIEW),
524 read($, SCOPE),
525 read($, DISMISSED),
526 read($, EXPANDED),
527 read($, WELCOMED),
528 read($, GRADUATED),
529 read($, TICK),
530 ])
531 const now = Math.max(tick, await $.clock.now())
532 const width = Math.max(20, e.props.bodyColumns ?? (e.viewport?.columns ?? 50) - 2)
533 const rows = e.viewport?.rows ?? 30
534 const isNarrow = width < NARROW
535 const here = all.filter(x => x.project === project)
536 const avg = average(all)
537 const reviewed = all.filter(x => x.status === 'done').length
538 const [focus] = weakHabits(all, 1, graduated)
539 const rules = ruleSuggestions(here, dismissed)
540 const habitsBadge = (focus ? 1 : 0) + rules.length
541
542 const tab = (key: string, hotkey: string, label: string, isActive: boolean, badge = 0) => (
543 <Button key={key} hotkey={hotkey} plain onPress={() => void update($, VIEW, () => (key === 'view-habits' ? 'habits' : 'review') as 'habits' | 'review')}>
544 {isActive ? (
545 <Text bold underline>
546 {label}
547 </Text>
548 ) : (
549 <Text dimColor>{label}</Text>
550 )}
551 {badge > 0 ? <Text color="yellow">·{badge}</Text> : ''}
552 </Button>
553 )
554
555 const header = (
556 <Box flexDirection="column">
557 <Box flexDirection="row" justifyContent="space-between">
558 <Text bold>{TITLE}</Text>
559 <Box flexDirection="row" gap={2}>
560 {tab('view-review', 'r', 'Review', view === 'review')}
561 {tab('view-habits', 'h', 'Habits', view === 'habits', habitsBadge)}
562 </Box>
563 </Box>
564 <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
565 </Box>
566 )
567
568 const footer = (
569 <Box flexDirection="column">
570 <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
571 <Box flexDirection="row" gap={1} flexWrap="wrap">
572 {e.props.isFocused ? (
573 <Text dimColor wrap="wrap">
574 {view === 'review'
575 ? 'u use · c copy · j/k move · e expand · y/n rate · i improve · h habits'
576 : 'a add rule · d dismiss · i improve · r review'}
577 </Text>
578 ) : (
579 <Text dimColor>ctrl+x tab to use keys</Text>
580 )}
581 <Button key="improve" hotkey="i" plain dimColor onPress={() => void improveDraft($)}>
582 Improve draft
583 </Button>
584 </Box>
585 </Box>
586 )
587
588 const welcome = isWelcomed ? null : (
589 <Box flexDirection="column" borderStyle="round" paddingX={1}>
590 <Text bold>Welcome to Prompt coach</Text>
591 <Text wrap="wrap">
592 After each answer, the coach reviews the prompt you sent and suggests a better one. That is one {model} call, about
593 half a cent; short follow-ups like "yes" are skipped.
594 </Text>
595 <Text wrap="wrap">
596 <Text bold>u</Text> uses the better prompt · <Text bold>i</Text> improves your draft before you send ·{' '}
597 <Text bold>h</Text> shows your habits. Press ctrl+x tab to give the sidebar the keys.
598 </Text>
599 <Button key="welcome-ok" hotkey="g" onPress={() => void dismissWelcome($)}>
600 Got it
601 </Button>
602 </Box>
603 )
604
605 if (all.length === 0) {
606 return (
607 <Box flexDirection="column" gap={1}>
608 {header}
609 {welcome}
610 <Text dimColor wrap="wrap">
611 Send a prompt. After Claude answers, a review of your prompt and a better version of it appear here.
612 </Text>
613 {footer}
614 </Box>
615 )
616 }
617
618 if (view === 'habits') {
619 const stats = habitStats(all)
620 const focusStat = stats.find(s => s.id === focus)
621 const focusStreak = focus ? streak(all, focus) : 0
622 const saving =
623 focusStat?.tokensShown !== undefined && focusStat.tokensMissed !== undefined && focusStat.tokensMissed > focusStat.tokensShown
624 ? Math.round((1 - focusStat.tokensShown / focusStat.tokensMissed) * 100)
625 : undefined
626
627 return (
628 <Box flexDirection="column" gap={1}>
629 {header}
630 {welcome}
631 {focusStat ? (
632 <Box flexDirection="column">
633 <Text bold color="yellow">
634 Focus: {focusStat.name}
635 </Text>
636 <Text wrap="wrap">{focusStat.advice}</Text>
637 <Text>
638 <Text color="green">{'●'.repeat(focusStreak)}</Text>
639 <Text dimColor>{'○'.repeat(Math.max(0, STREAK_TO_GRADUATE - focusStreak))}</Text>
640 <Text dimColor>
641 {' '}
642 {focusStreak === 0
643 ? `keep it ${STREAK_TO_GRADUATE} prompts in a row to make it a habit`
644 : `${STREAK_TO_GRADUATE - focusStreak} more in a row to make it a habit`}
645 </Text>
646 </Text>
647 {saving !== undefined && saving >= 10 ? (
648 <Text color="green" wrap="wrap">
649 Your turns used {saving}% fewer tokens when you did this.
650 </Text>
651 ) : null}
652 </Box>
653 ) : (
654 <Text dimColor wrap="wrap">
655 {reviewed < 3 ? 'A few more reviewed prompts and your habits show here.' : 'No weak habit right now. Keep it up.'}
656 </Text>
657 )}
658 <Box flexDirection="column">
659 <Text dimColor>
660 Last {Math.min(30, reviewed)} reviewed{avg === undefined ? '' : ` · avg ${avg.toFixed(1)}/5`}
661 </Text>
662 {stats.map(s => (
663 <Text>
664 <Text color={s.rate === undefined ? undefined : s.rate >= 0.8 ? 'green' : 'yellow'}>
665 {s.rate === undefined ? '░'.repeat(isNarrow ? 6 : 10) : bar(s.rate, isNarrow ? 6 : 10)}
666 </Text>{' '}
667 {s.id === focus ? <Text bold>{s.name}</Text> : s.name}
668 <Text dimColor>
669 {s.rate === undefined
670 ? ' n/a'
671 : ` ${s.shown}/${s.shown + s.missed}${s.trend === undefined ? '' : s.trend > 0.1 ? ' ↑' : s.trend < -0.1 ? ' ↓' : ' →'}`}
672 </Text>
673 {graduated.includes(s.id) ? <Text color="green"> ★</Text> : ''}
674 </Text>
675 ))}
676 {graduated.length > 0 ? <Text dimColor>★ kept 3 in a row: a habit now</Text> : null}
677 </Box>
678 {rules.length > 0 && (
679 <Box flexDirection="column">
680 <Text bold>You keep repeating these: add them to CLAUDE.md</Text>
681 {rules.slice(0, 4).map((r, i) => (
682 <Box flexDirection="column">
683 <Text wrap="wrap">
684 “{r.text}” <Text dimColor>· {r.count} prompts</Text>
685 </Text>
686 <Box flexDirection={isNarrow ? 'column' : 'row'} gap={isNarrow ? 0 : 1}>
687 <Button
688 key={`rule-add-${i}`}
689 hotkey={i === 0 ? 'a' : undefined}
690 onPress={() => void $.prompt.fill({ text: claudeMdPrompt(r.text) })}
691 >
692 Ask Claude to add it
693 </Button>
694 <Button key={`rule-dismiss-${i}`} hotkey={i === 0 ? 'd' : undefined} dimColor onPress={() => void dismissRule($, r.text)}>
695 Dismiss
696 </Button>
697 </Box>
698 </Box>
699 ))}
700 </Box>
701 )}
702 {footer}
703 </Box>
704 )
705 }
706
707 const entries = scope === 'project' && here.length > 0 ? here : all
708 const found = selectedId === null ? -1 : entries.findIndex(x => x.id === selectedId)
709 const index = found < 0 ? entries.length - 1 : found
710 const shown = entries[index] ?? entries[entries.length - 1]!
711 const select = (i: number) => {
712 const target = entries[i]
713 if (target) void update($, SELECTED, () => (i === entries.length - 1 ? null : target.id))
714 }
715 const missed = Object.entries(shown.habits ?? {}).filter(([, m]) => m === 'missed').map(([id]) => id as HabitId)
716 const kept = Object.values(shown.habits ?? {}).filter(m => m === 'shown').length
717 const isClear = shown.status === 'done' && (shown.score ?? 0) >= 5 && !shown.better
718 const headline = shown.nextStep || shown.feedback?.[0] || ''
719 const points = (shown.feedback ?? []).filter(f => f !== headline)
720 const quote = oneLine(shown.prompt)
721 const quoteMax = isNarrow && !isExpanded ? (width - 2) * 2 : isExpanded ? 4000 : 400
722 const elapsed = shown.reviewStartedAt !== undefined ? Math.max(0, now - shown.reviewStartedAt) : 0
723
724 // The trail takes the rows the detail leaves free.
725 const room = Math.max(3, rows - (isClear || shown.status !== 'done' ? 16 : 30))
726 const start = Math.max(0, Math.min(index - Math.floor(room / 2), entries.length - room))
727 const trail = entries.slice(start, start + room)
728
729 const actions = shown.status === 'done' && shown.better ? (
730 <Box flexDirection={isNarrow ? 'column' : 'row'} gap={isNarrow ? 0 : 1}>
731 <Button key="use" hotkey="u" variant="primary" onPress={() => void useBetter($, shown.better ?? '')}>
732 Use it
733 </Button>
734 <Button key="copy" hotkey="c" onPress={press => void $.ui.copy({ text: shown.better ?? '', surface: press.surface })}>
735 Copy
736 </Button>
737 </Box>
738 ) : null
739
740 return (
741 <Box flexDirection="column" gap={1}>
742 {header}
743 {welcome}
744
745 <Box flexDirection="column">
746 <Box flexDirection="row" justifyContent="space-between">
747 <Text dimColor>
748 #{index + 1} · {Math.round(shown.durationMs / 1000)}s · {shown.tools.length} call{shown.tools.length === 1 ? '' : 's'}
749 {shown.tokens && !isNarrow ? ` · ${formatTokens(shown.tokens)} tok` : ''}
750 {shown.interrupted ? ' · interrupted' : ''}
751 {scope === 'all' && shown.project && !isNarrow ? ` · ${projectName(shown.project)}` : ''}
752 </Text>
753 {shown.status === 'done' && shown.score !== undefined ? (
754 <Text color={scoreColor(shown.score)} dimColor={shown.score >= 4}>
755 {shown.score}/5
756 </Text>
757 ) : null}
758 </Box>
759 <Text wrap="wrap">“{quote.length > quoteMax ? `${quote.slice(0, quoteMax - 1)}…` : quote}”</Text>
760 {quote.length > quoteMax || isExpanded ? (
761 <Button key="expand" hotkey="e" plain dimColor onPress={() => void update($, EXPANDED, x => !x)}>
762 {isExpanded ? 'show less' : 'show all'}
763 </Button>
764 ) : null}
765 </Box>
766
767 {shown.status === 'reviewing' ? (
768 <Box flexDirection="column">
769 <Text dimColor>Reviewing… {Math.round(elapsed / 1000)}s</Text>
770 {elapsed > SLOW_MS ? (
771 <Box flexDirection="row" gap={1}>
772 <Text color="yellow">Taking longer than usual.</Text>
773 <Button key="retry" hotkey="v" onPress={() => void review($, shown.id)}>
774 Retry
775 </Button>
776 </Box>
777 ) : null}
778 </Box>
779 ) : null}
780 {shown.status === 'skipped' || shown.status === 'failed' ? (
781 <Box flexDirection="column">
782 {shown.status === 'skipped' ? (
783 <Text dimColor wrap="wrap">
784 {isAutoReview ? '· A short follow-up: not reviewed, to save a call.' : 'Automatic review is off.'}
785 </Text>
786 ) : (
787 <Text color="yellow" wrap="wrap">
788 {shown.error ?? 'The review failed.'}
789 </Text>
790 )}
791 <Button key="review-it" hotkey="v" onPress={() => void review($, shown.id)}>
792 Review it
793 </Button>
794 </Box>
795 ) : null}
796
797 {isClear ? (
798 <Text wrap="wrap">
799 <Text color="green">✓ Clear prompt.</Text> <Text dimColor>{shown.verdict}</Text>
800 </Text>
801 ) : null}
802
803 {shown.status === 'done' && !isClear ? (
804 <Box flexDirection="column" gap={1}>
805 <Box flexDirection="column">
806 {headline ? (
807 <Text bold wrap="wrap">
808 → {headline}
809 </Text>
810 ) : null}
811 {shown.verdict ? (
812 <Text dimColor wrap="wrap">
813 {shown.verdict}
814 </Text>
815 ) : null}
816 </Box>
817 {shown.better ? (
818 <Box flexDirection="column">
819 <Text dimColor>
820 Better prompt <Text color="green">+added</Text> <Text color="yellow">[fill in]</Text>
821 </Text>
822 <Text wrap="wrap">
823 {diffWords(shown.prompt, shown.better).flatMap(part =>
824 placeholders(part.text).map(run =>
825 run.isPlaceholder ? (
826 <Text color="yellow">{run.text}</Text>
827 ) : part.isAdded ? (
828 <Text color="green">{run.text}</Text>
829 ) : (
830 <Text>{run.text}</Text>
831 ),
832 ),
833 )}
834 </Text>
835 </Box>
836 ) : null}
837 {actions}
838 {missed.length > 0 || kept > 0 ? (
839 <Text wrap="wrap">
840 {missed.map(id => (
841 <Text color="yellow">✗ {habitName(id)}{' '}</Text>
842 ))}
843 {kept > 0 ? <Text color="green">+{kept} ✓</Text> : ''}
844 </Text>
845 ) : null}
846 {points.length > 0 ? (
847 <Box flexDirection="column">
848 {points.map(f => (
849 <Text wrap="wrap" dimColor>
850 • {f}
851 </Text>
852 ))}
853 </Box>
854 ) : null}
855 {shown.followup ? (
856 <Text color="yellow" wrap="wrap">
857 ↳ Your next prompt had to correct this: {shown.followup}
858 </Text>
859 ) : null}
860 {shown.tip ? (
861 <Text dimColor wrap="wrap">
862 Tip: {shown.tip}
863 </Text>
864 ) : null}
865 {(shown.rules ?? []).length > 0 ? (
866 <Text dimColor wrap="wrap">
867 Standing instructions: {(shown.rules ?? []).map(r => `“${r}”`).join(', ')}
868 </Text>
869 ) : null}
870 </Box>
871 ) : null}
872
873 {shown.status === 'done' ? (
874 <Box flexDirection="row" gap={1}>
875 <Button key="rate-up" hotkey="y" plain dimColor={shown.rating !== 'up'} onPress={() => void rate($, shown.id, 'up')}>
876 {shown.rating === 'up' ? '✓ Helpful' : 'Helpful'}
877 </Button>
878 <Text dimColor>·</Text>
879 <Button key="rate-down" hotkey="n" plain dimColor={shown.rating !== 'down'} onPress={() => void rate($, shown.id, 'down')}>
880 {shown.rating === 'down' ? '✓ Not helpful' : 'Not helpful'}
881 </Button>
882 </Box>
883 ) : null}
884
885 <Box flexDirection="column">
886 <Box flexDirection="row" gap={1}>
887 <Text dimColor>Trail ·</Text>
888 <Button
889 key="scope"
890 hotkey="p"
891 plain
892 dimColor
893 onPress={() => void update($, SCOPE, s => (s === 'project' ? ('all' as const) : ('project' as const)))}
894 >
895 {scope === 'project' && here.length > 0 ? `${projectName(project)} (show all)` : 'all projects (show this one)'}
896 </Button>
897 </Box>
898 {trail.flatMap((x, i) => {
899 const n = start + i
900 const before = entries[n - 1]
901 const isNewSession = x.session !== undefined && before !== undefined && before.session !== x.session
902 const time = relTime(now - x.at)
903 const textRoom = Math.max(8, width - 6 - time.length)
904 const prompt = oneLine(x.prompt)
905 const label = prompt.length > textRoom ? `${prompt.slice(0, textRoom - 1)}…` : prompt.padEnd(textRoom)
906 const mark =
907 x.status === 'reviewing' ? (
908 <Text dimColor>… </Text>
909 ) : x.status === 'skipped' ? (
910 <Text dimColor>· </Text>
911 ) : x.status === 'failed' ? (
912 <Text color="yellow">! </Text>
913 ) : (
914 <Text color={scoreColor(x.score ?? 0)}>●{x.score ?? '–'} </Text>
915 )
916 const row = (
917 <Button key={`row-${x.id}`} plain dimColor={n !== index} onPress={() => select(n)}>
918 {n === index ? '▸' : ' '}
919 {mark}
920 {label} <Text dimColor>{time}</Text>
921 </Button>
922 )
923
924 return isNewSession && x.session !== undefined
925 ? [<Text dimColor>{`── ${sessionLabel(x.session, now)} ──`}</Text>, row]
926 : [row]
927 })}
928 <Box flexDirection="row" gap={2}>
929 {index < entries.length - 1 ? (
930 <Button key="newer" hotkey="j" plain dimColor onPress={() => select(index + 1)}>
931 newer
932 </Button>
933 ) : null}
934 {index > 0 ? (
935 <Button key="older" hotkey="k" plain dimColor onPress={() => select(index - 1)}>
936 older
937 </Button>
938 ) : null}
939 </Box>
940 </Box>
941
942 {footer}
943 </Box>
944 )
945 })
946}
947hooks/coach.ts 438 lines1import type { CoachEntry, HabitId, HabitMark } from '../types'
2
3export const HABITS: readonly { id: HabitId; name: string; advice: string }[] = [
4 { id: 'target', name: 'Point at the code', advice: 'Name the files, functions or commands involved, or @-mention them.' },
5 { id: 'goal', name: 'Say what done looks like', advice: 'State the outcome you want, not just the area to work on.' },
6 { id: 'evidence', name: 'Paste the evidence', advice: 'Include the error, stack trace, output or steps to reproduce.' },
7 { id: 'verify', name: 'Say how to check', advice: 'Tell Claude which test, build or command proves it works.' },
8 { id: 'constraints', name: 'Set the limits', advice: "Say what not to touch, and which libraries or style to keep." },
9 { id: 'scope', name: 'One task at a time', advice: 'Split unrelated asks; /clear between unrelated tasks.' },
10 { id: 'plan', name: 'Plan big changes first', advice: 'Ask for a plan (shift+tab for plan mode) before large or risky edits.' },
11]
12
13const HABIT_IDS = new Set<string>(HABITS.map(h => h.id))
14
15export const habitName = (id: HabitId) => HABITS.find(h => h.id === id)?.name ?? id
16
17const HABIT_LINES = HABITS.map(h => `- ${h.id}: ${h.name}. ${h.advice}`).join('\n')
18
19export const SYSTEM = `You are a Claude Code coach. You review one exchange between a developer and Claude Code (an agentic coding CLI) and help the developer build habits that get better results.
20
21Judge the developer's PROMPT, not Claude's work. Use what Claude did as evidence of what the prompt left unclear: many searches or file reads mean the prompt did not say where to look; clarifying questions in the answer mean the goal or constraints were missing; a long turn on a small ask means the scope was vague; an interrupted turn usually means Claude went the wrong way.
22
23Read the earlier prompts and the end of Claude's previous answer first. A short prompt that follows clearly from them (answering Claude's question, "now do the same for the tests") is a GOOD prompt: the context carries it.
24
25The habits, by id:
26${HABIT_LINES}
27
28For each habit that APPLIES to this prompt, mark it "shown" or "missed". Leave out a habit that does not apply: "plan" applies only to large or risky changes, "evidence" only to bugs and failures, "verify" only to changes that can be checked, "target" only when the code involved was not already clear from the context.
29
30Scores:
315 = Claude could act right away with no guessing. 4 = one small gap. 3 = Claude had to explore or guess at something the developer knew. 2 = vague: several gaps, Claude searched widely or asked back. 1 = Claude could not tell what was wanted.
32
33Examples (prompt -> score, habits):
34- "the login is broken fix it" -> 2; target missed, evidence missed, verify missed, goal missed
35- "Login returns 401 for valid users since the token refactor (error below). Fix it in @src/auth/session.ts without changing the public API, then run npm test -- auth. [error]" -> 5; target, evidence, goal, constraints, verify shown
36- "yes, and do the same for the signup form" after Claude offered to -> 5; no habits apply
37- "refactor the whole data layer to use the repository pattern" -> 2; plan missed, scope missed, goal missed, verify missed
38- "add a --json flag to the export command in cli/export.ts" -> 4; target shown, goal shown, verify missed
39
40The developer's focus habit, when given, is the one they are working on: when the prompt shows it, say so first in the feedback ("You said how to check: good."); when it misses it, make that the first point.
41
42If the prompt gives a STANDING instruction, one that would hold for future tasks in this project too (tooling, conventions, style, what never to touch: "use pnpm", "don't edit the generated files"), list it in "standing_instructions", each one short imperative line fit for CLAUDE.md, at most 3. A detail of this one task is not one. When one matches a known standing instruction given, copy that one's text exactly. Otherwise leave the list empty.
43
44The developer's ratings of your past reviews, when given, show what feedback helps them: give more of what they rated helpful, less of what they rated unhelpful.
45
46If THIS prompt corrects or redirects the previous one ("no, I meant...", "that's the wrong file"), set "previous_unclear" to one short line on what the previous prompt should have said; otherwise leave it empty.
47
48Reply with JSON only, no prose, no code fence:
49{"score": 1-5, "verdict": "<one short line>", "next_step": "<the single change that would have helped most, as an instruction to the developer, one line; empty if the prompt was a 5>", "feedback": ["<at most 3 short, specific points; name the actual files, errors or commands from the exchange>"], "better": "<the prompt rewritten as the developer should have sent it, keeping their language and intent; empty if it was already a 5>", "tip": "<one Claude Code feature or habit that fits this exchange, one line>", "habits": {"<habit id>": "shown" | "missed"}, "previous_unclear": "", "standing_instructions": []}
50
51Claude Code features worth suggesting when they fit: plan mode (shift+tab), @file mentions, CLAUDE.md for instructions you repeat, /clear between unrelated tasks, /compact, subagents for parallel research, ! to run a shell command yourself so its output lands in context, custom slash commands or skills for repeated workflows, hooks for automatic checks.`
52
53export const IMPROVE_SYSTEM = `You rewrite a developer's draft prompt for Claude Code (an agentic coding CLI) so Claude can act on it right away.
54
55Keep the developer's intent, language and voice. Keep it short: a few sentences, never a spec. Use what the conversation so far makes clear: name the files, functions, errors and commands it mentions. Never invent a fact or a decision you were not given: no new requirements, names or values the developer did not choose. Where a needed fact or decision is missing, put a short bracketed placeholder the developer fills in, like [paste the error] or [env var or config file?]. Add one line on how to verify when the change can be checked. Ask for a plan first only when the change is large or risky.
56
57The habits a strong prompt shows:
58${HABIT_LINES}
59
60Reply with the rewritten prompt alone: no preamble, no quotes, no code fence.`
61
62export type Review = Pick<CoachEntry, 'score' | 'verdict' | 'nextStep' | 'feedback' | 'better' | 'tip' | 'habits' | 'rules'> & {
63 previousUnclear: string
64}
65
66const ACK =
67 /^(y|yes|yep|yeah|ya|no|nope|ok|okay|k|sure|go|go ahead|go on|continue|proceed|do it|please do|thanks|thank you|ty|lgtm|sounds good|perfect|great|nice|cool|done|next|retry|try again|keep going|\d+|[a-d])[.!\s]*$/i
68
69// A follow-up a review could only call fine: an acknowledgement, or two words
70// that set off little work. "fix the bug" is three words and is reviewed.
71export function isTrivial(prompt: string, tools: number): boolean {
72 const text = prompt.trim()
73 if (ACK.test(text)) return true
74 const words = text.split(/\s+/).filter(Boolean).length
75
76 return words <= 2 && tools <= 3
77}
78
79export const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
80
81const clipEnd = (text: string, max: number) =>
82 text.length > max ? `${text.slice(0, max)}\n[... ${text.length - max} more characters]` : text
83
84// Keeps the opening and, longer, the end: where Claude asks back or sums up.
85export function clipMiddle(text: string, max: number): string {
86 if (text.length <= max) return text
87 const head = Math.floor(max * 0.35)
88 const tail = max - head
89
90 return `${text.slice(0, head)}\n[... ${text.length - max} characters left out ...]\n${text.slice(-tail)}`
91}
92
93const clipTail = (text: string, max: number) => (text.length > max ? `[...]${text.slice(-max)}` : text)
94
95const ARG_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'description', 'prompt', 'skill'] as const
96
97// One short line for a tool call: its name and its main argument.
98export function stepLine(tool: string, args: Readonly<Record<string, unknown>>): string {
99 for (const key of ARG_KEYS) {
100 const value = args[key]
101 if (typeof value === 'string' && value.trim() !== '') {
102 const arg = oneLine(value)
103
104 return `${tool}: ${arg.length > 80 ? `${arg.slice(0, 77)}...` : arg}`
105 }
106 }
107
108 return tool
109}
110
111export function toolSummary(tools: readonly string[]): string {
112 if (tools.length === 0) return 'none'
113 const counts = new Map<string, number>()
114 for (const t of tools) counts.set(t, (counts.get(t) ?? 0) + 1)
115
116 return [...counts].map(([t, n]) => (n > 1 ? `${t} ×${n}` : t)).join(', ')
117}
118
119function context(earlier: readonly CoachEntry[]): string[] {
120 const prompts = earlier
121 .slice(-3)
122 .map(e => `- ${clipEnd(oneLine(e.prompt), 300)}`)
123 .join('\n')
124 const last = earlier[earlier.length - 1]
125
126 return [
127 prompts ? `<earlier_prompts oldest_first="true">\n${prompts}\n</earlier_prompts>` : '',
128 last && last.answer ? `<end_of_previous_answer>\n${clipTail(last.answer, 1500)}\n</end_of_previous_answer>` : '',
129 ]
130}
131
132export type ReviewContext = {
133 // The habit the developer is working on.
134 focus?: HabitId
135 // Standing instructions already seen, so a repeat is worded the same.
136 rules?: readonly string[]
137 // Past reviews the developer rated.
138 ratings?: readonly CoachEntry[]
139}
140
141export function formatTokens(tokens: number): string {
142 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
143}
144
145export function buildPrompt(entry: CoachEntry, earlier: readonly CoachEntry[], ctx: ReviewContext = {}): string {
146 const { focus, rules = [], ratings = [] } = ctx
147 const rated = ratings
148 .filter(e => e.rating && e.verdict)
149 .slice(-6)
150 .map(e => `- ${e.rating === 'up' ? 'helpful' : 'unhelpful'}: ${e.verdict}${e.feedback?.[0] ? ` | ${e.feedback[0]}` : ''}`)
151 .join('\n')
152
153 const steps = entry.steps ?? []
154 const shown = steps.slice(0, 25).join('\n')
155 const more = steps.length > 25 ? `\n[... ${steps.length - 25} more]` : ''
156
157 return [
158 ...context(earlier),
159 focus ? `<developer_focus_habit>${focus}: ${habitName(focus)}</developer_focus_habit>` : '',
160 rules.length > 0 ? `<known_standing_instructions>\n${rules.map(r => `- ${r}`).join('\n')}\n</known_standing_instructions>` : '',
161 rated ? `<developer_ratings_of_past_reviews>\n${rated}\n</developer_ratings_of_past_reviews>` : '',
162 `<prompt>\n${clipEnd(entry.prompt, 4000)}\n</prompt>`,
163 `<what_claude_did tool_calls="${entry.tools.length}" summary="${toolSummary(entry.tools)}">\n${shown}${more}\n</what_claude_did>`,
164 `<turn_seconds>${Math.round(entry.durationMs / 1000)}</turn_seconds>`,
165 entry.tokens ? `<turn_tokens>${entry.tokens}</turn_tokens>` : '',
166 entry.interrupted ? '<interrupted_by_developer>true</interrupted_by_developer>' : '',
167 `<answer>\n${clipMiddle(entry.answer, 6000)}\n</answer>`,
168 ]
169 .filter(Boolean)
170 .join('\n\n')
171}
172
173export function buildImprovePrompt(draft: string, earlier: readonly CoachEntry[], weak: readonly HabitId[]): string {
174 return [
175 ...context(earlier),
176 weak.length > 0 ? `<habits_this_developer_often_misses>${weak.join(', ')}</habits_this_developer_often_misses>` : '',
177 `<draft>\n${clipEnd(draft, 6000)}\n</draft>`,
178 ]
179 .filter(Boolean)
180 .join('\n\n')
181}
182
183// Strips a fence or quotes a model put around a rewritten prompt.
184export function cleanRewrite(text: string): string {
185 let t = text.trim()
186 const fence = /^```[a-z]*\n([\s\S]*?)\n```$/.exec(t)
187 if (fence?.[1] !== undefined) t = fence[1].trim()
188 if (t.length > 1 && /^["“]/.test(t) && /["”]$/.test(t)) t = t.slice(1, -1).trim()
189
190 return t
191}
192
193export function parseReview(text: string): Review | undefined {
194 const start = text.indexOf('{')
195 const end = text.lastIndexOf('}')
196 if (start < 0 || end <= start) return undefined
197 let raw: unknown
198 try {
199 raw = JSON.parse(text.slice(start, end + 1))
200 } catch {
201 return undefined
202 }
203 if (typeof raw !== 'object' || raw === null) return undefined
204 const r = raw as Record<string, unknown>
205 const score = Number(r.score)
206 if (!Number.isFinite(score)) return undefined
207
208 const habits: Partial<Record<HabitId, HabitMark>> = {}
209 if (typeof r.habits === 'object' && r.habits !== null) {
210 for (const [id, mark] of Object.entries(r.habits as Record<string, unknown>)) {
211 if (HABIT_IDS.has(id) && (mark === 'shown' || mark === 'missed')) habits[id as HabitId] = mark
212 }
213 }
214
215 return {
216 score: Math.min(5, Math.max(1, Math.round(score))),
217 verdict: typeof r.verdict === 'string' ? r.verdict.trim() : '',
218 nextStep: typeof r.next_step === 'string' ? r.next_step.trim() : '',
219 feedback: Array.isArray(r.feedback)
220 ? r.feedback.filter((f): f is string => typeof f === 'string' && f.trim() !== '').slice(0, 3)
221 : [],
222 better: typeof r.better === 'string' ? r.better.trim() : '',
223 tip: typeof r.tip === 'string' ? r.tip.trim() : '',
224 habits,
225 previousUnclear: typeof r.previous_unclear === 'string' ? r.previous_unclear.trim() : '',
226 rules: Array.isArray(r.standing_instructions)
227 ? r.standing_instructions
228 .filter((x): x is string => typeof x === 'string' && x.trim() !== '')
229 .map(oneLine)
230 .slice(0, 3)
231 : [],
232 }
233}
234
235export function average(entries: readonly CoachEntry[]): number | undefined {
236 const scored = entries.filter(e => e.status === 'done' && e.score !== undefined)
237 if (scored.length === 0) return undefined
238
239 return scored.reduce((sum, e) => sum + (e.score ?? 0), 0) / scored.length
240}
241
242export type HabitStat = {
243 id: HabitId
244 name: string
245 advice: string
246 shown: number
247 missed: number
248 // Share shown, 0 to 1; undefined before the habit applied to any prompt.
249 rate: number | undefined
250 // The newer half's rate minus the older half's, when both halves have marks.
251 trend: number | undefined
252 // Mean tokens per turn when the habit was shown, and when missed: what it saves.
253 tokensShown: number | undefined
254 tokensMissed: number | undefined
255}
256
257const WINDOW = 30
258// A habit is a focus candidate once it applied to this many recent prompts.
259const MIN_MARKS = 3
260
261export function habitStats(entries: readonly CoachEntry[]): HabitStat[] {
262 const recent = entries.filter(e => e.status === 'done' && e.habits).slice(-WINDOW)
263
264 return HABITS.map(h => {
265 const marks = recent.map(e => e.habits?.[h.id]).filter((m): m is HabitMark => m !== undefined)
266 const meanTokens = (mark: HabitMark) => {
267 const ts = recent.filter(e => e.habits?.[h.id] === mark && e.tokens).map(e => e.tokens ?? 0)
268
269 return ts.length >= 2 ? ts.reduce((a, b) => a + b, 0) / ts.length : undefined
270 }
271 const shown = marks.filter(m => m === 'shown').length
272 const rateOf = (ms: HabitMark[]) => (ms.length === 0 ? undefined : ms.filter(m => m === 'shown').length / ms.length)
273 const half = Math.floor(marks.length / 2)
274 const older = rateOf(marks.slice(0, half))
275 const newer = rateOf(marks.slice(half))
276
277 return {
278 ...h,
279 shown,
280 missed: marks.length - shown,
281 rate: rateOf(marks),
282 trend: marks.length >= 4 && older !== undefined && newer !== undefined ? newer - older : undefined,
283 tokensShown: meanTokens('shown'),
284 tokensMissed: meanTokens('missed'),
285 }
286 })
287}
288
289// The habits missed most, worst first: the first is the one to work on. A
290// graduated habit comes back only once it slips below half.
291export function weakHabits(entries: readonly CoachEntry[], count = 2, graduated: readonly HabitId[] = []): HabitId[] {
292 return habitStats(entries)
293 .filter(s => s.rate !== undefined && s.shown + s.missed >= MIN_MARKS && s.rate < 0.8)
294 .filter(s => !graduated.includes(s.id) || (s.rate ?? 1) < 0.5)
295 .sort((a, b) => (a.rate ?? 1) - (b.rate ?? 1) || b.missed - a.missed)
296 .slice(0, count)
297 .map(s => s.id)
298}
299
300// The distinct standing instructions seen, newest spelling of each, for the coach to reuse.
301export function knownRules(entries: readonly CoachEntry[], max = 15): string[] {
302 const byKey = new Map<string, string>()
303 for (const e of entries) for (const rule of e.rules ?? []) byKey.set(rule.toLowerCase(), rule)
304
305 return [...byKey.values()].slice(-max)
306}
307
308export type RuleSuggestion = { text: string; count: number }
309
310// Standing instructions given in two or more prompts: lines for CLAUDE.md.
311export function ruleSuggestions(entries: readonly CoachEntry[], dismissed: readonly string[]): RuleSuggestion[] {
312 const counts = new Map<string, RuleSuggestion>()
313 for (const e of entries) {
314 // Counted once per prompt, however often it says it.
315 for (const rule of new Set(e.rules ?? [])) {
316 const key = rule.toLowerCase()
317 counts.set(key, { text: rule, count: (counts.get(key)?.count ?? 0) + 1 })
318 }
319 }
320
321 return [...counts]
322 .filter(([key, r]) => r.count >= 2 && !dismissed.includes(key))
323 .map(([, r]) => r)
324 .sort((a, b) => b.count - a.count)
325}
326
327export const claudeMdPrompt = (rule: string) =>
328 `Add this line to CLAUDE.md, in the section where it fits (create the file at the project root if there is none): "${rule}"`
329
330// How many of the most recent prompts the habit applied to showed it, in a row.
331export function streak(entries: readonly CoachEntry[], habit: HabitId): number {
332 let n = 0
333 for (let i = entries.length - 1; i >= 0; i--) {
334 const mark = entries[i]?.status === 'done' ? entries[i]?.habits?.[habit] : undefined
335 if (mark === undefined) continue
336 if (mark === 'missed') break
337 n++
338 }
339
340 return n
341}
342
343export const STREAK_TO_GRADUATE = 3
344
345// Cheap checks on a draft as it is typed: no model call. At most two, the
346// most useful first; none for short drafts, commands and shell lines.
347export function lintDraft(text: string): string[] {
348 const t = text.trim()
349 if (t.startsWith('/') || t.startsWith('!')) return []
350 const words = t.split(/\s+/).filter(Boolean).length
351 if (words < 4) return []
352 const lower = t.toLowerCase()
353 const hints: string[] = []
354 const isBug = /\b(fix|bug|broken|error|errors|fail|fails|failing|crash|crashes|exception|wrong|issue)\b/.test(lower)
355 const hasEvidence = /```|\n\s+at |error:|exception:|traceback|stack|expected .* (got|received)|"[^"]{12,}"|`[^`]{12,}`/i.test(t)
356 const hasTarget = /@\S+|[\w-]+\/[\w./-]+|\b[\w-]+\.(ts|tsx|js|jsx|py|go|rs|rb|java|kt|swift|md|json|ya?ml|css|html|sql|sh)\b|`[^`]+`|\b\w+\(\)/i.test(t)
357 const isChange = /\b(add|fix|implement|build|create|refactor|change|update|make|rename|remove|delete|migrate|write)\b/.test(lower)
358 const hasCheck = /\b(test|tests|run|check|verify|build|lint|typecheck|confirm|ensure|expect|should pass|prove)\b/.test(lower)
359 const isBig = /\b(refactor|rewrite|migrate|redesign|overhaul|whole|entire|everything|all the)\b/.test(lower)
360 const hasPlan = /\b(plan|first propose|outline|step by step|before (you )?(change|edit|start))\b/.test(lower)
361
362 if (isBug && !hasEvidence) hints.push('no error pasted')
363 if (!hasTarget) hints.push('no file named')
364 if (isBig && !hasPlan) hints.push('big change: ask for a plan')
365 if (isChange && !hasCheck) hints.push('no way to check')
366
367 return hints.slice(0, 2)
368}
369
370export type DiffPart = { text: string; isAdded: boolean }
371
372// The words of `after` marked new where they are not in `before`, by a
373// longest common subsequence over words; a long pair is not diffed.
374export function diffWords(before: string, after: string): DiffPart[] {
375 const a = before.split(/(\s+)/).filter(x => x !== '')
376 const b = after.split(/(\s+)/).filter(x => x !== '')
377 if (a.length * b.length > 250_000) return [{ text: after, isAdded: false }]
378 const key = (w: string) => w.toLowerCase().replace(/[^\p{L}\p{N}@./_-]/gu, '')
379 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
380 for (let i = a.length - 1; i >= 0; i--) {
381 for (let j = b.length - 1; j >= 0; j--) {
382 lcs[i]![j] =
383 key(a[i]!) === key(b[j]!) && key(a[i]!) !== '' ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
384 }
385 }
386 const parts: DiffPart[] = []
387 const push = (text: string, isAdded: boolean) => {
388 // Whitespace joins whichever run it sits in.
389 const added = /^\s+$/.test(text) ? (parts[parts.length - 1]?.isAdded ?? false) : isAdded
390 const last = parts[parts.length - 1]
391 if (last && last.isAdded === added) last.text += text
392 else parts.push({ text, isAdded: added })
393 }
394 let i = 0
395 let j = 0
396 while (j < b.length) {
397 if (i < a.length && key(a[i]!) === key(b[j]!) && key(a[i]!) !== '') {
398 push(b[j]!, false)
399 i++
400 j++
401 } else if (i < a.length && lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
402 i++
403 } else {
404 push(b[j]!, true)
405 j++
406 }
407 }
408
409 return parts
410}
411
412// Splits text into runs, marking [placeholders] the developer fills in.
413export function placeholders(text: string): { text: string; isPlaceholder: boolean }[] {
414 return text
415 .split(/(\[[^\]\n]{1,60}\])/)
416 .filter(x => x !== '')
417 .map(x => ({ text: x, isPlaceholder: /^\[[^\]]+\]$/.test(x) }))
418}
419
420export function relTime(ms: number): string {
421 const s = Math.max(0, Math.round(ms / 1000))
422 if (s < 60) return 'now'
423 if (s < 3600) return `${Math.round(s / 60)}m`
424 if (s < 86_400) return `${Math.round(s / 3600)}h`
425
426 return `${Math.round(s / 86_400)}d`
427}
428
429const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
430
431export function sessionLabel(start: number, now: number): string {
432 const d = new Date(start)
433 const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
434 const sameDay = new Date(now).toDateString() === d.toDateString()
435
436 return sameDay ? `today ${time}` : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
437}
438types/index.d.ts 83 lines1export type CoachStatus = 'reviewing' | 'done' | 'failed' | 'skipped'
2
3export type HabitId = 'target' | 'goal' | 'evidence' | 'verify' | 'constraints' | 'scope' | 'plan'
4
5export type HabitMark = 'shown' | 'missed'
6
7export type CoachEntry = {
8 id: string
9 at: number
10 prompt: string
11 answer: string
12 // Tool names, one per call, for the counts.
13 tools: string[]
14 // One short line per call with its main argument (`Read: src/a.ts`).
15 steps?: string[]
16 durationMs: number
17 status: CoachStatus
18 // The turn was interrupted by the person.
19 interrupted?: boolean
20 score?: number
21 verdict?: string
22 feedback?: string[]
23 better?: string
24 tip?: string
25 // The habits this prompt showed or missed; a habit that did not apply is absent.
26 habits?: Partial<Record<HabitId, HabitMark>>
27 // Set when the next prompt had to correct this one.
28 followup?: string
29 // The directory the session ran in.
30 project?: string
31 // What the turn cost, all input and output tokens summed.
32 tokens?: number
33 // Standing instructions the prompt gave, each worded for CLAUDE.md.
34 rules?: string[]
35 // The person's rating of this review.
36 rating?: 'up' | 'down'
37 // The one change that would have helped most: the review's headline.
38 nextStep?: string
39 // When the review in flight started, for its elapsed time.
40 reviewStartedAt?: number
41 // The start time of the session the prompt was sent in.
42 session?: number
43 error?: string
44}
45
46// Improve draft's progress, and the draft it replaced, for Undo.
47export type DraftState =
48 | { phase: 'improving' }
49 // `use`: Use it replaced the draft with a review's better prompt.
50 | { phase: 'improved'; original: string; source: 'improve' | 'use' }
51 | { phase: 'failed'; error: string }
52
53declare module 'claude-code' {
54 interface PluginState {
55 'prompt-coach': {
56 entries: CoachEntry[]
57 // The id the pane shows in detail; null follows the newest.
58 selected: string | null
59 view: 'review' | 'habits'
60 // Which prompts the review trail lists.
61 scope: 'project' | 'all'
62 // Repeated instructions the person dismissed, lowercased.
63 dismissedRules: string[]
64 draft: DraftState | null
65 // What the prompt box holds now: whether it has a draft, and the
66 // local checks it fails.
67 box: { hasText: boolean; hints: string[] }
68 // Whether the sidebar is showing.
69 paneOpen: boolean
70 // The line above the prompt, hidden for this session.
71 bandHidden: boolean
72 // The quoted prompt shown whole on a narrow sidebar.
73 expanded: boolean
74 // The first-run card was dismissed.
75 welcomed: boolean
76 // Habits kept three prompts in a row, no longer the focus.
77 graduated: HabitId[]
78 // The time, advanced each second while a review runs, to redraw it.
79 tick: number
80 }
81 }
82}
83