Learn while Claude codes. After a turn that changed code, a quick question about that exact change appears above the prompt; answer with /a 1-4, see your weak…

Learn while Claude codes. A Claude Code mod.

A real, unedited session: Claude adds a cache, teach-me asks why (in Hinglish here; English is a setting), /a 1 answers it.
After a turn that changed code, a small model (Haiku) writes one multiple-choice question about that exact change: the idea behind it, not trivia. It sits above the prompt until you answer. Scores are kept per concept, so /teach stats tells you what to revise.
Built for the way a lot of us code now: the agent writes it, you still have to understand it.
/a 1-4 answer, /a skip see the answer/teach stats your weak spots, /teach now a question about the last change/teach on, /teach off/plugin configure teach-me@nerfsaksham-teach-me: quiz language (Hinglish or English), quiz model (default haiku), minutes between questions (default 4, so it never nags).
Its own store, and one Haiku call per question. Nothing else.
Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.
claude plugin marketplace add saksham10arora-dotcom/claude-teach-me
claude plugin install teach-me@nerfsaksham-teach-me
Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.
Or try it without installing:
git clone https://github.com/saksham10arora-dotcom/claude-teach-me && claude --plugin-dir claude-teach-me
claude plugin validate .
claude plugin test .
Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.
MIT. Made by Saksham Arora (@nerfsaksham).
hooks/register.ts 229 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// teach-me: Claude writes the code, you learn what it did.
4// Edits made during a turn are collected; when the turn ends (and the cooldown
5// allows) a small model writes one multiple-choice question about that exact
6// change. It sits above the prompt until you answer with /a 1-4 or /a skip.
7// Scores are kept per concept, so /teach stats shows what to revise.
8
9const CORAL = '#e8603c'
10const CREAM = '#f3eee6'
11const MUTED = '#8a8f98'
12const GREEN = '#3ecf8e'
13const RED = '#ef5a5a'
14
15type Change = { path: string; before: string; after: string }
16type Quiz = { concept: string; question: string; choices: string[]; answer: number; why: string; path: string }
17type Score = { right: number; wrong: number }
18type Verdict = { isCorrect: boolean; text: string } | null
19
20const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
21const CLIP = 1600 // characters of each side of a change sent to the model
22
23// Module state for this session (a hot reload starts it over).
24let changes: Change[] = []
25let pending: Quiz | null = null
26let lastVerdict: Verdict = null
27let lastQuizAt = 0
28let isMaking = false
29
30const clip = (s: string) => (s.length > CLIP ? `${s.slice(0, CLIP)}\n...(${s.length - CLIP} more characters)` : s)
31const str = (v: unknown) => (typeof v === 'string' ? v : '')
32
33function changeFrom(e: Record<string, unknown>): Change | null {
34 const path = str(e.file_path) || str(e.notebook_path)
35 if (!path) return null
36 if (Array.isArray(e.edits)) {
37 const edits = e.edits as { old_string?: unknown; new_string?: unknown }[]
38 return { path, before: clip(edits.map(x => str(x.old_string)).join('\n...\n')), after: clip(edits.map(x => str(x.new_string)).join('\n...\n')) }
39 }
40 if (typeof e.content === 'string') return { path, before: '', after: clip(e.content) }
41 return { path, before: clip(str(e.old_string)), after: clip(str(e.new_string) || str(e.new_source)) }
42}
43
44function parseQuiz(text: string, path: string): Quiz | null {
45 const m = text.match(/\{[\s\S]*\}/)
46 if (!m) return null
47 try {
48 const j = JSON.parse(m[0]) as Partial<Quiz>
49 const choices = Array.isArray(j.choices) ? j.choices.map(c => String(c)).slice(0, 4) : []
50 const answer = Number(j.answer)
51 if (!j.question || choices.length !== 4 || !(answer >= 1 && answer <= 4)) return null
52 return { concept: String(j.concept || 'this change').slice(0, 40), question: String(j.question), choices, answer, why: String(j.why || ''), path }
53 } catch {
54 return null
55 }
56}
57
58async function makeQuiz($: EngineInterface, batch: Change[], model: string, language: string) {
59 isMaking = true
60 try {
61 const shown = batch.slice(-3)
62 const diff = shown
63 .map(c => `FILE ${c.path}\n${c.before ? `--- before\n${c.before}\n` : ''}+++ after\n${c.after}`)
64 .join('\n\n')
65 const r = await $.model.complete({
66 model,
67 effort: 'low',
68 maxTokens: 700,
69 timeoutMs: 25000,
70 system:
71 'You write one quiz question that teaches a student developer what an AI coding agent just changed. ' +
72 'The student did not write this code and is learning to understand and judge it.',
73 prompt:
74 'Here is the change the agent just made:\n\n' +
75 diff +
76 '\n\nWrite ONE multiple-choice question about WHY this change was made or what it does: the idea behind it ' +
77 '(a design choice, a concept, a bug it fixes, a trade-off), not trivia like variable names or line numbers. ' +
78 'Exactly four choices, one clearly right, three plausible but wrong. Keep the question under 25 words and each choice under 14 words. ' +
79 `Write the question, the choices and "why" in ${language === 'Hinglish' ? 'casual Hinglish (Hindi in Latin script mixed with English tech terms)' : 'plain English'}; "why" is 2 short sentences. ` +
80 'Reply with JSON only: {"concept": "2-4 words, e.g. caching, race condition", "question": "...", "choices": ["...","...","...","..."], "answer": 1-4, "why": "..."}',
81 })
82 if (!r.isAnswered) return
83 const quiz = parseQuiz(r.text ?? '', shown[shown.length - 1].path)
84 if (!quiz) return
85 pending = quiz
86 lastVerdict = null
87 lastQuizAt = await $.clock.now()
88 $.ui.invalidate('ui.render')
89 } finally {
90 isMaking = false
91 }
92}
93
94async function record($: EngineInterface, concept: string, isCorrect: boolean) {
95 const raw = (await $.store.get('scores')) as Record<string, Score> | undefined
96 const scores: Record<string, Score> = raw && typeof raw === 'object' ? raw : {}
97 const s = scores[concept] ?? { right: 0, wrong: 0 }
98 scores[concept] = isCorrect ? { ...s, right: s.right + 1 } : { ...s, wrong: s.wrong + 1 }
99 await $.store.set('scores', scores)
100 const streak = Number(await $.store.get('streak')) || 0
101 await $.store.set('streak', isCorrect ? streak + 1 : 0)
102}
103
104async function stats($: EngineInterface) {
105 const raw = (await $.store.get('scores')) as Record<string, Score> | undefined
106 const rows = Object.entries(raw && typeof raw === 'object' ? raw : {})
107 if (rows.length === 0) return 'No answers yet. Questions appear after Claude changes code.'
108 const right = rows.reduce((n, [, s]) => n + s.right, 0)
109 const total = rows.reduce((n, [, s]) => n + s.right + s.wrong, 0)
110 const weak = rows
111 .filter(([, s]) => s.wrong > 0)
112 .sort((a, b) => b[1].wrong / (b[1].right + b[1].wrong) - a[1].wrong / (a[1].right + a[1].wrong) || b[1].wrong - a[1].wrong)
113 .slice(0, 5)
114 .map(([c, s]) => ` ${c}: ${s.right}/${s.right + s.wrong}`)
115 const strong = rows
116 .filter(([, s]) => s.wrong === 0 && s.right >= 2)
117 .map(([c]) => c)
118 .slice(0, 6)
119 const streak = Number(await $.store.get('streak')) || 0
120 return (
121 `${right}/${total} right (${Math.round((right / total) * 100)}%), streak ${streak}.\n` +
122 (weak.length ? `Revise these:\n${weak.join('\n')}\n` : 'Nothing weak yet.\n') +
123 (strong.length ? `Solid: ${strong.join(', ')}` : '')
124 )
125}
126
127export const register: Register = (on, options) => {
128 const language = String(options.language || 'Hinglish')
129 const model = String(options.quizModel || 'haiku')
130 const cooldownMs = Math.max(0, Number(options.cooldownMinutes ?? 4)) * 60_000
131 let enabled = true
132
133 on('session.start', async ($, e, next) => {
134 const r = await next(e)
135 enabled = (await $.store.get('enabled')) !== false
136 await $.command.register({ name: 'a', description: 'teach-me: answer the question above the prompt (1-4, or skip)', argumentHint: '<1-4 | skip>', immediate: true })
137 await $.command.register({ name: 'teach', description: 'teach-me: on, off, stats, or now (a question about the last change)', argumentHint: '[on | off | stats | now]', immediate: true })
138 return r
139 })
140
141 on('turn.start', async ($, e, next) => {
142 if (!e.agentId) {
143 changes = []
144 if (lastVerdict) {
145 lastVerdict = null // the result line stays until the next turn starts
146 $.ui.invalidate('ui.render')
147 }
148 }
149 return next(e)
150 })
151
152 on('tool.call', async ($, e, next) => {
153 const ran = await next(e)
154 if (!EDIT_TOOLS.has(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
155 const c = changeFrom(e as unknown as Record<string, unknown>)
156 if (c) changes.push(c)
157 return ran
158 })
159
160 on('turn.complete', async ($, e, next) => {
161 const r = await next(e)
162 if (e.agentId || e.isAborted || !enabled || pending || isMaking || changes.length === 0) return r
163 if ((await $.clock.now()) - lastQuizAt < cooldownMs) return r
164 void makeQuiz($, changes.slice(), model, language)
165 return r
166 })
167
168 on('command.run', { command: 'a' }, async ($, e) => {
169 const arg = String(e.args ?? '').trim().toLowerCase()
170 if (!pending) return { text: 'No question waiting. One appears after Claude changes code.' }
171 const quiz = pending
172 if (arg === 'skip') {
173 pending = null
174 $.ui.invalidate('ui.render')
175 return { text: `Skipped. The answer was ${quiz.answer}) ${quiz.choices[quiz.answer - 1]}. ${quiz.why}` }
176 }
177 const n = Number(arg)
178 if (!(n >= 1 && n <= 4)) return { text: 'Answer with /a 1, /a 2, /a 3 or /a 4 (or /a skip).' }
179 const isCorrect = n === quiz.answer
180 await record($, quiz.concept, isCorrect)
181 pending = null
182 const text = isCorrect
183 ? `Sahi! ${quiz.why}`
184 : `Not quite: it was ${quiz.answer}) ${quiz.choices[quiz.answer - 1]}. ${quiz.why}`
185 lastVerdict = { isCorrect, text }
186 $.ui.invalidate('ui.render')
187 return { text: `${isCorrect ? '✓' : '✗'} ${text}` }
188 })
189
190 on('command.run', { command: 'teach' }, async ($, e) => {
191 const arg = String(e.args ?? '').trim().toLowerCase()
192 if (arg === 'on' || arg === 'off') {
193 enabled = arg === 'on'
194 await $.store.set('enabled', enabled)
195 if (!enabled) pending = null
196 $.ui.invalidate('ui.render')
197 return { text: enabled ? 'teach-me is on: a question appears after turns that change code.' : 'teach-me is off.' }
198 }
199 if (arg === 'stats') return { text: await stats($) }
200 if (arg === 'now') {
201 if (changes.length === 0) return { text: 'No changes in the last turn to ask about.' }
202 if (isMaking) return { text: 'Already writing one.' }
203 void makeQuiz($, changes.slice(), model, language)
204 return { text: 'Writing a question about the last change...' }
205 }
206 return { text: `teach-me is ${enabled ? 'on' : 'off'}. ${await stats($)}\nTry /teach on, off, stats or now.` }
207 })
208
209 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
210 const original = await next(e)
211 if (!enabled || (!pending && !lastVerdict)) return original
212 const { Box, Text } = $.ui.resolve(e)
213 const rows: ReturnType<typeof Text>[] = []
214 if (pending) {
215 const q = pending
216 rows.push(
217 Text({ wrap: 'truncate', children: [Text({ color: CORAL, bold: true, children: '◆ teach-me ' }), Text({ color: MUTED, children: `${q.concept} · ${q.path.split('/').pop()}` })] }),
218 Text({ color: CREAM, bold: true, children: q.question }),
219 ...q.choices.map((c, i) => Text({ color: CREAM, wrap: 'truncate', children: ` ${i + 1}) ${c}` })),
220 Text({ color: MUTED, children: ' answer with /a 1-4, or /a skip' }),
221 )
222 } else if (lastVerdict) {
223 rows.push(Text({ color: lastVerdict.isCorrect ? GREEN : RED, children: `◆ teach-me ${lastVerdict.isCorrect ? '✓' : '✗'} ${lastVerdict.text}` }))
224 }
225 const band = Box({ flexDirection: 'column', marginTop: 1, children: rows })
226 return original ? Box({ flexDirection: 'column', children: [original, band] }) : band
227 })
228}
229