/coach: live cost, cache, context and subagent stats, SDLC habit checks and architect lessons; one tip after a turn when it matters.

What it does: shows the numbers behind your sessions that drive cost and quality, checks your habits against a normal software lifecycle (SDLC), and teaches one architect-level concept at a time, tied to what you just did. The aim is to carry vibe-coding skill into real engineering work.
Where you see it:
/coach toggles the panel: `` ● Cache 92% ━━━━━━━━━━━ reusing context well ● Context 41% ━━━━━━━━━━━ room to spare ● Cost $3.20 $0.27 per turn Habits ✓ Plan ✓ Branch ✗ Tests ✓ Commits · PR Do next Run the tests: 8 edits since the last run Tokens 120k fresh 4.1M cached 300k cache-write 52k out Model claude-opus-5-5 effort high subagents 4 runs · 31% of tokens Lesson The test pyramid Many fast unit tests at the base, fewer integration tests… ``🎓 Coach · … row in the chat:The same tip waits at least 6 turns before repeating.
Glossary:
How it works: everything comes from what the app already reports (token usage per turn, session cost and context, tool calls such as edits, tests, commits and gh pr create). No model calls.
Cost: none.
Data saved: none on disk. Stats live for the session.
hooks/register.tsx 607 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { CoachStats, CoachTokens } from '../types'
5
6// Watches the session's own numbers (token usage per turn, cache reads,
7// context fill, cost, subagents) and its habits (branch, tests, commits, PRs,
8// plans), all from events the app already reports: no model calls. After a
9// turn it adds one tip to the chat only when a rule fires; /coach shows it all.
10
11const PANE = 'coach'
12const COOLDOWN_TURNS = 6
13const stats = atom({ plugin: 'coach', key: 'stats' } as const, null)
14const lesson = atom({ plugin: 'coach', key: 'lesson' } as const, 0)
15const tipIndex = atom({ plugin: 'coach', key: 'tip' } as const, 0)
16const unseen = atom({ plugin: 'coach', key: 'unseen' } as const, false)
17const isOpen = atom({ plugin: 'coach', key: 'isOpen' } as const, false)
18const lastTip = atom({ plugin: 'coach', key: 'lastTip' } as const, null)
19const isMore = atom({ plugin: 'coach', key: 'isMore' } as const, false)
20
21const ZERO: CoachTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
22const TEST_RE = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bvitest\b|\bjest\b|\bpytest\b|playwright\s+test|\bcargo\s+test\b|\bgo\s+test\b|\bdotnet\s+test\b|\bnode\s+\S*tests?[\\/]\S+/i
23const COMMIT_RE = /\bgit\b(\s+-c\s+\S+)*\s+commit\b/i
24const PR_RE = /\bgh\s+pr\s+create\b/i
25const PERF_RE = /qa:perf|qa:scale|lighthouse|\bbench(mark)?\b|\bperf\b/i
26
27type Lesson = { id: string; title: string; idea: string; realWorld: string; tryNext: string }
28
29const LESSONS: Lesson[] = [
30 {
31 id: 'adr',
32 title: 'Architecture Decision Records (ADRs)',
33 idea: 'Write each significant decision down: context, options, the choice, and its consequences. One short page per decision, kept in the repo.',
34 realWorld: 'Teams use docs/adr/0001-*.md so newcomers learn why, not just what. It is a staple question in architect interviews.',
35 tryNext: 'Next time you pick between two approaches (say R2 vs tiles in the repo), ask Claude to draft an ADR before building.',
36 },
37 {
38 id: 'test-pyramid',
39 title: 'The test pyramid',
40 idea: 'Many fast unit tests at the base, fewer integration tests, very few slow end-to-end tests at the top.',
41 realWorld: 'Browser and Playwright checks are the top of the pyramid. Logic like route maths or parsers deserves fast unit tests underneath, so failures point at the exact function.',
42 tryNext: 'Pick one pure function that broke before and ask for unit tests that pin its edge cases.',
43 },
44 {
45 id: 'branching',
46 title: 'Branching strategy',
47 idea: 'Trunk-based development: short-lived branches merged often (behind feature flags if unfinished) instead of long-running ones.',
48 realWorld: 'Protected main + PR + green CI is the default at most companies. Direct commits to main are blocked by branch protection.',
49 tryNext: 'Turn on branch protection for main in GitHub (require PR + passing checks). Your merge guard hook is the local version of this.',
50 },
51 {
52 id: 'code-review',
53 title: 'Code review that scales',
54 idea: 'Small PRs (under ~400 changed lines) with a description of why, what, and how to test get faster, better reviews.',
55 realWorld: 'Reviewers look for design and risk, not typos; linters and tests catch the rest. One concern per PR makes reverts safe.',
56 tryNext: 'Ask Claude to write the PR description as: Problem · Approach · Risks · How to test · Screenshots.',
57 },
58 {
59 id: 'ci-cd',
60 title: 'CI/CD and ephemeral environments',
61 idea: 'CI runs checks on every push; CD ships automatically. A preview deploy per PR is an "ephemeral environment".',
62 realWorld: 'Your Vercel previews are exactly this. The next step teams take is making merge impossible unless CI is green.',
63 tryNext: 'Add a GitHub Actions workflow that runs npm test on every PR and make it a required check.',
64 },
65 {
66 id: 'perf-budget',
67 title: 'Performance budgets',
68 idea: 'Set explicit limits (frame time, bundle size, memory) and fail the build when they are crossed.',
69 realWorld: 'Budgets are measured on a quiet, consistent machine or in CI, never on a busy laptop, or the numbers are noise.',
70 tryNext: 'Write the budget down (e.g. 60 fps at 1080p, < 3 MB JS) and have the perf script compare against it.',
71 },
72 {
73 id: 'cohesion',
74 title: 'Cohesion and coupling',
75 idea: 'A change that touches many files is a hint the boundaries are off: related logic should live together, and modules should depend on each other as little as possible.',
76 realWorld: 'Architects ask "which module owns this decision?" When the answer is "several", that is design debt.',
77 tryNext: 'Ask Claude which module should own the thing you just changed across many files, and whether to consolidate it.',
78 },
79 {
80 id: 'semver',
81 title: 'Semantic versioning and release notes',
82 idea: 'MAJOR.MINOR.PATCH = breaking.feature.fix. A changelog entry and a git tag per release make history auditable.',
83 realWorld: 'Release numbers are claimed when a release branch is cut, not at merge time, which avoids two branches taking the same number.',
84 tryNext: 'Tag releases (git tag v0.11.0) when they merge, so a deploy can always be traced to a commit.',
85 },
86 {
87 id: 'security',
88 title: 'Threat modelling in five minutes',
89 idea: 'For each feature: what are the assets, who could abuse it, what is the worst case, and what control stops it (STRIDE is a handy checklist).',
90 realWorld: 'Secrets live in a secret manager or env vars, tokens get the least privilege they need, and anything from outside is untrusted input.',
91 tryNext: 'Before the next feature with an API or user input, ask Claude for a STRIDE pass and the top 3 risks.',
92 },
93 {
94 id: 'requirements',
95 title: 'Requirements and acceptance criteria',
96 idea: 'Before building: who is it for, what problem it solves, and how you will know it worked.',
97 realWorld: 'Tickets carry acceptance criteria ("Given / When / Then"); a PR is done when they pass, not when the code compiles.',
98 tryNext: 'Start the next prompt with 2-3 acceptance criteria and ask Claude to check them off at the end.',
99 },
100 {
101 id: 'observability',
102 title: 'Observability before fixes',
103 idea: 'Logs, metrics and traces tell you what the system did. Reproduce a bug with a failing test before fixing it.',
104 realWorld: 'On-call engineers fix faster with structured logs and dashboards than with guesses, and a regression test keeps the bug fixed.',
105 tryNext: 'For the next bug, ask Claude to first write a failing test that reproduces it, then fix it.',
106 },
107]
108
109const TRENDS = [
110 'Plan mode for anything bigger than ~30 minutes: review the plan like a design doc before code exists.',
111 'Keep CLAUDE.md short and stable: it is sent with every request, and editing it mid-session breaks the prompt cache.',
112 'Send broad searches to an Explore subagent: the main conversation stays small, so every later turn is cheaper.',
113 'One feature per session and per PR, with a short handoff note; long sessions cost more per turn as context grows.',
114 'Parallel work belongs in separate git worktrees, one branch each, so sessions never edit the same checkout.',
115 'Lower effort for routine edits; save high or max effort for architecture and hard bugs.',
116 'Hooks turn "always do X" into guarantees: a guard you write once beats a reminder you repeat.',
117 'Read the diff before you merge: you own the code, not the model.',
118]
119
120const LINKS = [
121 { label: 'Claude Code docs', href: 'https://docs.claude.com/en/docs/claude-code/overview' },
122 { label: 'Claude Code changelog', href: 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' },
123 { label: 'Anthropic engineering blog', href: 'https://www.anthropic.com/engineering' },
124]
125
126const fromUsage = (u: ModelUsage | undefined): CoachTokens =>
127 u ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens } : ZERO
128
129const plus = (a: CoachTokens, b: CoachTokens): CoachTokens => ({
130 input: a.input + b.input,
131 output: a.output + b.output,
132 cacheRead: a.cacheRead + b.cacheRead,
133 cacheWrite: a.cacheWrite + b.cacheWrite,
134})
135
136const totalIn = (t: CoachTokens) => t.input + t.cacheRead + t.cacheWrite
137const hitPct = (t: CoachTokens) => (totalIn(t) > 0 ? (t.cacheRead / totalIn(t)) * 100 : null)
138const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
139const usd = (n: number | null) => (n === null ? '—' : n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
140const fmt = (ms: number) => {
141 const s = Math.round(ms / 1000)
142 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
143}
144
145const projectOf = (dir: string) => {
146 const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
147 return main.split(/[\\/]/).filter(Boolean).pop() ?? main
148}
149
150let costAtStart: number | null = null
151let modelBefore: string | null = null
152
153const fresh = async ($: EngineInterface): Promise<CoachStats> => ({
154 project: projectOf(await $.session.cwd()),
155 model: await $.session.model(),
156 effort: null,
157 turns: 0,
158 main: ZERO,
159 last: null,
160 sub: { ...ZERO, runs: 0 },
161 contextTokens: null,
162 contextWindow: null,
163 costUsd: null,
164 files: [],
165 edits: 0,
166 editsSinceTest: 0,
167 testsRun: 0,
168 commits: 0,
169 commitsOnMain: 0,
170 prsOpened: 0,
171 branch: null,
172 planUsed: false,
173 perfRuns: 0,
174 tipsShown: {},
175})
176
177const change = async ($: EngineInterface, fn: (s: CoachStats) => CoachStats) => {
178 const base = (await read($, stats)) ?? (await fresh($))
179 await update($, stats, s => fn(s ?? base))
180}
181
182const ready = (s: CoachStats, id: string) => s.turns - (s.tipsShown[id] ?? -99) >= COOLDOWN_TURNS
183
184// At most one tip per turn, the most important rule that fires.
185const pickTip = (s: CoachStats, switched: boolean): { id: string; text: string } | null => {
186 const last = s.last
187 const hit = last ? hitPct(last) : null
188 const ctxPct = s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
189 const subShare = totalIn(s.sub) / Math.max(1, totalIn(s.sub) + totalIn(s.main))
190
191 if (s.commitsOnMain > (s.tipsShown['main-commits'] ?? 0)) {
192 return {
193 id: 'main-commits',
194 text: '🟥 A commit went straight to main. At work, main is protected: branch → PR → review + CI → merge. It also makes a bad change a one-click revert.',
195 }
196 }
197 if (last && s.turns > 1 && hit !== null && totalIn(last) >= 30_000 && hit < 50 && ready(s, 'cache')) {
198 return {
199 id: 'cache',
200 text: switched
201 ? `🟨 Cache hit ${Math.round(hit)}% this turn: expected right after a model switch (each model keeps its own cache). It recovers next turn.`
202 : `🟥 Cache hit only ${Math.round(hit)}% this turn. Cached input costs ~10% of fresh input; misses come from model switches, CLAUDE.md or memory edits mid-session, /compact, or a long idle gap (the cache expires).`,
203 }
204 }
205 if (ctxPct !== null && ctxPct >= 75 && ready(s, 'context')) {
206 return {
207 id: 'context',
208 text: `🟨 Context ${Math.round(ctxPct)}% full (${tok(s.contextTokens!)} of ${tok(s.contextWindow!)}). Every turn re-reads all of it: slower, pricier, and quality dips near the limit. Finish this step, then /compact with a focus or start a fresh session with a handoff.`,
209 }
210 }
211 if (s.editsSinceTest >= 5 && ready(s, 'untested')) {
212 return {
213 id: 'untested',
214 text: `🟨 ${s.editsSinceTest} edits since tests last ran. The pro habit: run the suite (or add a test) before calling a change done; "it looked right" is not evidence.`,
215 }
216 }
217 if (last && last.costUsd !== null && last.costUsd >= 2 && ready(s, 'cost')) {
218 return {
219 id: 'cost',
220 text: `💸 That turn cost about ${usd(last.costUsd)} at API prices (${tok(totalIn(last))} tokens read). Fresh sessions per feature, Explore subagents for searching, and lower effort for routine edits cut this.`,
221 }
222 }
223 if (last && last.ms >= 10 * 60_000 && !s.planUsed && ready(s, 'plan')) {
224 return {
225 id: 'plan',
226 text: `🟦 ${fmt(last.ms)} of building with no written plan. Architects change plans on paper, where it is cheap: try Plan mode (or an ADR) before the next big step.`,
227 }
228 }
229 if (s.sub.runs >= 3 && subShare >= 0.5 && ready(s, 'subagents')) {
230 return {
231 id: 'subagents',
232 text: `ℹ️ Subagents used ${Math.round(subShare * 100)}% of this session's tokens. Great for parallel or broad work; for a single lookup, a direct search is cheaper.`,
233 }
234 }
235 return null
236}
237
238// The single most useful next step, short enough for the dock's hover card.
239const topAction = (s: CoachStats): string => {
240 const ctxPct = s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
241 const hit = s.last ? hitPct(s.last) : null
242 if (s.commitsOnMain > 0) return 'Move this work to a branch and open a PR before merging'
243 if (ctxPct !== null && ctxPct >= 75) return `Context ${Math.round(ctxPct)}% full: compact or start a fresh session soon`
244 if (s.editsSinceTest >= 5) return `Run the tests: ${s.editsSinceTest} edits since the last run`
245 if (s.last && s.turns > 1 && hit !== null && hit < 50 && totalIn(s.last) >= 30_000) return 'Cache misses: avoid switching models or editing CLAUDE.md mid-session'
246 if (s.last && s.last.ms >= 10 * 60_000 && !s.planUsed) return 'Plan the next big step first (Plan mode)'
247 if (s.commits > 0 && s.prsOpened === 0) return 'Open a PR so the change gets reviewed'
248 return 'Nothing urgent: keep going'
249}
250
251const lessonFor = (s: CoachStats | null, offset: number) => {
252 const want =
253 !s ? 'requirements'
254 : s.commitsOnMain > 0 ? 'branching'
255 : s.prsOpened > 0 ? 'code-review'
256 : s.perfRuns > 0 ? 'perf-budget'
257 : s.files.some(f => /package\.json$/.test(f)) ? 'semver'
258 : s.files.some(f => /\.env|secret|token|auth/i.test(f)) ? 'security'
259 : s.files.length >= 10 ? 'cohesion'
260 : s.testsRun > 0 ? 'test-pyramid'
261 : s.planUsed ? 'adr'
262 : 'requirements'
263 const start = Math.max(0, LESSONS.findIndex(l => l.id === want))
264 return LESSONS[(start + offset) % LESSONS.length]!
265}
266
267const LEVEL_HEX = { success: '#4ade80', warning: '#facc15', error: '#f87171' } as const
268
269// A solid rounded bar (track, then fill) as SVG, for surfaces that draw it.
270const svgBar = (fraction: number, width: number, color: string) => {
271 const fill = Math.max(0, Math.min(width, Math.round(fraction * width)))
272 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
273 `<rect y="1.5" width="${width}" height="5" rx="2.5" fill="#3f3f46"/>` +
274 (fill > 0 ? `<rect y="1.5" width="${Math.max(fill, 5)}" height="5" rx="2.5" fill="${color}"/>` : '') +
275 '</svg>'
276}
277
278const tone = (pct: number | null, good: number, ok: number, higherIsBetter: boolean) =>
279 pct === null ? undefined
280 : higherIsBetter ? (pct >= good ? 'success' : pct >= ok ? 'warning' : 'error')
281 : pct <= good ? 'success' : pct <= ok ? 'warning' : 'error'
282
283
284const currentBranch = async ($: EngineInterface) => {
285 const run = await $.process.run(['git', 'branch', '--show-current']).catch(() => null)
286 return run && run.exitCode === 0 ? run.stdout.trim() || null : null
287}
288
289const togglePane = async ($: EngineInterface) => {
290 if ((await $.ui.panes()).some(p => p.id === PANE)) {
291 await $.ui.close({ id: PANE })
292 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
293 await update($, isOpen, () => false)
294 return 'Coach closed.'
295 }
296 const opened = await $.ui.open({ id: PANE, title: 'Coach' })
297 if (opened.isPlaced) {
298 await update($, isOpen, () => true)
299 await update($, unseen, () => false)
300 } else {
301 $.ui.toast(`Coach could not open (${opened.reason}).`)
302 }
303 return opened.isPlaced ? 'Coach opened.' : `Could not open the pane (${opened.reason}).`
304}
305
306export const register: Register = on => {
307 on('session.start', async ($, e, next) => {
308 await $.command.register({ name: 'coach', description: 'Coach: cost, cache, context, habits and an architect lesson for this session' })
309 if ((await read($, stats)) === null) {
310 const s = await fresh($)
311 await update($, stats, cur => cur ?? s)
312 }
313 return next(e)
314 })
315
316 on('classic.UserPromptSubmit', async ($, e, next) => {
317 if (e.permission_mode === 'plan') await change($, s => ({ ...s, planUsed: true }))
318 return next(e)
319 })
320
321 on('classic.PostToolUse', async ($, e, next) => {
322 const level = e.effort?.level
323 if (e.agent_id === undefined && level) {
324 const s = await read($, stats)
325 if (s && s.effort !== level) await change($, cur => ({ ...cur, effort: level }))
326 }
327 return next(e)
328 })
329
330 on('turn.start', async ($, e, next) => {
331 costAtStart = (await $.session.usage()).cost?.usd ?? null
332 modelBefore = (await read($, stats))?.last?.model ?? null
333 return next(e)
334 })
335
336 on(
337 'tool.call',
338 { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell', 'ExitPlanMode', 'mcp__mission-control__plan'] },
339 async ($, e, next) => {
340 const ran = await next(e)
341 if (ran.deny !== undefined || ran.isError === true) return ran
342 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
343 const path = (e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path).replace(/\\/g, '/')
344 const short = path.split('/').slice(-3).join('/')
345 await change($, s => ({
346 ...s,
347 edits: s.edits + 1,
348 editsSinceTest: s.editsSinceTest + 1,
349 files: s.files.includes(short) ? s.files : [...s.files, short].slice(-300),
350 }))
351 } else if (e.tool === 'Bash' || e.tool === 'PowerShell') {
352 const cmd = e.command
353 const isTest = TEST_RE.test(cmd)
354 const isCommit = COMMIT_RE.test(cmd)
355 const branch = isCommit ? await currentBranch($) : null
356 await change($, s => ({
357 ...s,
358 testsRun: s.testsRun + (isTest ? 1 : 0),
359 editsSinceTest: isTest ? 0 : s.editsSinceTest,
360 commits: s.commits + (isCommit ? 1 : 0),
361 commitsOnMain: s.commitsOnMain + (isCommit && (branch === 'main' || branch === 'master') ? 1 : 0),
362 branch: branch ?? s.branch,
363 prsOpened: s.prsOpened + (PR_RE.test(cmd) ? 1 : 0),
364 perfRuns: s.perfRuns + (PERF_RE.test(cmd) ? 1 : 0),
365 }))
366 } else {
367 await change($, s => ({ ...s, planUsed: true }))
368 }
369 return ran
370 },
371 )
372
373 on('turn.complete', async ($, e, next) => {
374 const done = await next(e)
375 const u = fromUsage(e.usage)
376 if (e.agentId !== undefined) {
377 await change($, s => ({ ...s, sub: { ...plus(s.sub, u), runs: s.sub.runs + 1 } }))
378 return done
379 }
380 const usage = await $.session.usage()
381 const cost = usage.cost?.usd ?? null
382 const turnCost = cost !== null && costAtStart !== null ? Math.max(0, cost - costAtStart) : null
383 const model = e.usage?.model ?? (await $.session.model())
384 await change($, s => ({
385 ...s,
386 model,
387 turns: s.turns + 1,
388 main: plus(s.main, u),
389 last: { ...u, ms: e.durationMs, costUsd: turnCost, model },
390 contextTokens: usage.context.tokens ?? s.contextTokens,
391 contextWindow: usage.context.window,
392 costUsd: cost,
393 }))
394 const latest = await read($, stats)
395 if (latest) {
396 const action = topAction(latest)
397 await update($, lastTip, () => action)
398 }
399 if (e.reason !== 'answer') return done
400
401 const s = await read($, stats)
402 const tip = s ? pickTip(s, modelBefore !== null && modelBefore !== model) : null
403 if (s && tip) {
404 await change($, cur => ({
405 ...cur,
406 tipsShown: { ...cur.tipsShown, [tip.id]: tip.id === 'main-commits' ? cur.commitsOnMain : cur.turns },
407 }))
408 if (!(await read($, isOpen))) await update($, unseen, () => true)
409 void $.session
410 .append({ message: { type: 'system', content: [{ type: 'text', text: `🎓 Coach · ${tip.text}` }] } })
411 .catch(() => $.ui.log(`🎓 Coach · ${tip.text}`))
412 }
413 return done
414 })
415
416 on('command.run', { command: 'coach' }, async $ => ({ text: await togglePane($) }))
417
418 // A press on the dock's Coach button.
419 on('state.set', { plugin: 'mod-hub', key: 'signal' }, async ($, e, next) => {
420 const done = await next(e)
421 // Only a write that landed: update() retries a missed one, which would toggle twice.
422 const sig = e.value as { target?: string; action?: string } | null
423 if (done.value?.isSet !== true || !sig) return done
424 if (sig.target === 'coach') await togglePane($)
425 // Review: show the panel, never close it.
426 else if (sig.action === 'review' && !(await $.ui.panes()).some(p => p.id === PANE)) await togglePane($)
427 return done
428 })
429
430 on('ui.close', { id: PANE }, async ($, e, next) => {
431 const done = await next(e)
432 await update($, isOpen, () => false)
433 return done
434 })
435
436 // One glance: three colored metric rows, one habits row, one "Do next".
437 // Everything else (tokens, subagents, lesson, pro tip, links) sits behind More.
438 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
439 const elements = $.ui.resolve(e)
440 const { Box, Text, Button, Link } = elements
441 const SvgEl = 'Svg' in elements && e.surface !== 'terminal' ? elements.Svg : undefined
442 const s = await read($, stats)
443 const isMoreOpen = await read($, isMore)
444 const offset = await read($, lesson)
445 const l = lessonFor(s, offset)
446 const trend = TRENDS[(await read($, tipIndex)) % TRENDS.length]!
447
448 const hit = s?.last ? hitPct(s.last) : null
449 const ctxPct = s && s.contextTokens !== null && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : null
450 const subShare = s ? totalIn(s.sub) / Math.max(1, totalIn(s.sub) + totalIn(s.main)) : 0
451 const perTurn = s && s.turns > 0 && s.costUsd !== null ? s.costUsd / s.turns : null
452
453 const metric = (key: string, label: string, value: string, level: string | undefined, fill: number | null, verdict: string) => (
454 <Box key={`m:${key}`} flexDirection="row" gap={1}>
455 <Text color={level}>●</Text>
456 <Box width={8}>
457 <Text bold>{label}</Text>
458 </Box>
459 <Box width={6}>
460 <Text bold color={level}>
461 {value}
462 </Text>
463 </Box>
464 {fill !== null && SvgEl && (
465 <SvgEl source={svgBar(fill / 100, 84, LEVEL_HEX[level as keyof typeof LEVEL_HEX] ?? '#9ca3af')} alt={`${Math.round(fill)}%`} width={84} height={8} />
466 )}
467 {fill !== null && !SvgEl && (
468 <Box flexDirection="row">
469 <Text color={level}>{'━'.repeat(Math.round((fill / 100) * 12))}</Text>
470 <Text color="#3f3f46">{'━'.repeat(12 - Math.round((fill / 100) * 12))}</Text>
471 </Box>
472 )}
473 <Text dimColor wrap="truncate-end">
474 {verdict}
475 </Text>
476 </Box>
477 )
478 const habit = (label: string, ok: boolean | null) => (
479 <Text color={ok === null ? undefined : ok ? 'success' : 'error'} dimColor={ok === null}>
480 {ok === null ? '·' : ok ? '✓' : '✗'} {label}
481 </Text>
482 )
483
484 return (
485 <Box flexDirection="column" gap={1}>
486 <Box flexDirection="row" gap={1}>
487 <Text bold>Coach</Text>
488 <Text dimColor>{s ? `${s.project} · ${s.turns} turns` : 'after the first reply'}</Text>
489 <Box flexGrow={1} />
490 <Button key="more" label={isMoreOpen ? 'Less' : 'More'} plain onPress={() => update($, isMore, v => !v)} />
491 <Button key="close" label="✕" plain onPress={() => togglePane($)} />
492 </Box>
493
494 <Box flexDirection="column">
495 {metric(
496 'cache',
497 'Cache',
498 hit === null ? '—' : `${Math.round(hit)}%`,
499 tone(hit, 80, 50, true),
500 hit,
501 hit === null ? 'shows after the first reply' : hit >= 80 ? 'reusing context well' : hit >= 50 ? 'partly re-read' : 'mostly re-read (costly)',
502 )}
503 {metric(
504 'context',
505 'Context',
506 ctxPct === null ? '—' : `${Math.round(ctxPct)}%`,
507 tone(ctxPct, 50, 75, false),
508 ctxPct,
509 ctxPct === null ? '' : ctxPct <= 50 ? 'room to spare' : ctxPct <= 75 ? 'getting full' : 'near the limit: compact soon',
510 )}
511 {metric('cost', 'Cost', usd(s?.costUsd ?? null), perTurn !== null && perTurn >= 1 ? 'warning' : undefined, null, perTurn === null ? '' : `${usd(perTurn)} per turn`)}
512 </Box>
513
514 <Box flexDirection="row" gap={2}>
515 <Box width={8}>
516 <Text bold>Habits</Text>
517 </Box>
518 {habit('Plan', s ? s.planUsed || null : null)}
519 {habit('Branch', s && s.commits > 0 ? s.commitsOnMain === 0 : null)}
520 {habit('Tests', s && s.edits > 0 ? s.testsRun > 0 && s.editsSinceTest < 5 : null)}
521 {habit('Commits', s && s.edits > 0 ? s.commits > 0 : null)}
522 {habit('PR', s && s.commits > 0 ? s.prsOpened > 0 : null)}
523 </Box>
524
525 <Box flexDirection="row" gap={1}>
526 <Box width={8}>
527 <Text bold color="suggestion">
528 Do next
529 </Text>
530 </Box>
531 <Text wrap="wrap">{s ? topAction(s) : 'Nothing yet'}</Text>
532 </Box>
533
534 <Box flexDirection="column">
535 {s && (
536 <Box flexDirection="row" gap={1}>
537 <Box width={8}>
538 <Text bold>Tokens</Text>
539 </Box>
540 <Text>{tok(s.main.input)} fresh</Text>
541 <Text color="success">{tok(s.main.cacheRead)} cached</Text>
542 <Text color="warning">{tok(s.main.cacheWrite)} cache-write</Text>
543 <Text color="#22d3ee">{tok(s.main.output)} out</Text>
544 </Box>
545 )}
546 {s && (
547 <Box flexDirection="row" gap={1}>
548 <Box width={8}>
549 <Text bold>Model</Text>
550 </Box>
551 <Text color="#a78bfa">{s.model}</Text>
552 {s.effort && <Text dimColor>effort {s.effort}</Text>}
553 {s.sub.runs > 0 && (
554 <Text color={subShare >= 0.5 ? 'warning' : undefined} dimColor={subShare < 0.5}>
555 subagents {s.sub.runs} runs · {Math.round(subShare * 100)}% of tokens
556 </Text>
557 )}
558 </Box>
559 )}
560 <Box flexDirection="row" gap={1}>
561 <Box width={8}>
562 <Text bold color="claude">
563 Lesson
564 </Text>
565 </Box>
566 <Text bold>{l.title}</Text>
567 </Box>
568 <Box flexDirection="row" gap={1}>
569 <Box width={8}>
570 <Text> </Text>
571 </Box>
572 <Text dimColor wrap="wrap">
573 {l.idea}
574 </Text>
575 </Box>
576 </Box>
577
578 {isMoreOpen && (
579 <Box flexDirection="column" gap={1}>
580 <Box flexDirection="column">
581 <Box flexDirection="row" gap={1}>
582 <Text color="suggestion">Try: {l.tryNext}</Text>
583 </Box>
584 <Text dimColor>At work: {l.realWorld}</Text>
585 <Button key="another" label="Another lesson" plain onPress={() => update($, lesson, n => n + 1)} />
586 </Box>
587 <Box flexDirection="column">
588 <Box flexDirection="row" gap={1}>
589 <Text bold color="claude">
590 Pro tip
591 </Text>
592 <Button key="tip" label="Next tip" plain onPress={() => update($, tipIndex, n => n + 1)} />
593 </Box>
594 <Text>{trend}</Text>
595 <Box flexDirection="row" gap={2}>
596 {LINKS.map(link => (
597 <Link href={link.href} label={link.label} />
598 ))}
599 </Box>
600 </Box>
601 </Box>
602 )}
603 </Box>
604 )
605 })
606}
607types/index.d.ts 32 lines1export type CoachTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
2
3export type CoachStats = {
4 project: string
5 model: string
6 effort: string | null
7 turns: number
8 main: CoachTokens
9 last: (CoachTokens & { ms: number; costUsd: number | null; model: string }) | null
10 sub: CoachTokens & { runs: number }
11 contextTokens: number | null
12 contextWindow: number | null
13 costUsd: number | null
14 files: string[]
15 edits: number
16 editsSinceTest: number
17 testsRun: number
18 commits: number
19 commitsOnMain: number
20 prsOpened: number
21 branch: string | null
22 planUsed: boolean
23 perfRuns: number
24 tipsShown: { [id: string]: number }
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 coach: { stats: CoachStats | null; lesson: number; tip: number; unseen: boolean; isOpen: boolean; lastTip: string | null; isMore: boolean }
30 }
31}
32