Practise English by talking to Claude in English only: Chinese glosses on hard words, Chinese-to-English drafts, and a check of your English before you send.

Practise English by working with Claude in English only. Made for Chinese-speaking learners (for example, aiming for IELTS 7.5).
在 Claude Code 里只用英语和 Claude 交流,练英语。为中文母语的学习者设计。
idempotent(幂等的). Claude's stored reply, which it reads back later, stays exactly as it wrote it. Code, links and URLs are never glossed.EN A1─A2─[B1]─B2─C1★─C2 (in the desktop app, where the footer is short, only the stretch from your level to your goal: EN [B1]─B2─C1★).fix this bug → track down this bug.优化 → optimize. Start a prompt with zh: to send Chinese as typed.The checks, drafts and glosses use a small, fast model (Haiku): one call per message you send, and one per reply block Claude writes (at most 6 per turn).
At the prompt of a Claude Code terminal session:
/plugin install english-coach --marketplace chinayuren2022-2025/claude-english-coach
Answer y to add the marketplace, then choose a scope (user scope loads it in every session).
From a shell:
claude plugin install english-coach --marketplace chinayuren2022-2025/claude-english-coach
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with chinayuren2022-2025/claude-english-coach, then Add plugin.
| Command | What it does | |||
|---|---|---|---|---|
/english | Show settings and help | |||
/english on / off | Turn the mode on or off | |||
/english level auto | Measure your level from your writing (default) | |||
| `/english level A2\ | B1\ | B2\ | C1` | Fix the level of Claude's English |
/english goal IELTS 7.5 | Set your target | |||
| `/english gloss on\ | off` | Chinese glosses after hard words | ||
| `/english feedback before\ | after\ | off` | Check before sending (default), feedback after sending, or none | |
| `/english chinese review\ | auto\ | off` | Chinese input: draft to check (default), translate and send, or send as typed | |
/english demo | Show a sample feedback card | |||
/en <text> | Turn Chinese or rough English into an English draft in the box |
Settings are kept across sessions.
claude --plugin-dir . loads it from this folder; claude plugin test . runs the tests.MIT
hooks/register.tsx 728 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, RenderChildren } from 'claude-code'
3
4import type { Feedback, Gloss, LevelView, Phrase, ReplyGlosses } from '../types'
5
6const feedback = atom({ plugin: 'english-coach', key: 'feedback' } as const, null)
7const levelView = atom({ plugin: 'english-coach', key: 'level' } as const, null)
8const replyGlosses = atom({ plugin: 'english-coach', key: 'glosses' } as const, {} as ReplyGlosses)
9
10// Glosses per reply: at most this many replies kept, this many new blocks glossed per turn.
11const GLOSSED_KEPT = 100
12const BLOCKS_PER_TURN = 6
13const GLOSSES_PER_BLOCK = 6
14
15const LEVELS = ['A2', 'B1', 'B2', 'C1'] as const
16type Level = (typeof LEVELS)[number]
17const SCALE = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] as const
18type ChineseMode = 'review' | 'auto' | 'off'
19type CheckWhen = 'before' | 'after' | 'off'
20
21type Settings = {
22 isOn: boolean
23 level: Level | 'auto'
24 goal: string
25 gloss: boolean
26 chinese: ChineseMode
27 checkWhen: CheckWhen
28}
29
30const DEFAULTS: Settings = { isOn: true, level: 'auto', goal: 'IELTS 7.5', gloss: true, chinese: 'review', checkWhen: 'before' }
31
32// Prefix that sends a prompt as typed, Chinese included.
33const RAW_PREFIX = 'zh:'
34
35// How long a check before sending may hold a message up; past it, the message is sent.
36const HOLD_TIMEOUT_MS = 10_000
37
38// How long a card stays once nothing waits on it: a checked message, an English draft.
39const HIDE_CHECKED_MS = 8_000
40const HIDE_DRAFT_MS = 20_000
41
42// Auto level: the median of the last estimates the feedback check made.
43const ESTIMATES_KEPT = 20
44const ESTIMATES_NEEDED = 3
45// A message shorter than this says too little about a level to count.
46const WORDS_TO_ESTIMATE = 8
47
48const loadSettings = async ($: EngineInterface): Promise<Settings> => {
49 const saved = (await $.store.get('settings')) as Partial<Settings> | undefined
50 return { ...DEFAULTS, ...(saved ?? {}) }
51}
52
53const loadEstimates = async ($: EngineInterface) => ((await $.store.get('estimates')) as string[] | undefined) ?? []
54
55const addEstimate = async ($: EngineInterface, cefr: string) => {
56 if (!(SCALE as readonly string[]).includes(cefr)) return
57 await $.store.set('estimates', [...(await loadEstimates($)), cefr].slice(-ESTIMATES_KEPT))
58}
59
60const currentLevel = async ($: EngineInterface, s: Settings): Promise<Level> => {
61 if (s.level !== 'auto') return s.level
62 const ranks = (await loadEstimates($)).map(c => SCALE.indexOf(c as never)).sort((a, b) => a - b)
63 if (ranks.length < ESTIMATES_NEEDED) return 'B1'
64 const median = SCALE[ranks[Math.floor(ranks.length / 2)] ?? 2] ?? 'B1'
65 return median === 'A1' ? 'A2' : median === 'C2' ? 'C1' : median
66}
67
68// "IELTS 7.5" → C1, "C1" → C1; null for a goal that names neither.
69const goalLevel = (goal: string): string | null => {
70 const cefr = goal.toUpperCase().match(/\b([ABC][12])\b/)
71 if (cefr?.[1]) return cefr[1]
72 const ielts = goal.match(/ielts\s*(\d+(?:\.\d)?)/i)
73 if (!ielts?.[1]) return null
74 const band = Number(ielts[1])
75 return band >= 8.5 ? 'C2' : band >= 7 ? 'C1' : band >= 5.5 ? 'B2' : band >= 4 ? 'B1' : 'A2'
76}
77
78const publishLevel = async ($: EngineInterface, s: Settings) => {
79 const samples = (await loadEstimates($)).length
80 const view: LevelView = {
81 isOn: s.isOn,
82 level: await currentLevel($, s),
83 isMeasuring: s.level === 'auto' && samples < ESTIMATES_NEEDED,
84 samples: Math.min(samples, ESTIMATES_NEEDED),
85 needed: ESTIMATES_NEEDED,
86 goal: s.goal,
87 goalLevel: goalLevel(s.goal),
88 }
89 await update($, levelView, () => view)
90}
91
92const levelLabel = async ($: EngineInterface, s: Settings) => {
93 const level = await currentLevel($, s)
94 if (s.level !== 'auto') return level
95 const n = (await loadEstimates($)).length
96 return n < ESTIMATES_NEEDED ? `${level}? (measuring)` : `~${level}`
97}
98
99const saveSettings = async ($: EngineInterface, s: Settings) => {
100 await $.store.set('settings', s)
101 await publishLevel($, s)
102}
103
104const isPerson = (origin: PromptOrigin) =>
105 origin.kind === 'composer' ||
106 origin.kind === 'bridge' ||
107 (origin.kind === 'plugin' && origin.asUser === true && origin.name !== 'english-coach')
108
109// Placeholders the box puts in for pasted images and long pastes.
110const PLACEHOLDERS = /\[(?:Image|Pasted text) #\d+[^\]]*\]/g
111
112// Code, inline code, URLs, paths and placeholders are not the person's prose.
113const prose = (text: string) =>
114 text
115 .replace(/```[\s\S]*?```/g, ' ')
116 .replace(/`[^`]*`/g, ' ')
117 .replace(PLACEHOLDERS, ' ')
118 .replace(/https?:\/\/\S+/g, ' ')
119 .replace(/\S*[\/\\]\S*/g, ' ')
120
121const hasChinese = (text: string) => /[㐀-鿿]/.test(prose(text))
122
123const wordCount = (text: string) => (prose(text).match(/[A-Za-z]+(?:'[a-z]+)?/g) ?? []).length
124
125const isCheckable = (text: string) => wordCount(text) >= 3 && text.length <= 800
126
127type Piece = { text: string; kind: 'same' | 'del' | 'add' }
128
129const tokenize = (text: string) => text.match(/[\p{L}\p{N}'’-]+|[^\s\p{L}\p{N}]/gu) ?? []
130
131// Word diff of the person's text against the better one, by longest common subsequence.
132export const diffWords = (from: string, to: string): Piece[] => {
133 const a = tokenize(from)
134 const b = tokenize(to)
135 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
136 for (let i = a.length - 1; i >= 0; i--)
137 for (let j = b.length - 1; j >= 0; j--)
138 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
139
140 const ops: { tok: string; kind: Piece['kind'] }[] = []
141 let i = 0
142 let j = 0
143 while (i < a.length || j < b.length) {
144 if (i < a.length && j < b.length && a[i] === b[j]) ops.push({ tok: a[i++]!, kind: 'same' }), j++
145 else if (j < b.length && (i === a.length || lcs[i]![j + 1]! >= lcs[i + 1]![j]!)) ops.push({ tok: b[j++]!, kind: 'add' })
146 else ops.push({ tok: a[i++]!, kind: 'del' })
147 }
148
149 // Each run of changes is drawn whole: what goes first, then what replaces it.
150 const ordered: typeof ops = []
151 let run: typeof ops = []
152 const flush = () => {
153 ordered.push(...run.filter(o => o.kind === 'del'), ...run.filter(o => o.kind === 'add'))
154 run = []
155 }
156 for (const o of ops) {
157 if (o.kind === 'same') flush(), ordered.push(o)
158 else run.push(o)
159 }
160 flush()
161
162 const pieces: Piece[] = []
163 ordered.forEach(({ tok, kind }, k) => {
164 const text = k === 0 || /^[,.!?;:)\]}%’]/.test(tok) ? tok : ` ${tok}`
165 const last = pieces.at(-1)
166 if (last?.kind === kind) last.text += text
167 else pieces.push({ text, kind })
168 })
169 return pieces
170}
171
172const parseJson = (text: string): Record<string, unknown> | null => {
173 const start = text.indexOf('{')
174 const end = text.lastIndexOf('}')
175 if (start < 0 || end <= start) return null
176 try {
177 return JSON.parse(text.slice(start, end + 1))
178 } catch {
179 return null
180 }
181}
182
183const ask = async ($: EngineInterface, system: string, prompt: string, timeoutMs = 20_000) => {
184 const r = await $.model.complete({ model: 'haiku', system, prompt, effort: 'low', maxTokens: 800, timeoutMs })
185 return r.isAnswered ? parseJson(r.text) : null
186}
187
188const translate = async ($: EngineInterface, text: string, level: Level) => {
189 const json = await ask(
190 $,
191 `You turn a learner's message, written in Chinese or mixed Chinese and English, into the English they meant to write. ` +
192 `Write natural, plain English a CEFR ${level} learner can read. Keep code, commands, identifiers, file paths, URLs and quoted text exactly as they are. ` +
193 `Keep the intent and tone; do not answer the message. ` +
194 `Reply with JSON only: {"english": "<the message in English>", "phrases": [{"zh": "<Chinese phrase from the message>", "en": "<its English in your version>"}]} ` +
195 `with up to 4 phrases worth learning.`,
196 text,
197 )
198 if (!json || typeof json.english !== 'string' || !json.english.trim()) return null
199 const phrases: Phrase[] = Array.isArray(json.phrases)
200 ? json.phrases
201 .filter((p: any) => typeof p?.zh === 'string' && typeof p?.en === 'string')
202 .slice(0, 4)
203 .map((p: any) => ({ zh: p.zh, en: p.en }))
204 : []
205 return { english: json.english.trim(), phrases }
206}
207
208// The end of Claude's last reply: what the person's message answers.
209const lastReply = async ($: EngineInterface) => {
210 try {
211 const messages = await $.session.messages()
212 const reply = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')
213 return reply?.text.slice(-1500) ?? ''
214 } catch {
215 return ''
216 }
217}
218
219// Words that say who does what: a fix must not change them.
220const PERSONS = /\b(i|me|my|mine|you|your|yours|we|us|our|ours|he|him|his|she|her|they|them|their)\b/gi
221const persons = (t: string) => (t.match(PERSONS) ?? []).map(w => w.toLowerCase()).join(' ')
222// What a fix must change beyond casing and punctuation; an apostrophe is spelling (dont → don't).
223const bare = (t: string) => t.toLowerCase().replace(/’/g, "'").replace(/[\s.,!?;:"]/g, '')
224
225export type Fix = { wrong: string; right: string; tip: string }
226
227// The model's fixes the mod trusts: each a real error, quoted exactly, changing more
228// than casing or punctuation and never who does what. Applied left to right, no overlaps.
229export const applyFixes = (text: string, raw: unknown) => {
230 const str = (v: unknown) => (typeof v === 'string' ? v : '')
231 const candidates = (Array.isArray(raw) ? raw : [])
232 .map((f: any) => ({ wrong: str(f?.wrong), right: str(f?.right), tip: str(f?.tip).trim(), type: str(f?.type).toLowerCase() }))
233 .filter(f => f.wrong.trim() !== '' && f.type !== 'style' && text.includes(f.wrong))
234 .filter(f => bare(f.wrong) !== bare(f.right) && persons(f.wrong) === persons(f.right))
235 .map(f => ({ ...f, at: text.indexOf(f.wrong) }))
236 .sort((x, y) => x.at - y.at)
237
238 const fixes: Fix[] = []
239 let better = ''
240 let from = 0
241 for (const f of candidates) {
242 if (f.at < from) continue
243 better += text.slice(from, f.at) + f.right
244 from = f.at + f.wrong.length
245 fixes.push({ wrong: f.wrong, right: f.right, tip: f.tip })
246 }
247 return { better: better + text.slice(from), fixes }
248}
249
250const check = async ($: EngineInterface, text: string, s: Settings, level: Level, timeoutMs?: number) => {
251 const context = await lastReply($)
252 const message = text.replace(PLACEHOLDERS, '').trim()
253 const json = await ask(
254 $,
255 `You check the English of a message a Chinese-speaking learner (around CEFR ${level}, goal ${s.goal}) is about to send to an AI assistant, Claude. ` +
256 `In it "you" is Claude and "I" is the learner. Ignore code, paths and commands. Claude's previous reply may be shown so you know what they mean; never judge it. ` +
257 `List only real errors: wrong grammar, a wrong word, a misspelling, or a collocation a native reader would stumble on. ` +
258 `Do not list what is merely less elegant, casing, punctuation or a missing final period. If the message reads fine, list nothing. ` +
259 `For each error: "wrong" is the shortest span copied character for character from the message, "right" is that span corrected with the smallest change, ` +
260 `keeping the meaning and who does what; "type" is grammar, word, spelling or collocation; "tip" says why in Simplified Chinese, under 30 characters, and must be true of this message. ` +
261 `Separately, give one "upgrade": a more precise or higher-level word, collocation or structure a ${s.goal} candidate would use for part of the message, written "plain → better" (empty if nothing fits). ` +
262 `Estimate the CEFR level (A1, A2, B1, B2, C1 or C2) the message shows. ` +
263 `Reply with JSON only: {"errors": [{"wrong": "<exact span>", "right": "<fix>", "type": "<type>", "tip": "<中文>"}], "upgrade": "<plain → better, or empty>", "cefr": "<level>"}`,
264 (context ? `Claude's previous reply (context only):\n"""\n${context}\n"""\n\n` : '') +
265 `The learner's message to check:\n"""\n${message}\n"""`,
266 timeoutMs,
267 )
268 if (!json || !Array.isArray(json.errors)) return null
269 const { better, fixes } = applyFixes(text, json.errors)
270 return {
271 isNatural: fixes.length === 0,
272 better: fixes.length === 0 ? '' : better,
273 tips: fixes.map(f => f.tip).filter(Boolean).slice(0, 3),
274 upgrade: typeof json.upgrade === 'string' ? json.upgrade.trim() : '',
275 cefr: typeof json.cefr === 'string' ? json.cefr.trim().toUpperCase() : '',
276 }
277}
278
279// Only the language Claude writes in: everything else the mod does after the reply.
280const systemSection = () =>
281 [
282 `# English`,
283 `Write every reply in English, even when the person writes in Chinese. Code, commands and quoted text stay as they are.`,
284 ].join('\n')
285
286const pickGlosses = async ($: EngineInterface, text: string, level: Level, s: Settings, known: string[]): Promise<Gloss[]> => {
287 const json = await ask(
288 $,
289 `You help a Chinese-speaking English learner, around CEFR ${level} and aiming for ${s.goal}, read an English reply. ` +
290 `Pick up to ${GLOSSES_PER_BLOCK} words or phrases in it that are likely above ${level} and worth learning: rare words, idioms, phrasal verbs, collocations, technical terms whose meaning is not obvious. ` +
291 `Copy each term exactly as it appears in the text, outside code. Skip code identifiers, file names, commands, product names` +
292 (known.length ? `, and these terms the learner has already seen glossed: ${known.join(', ')}` : '') +
293 `. Give each a short Simplified Chinese gloss of its meaning here, 2 to 8 characters. ` +
294 `Reply with JSON only: {"glosses": [{"term": "<exact text>", "zh": "<释义>"}]}`,
295 text.slice(0, 6000),
296 )
297 if (!json || !Array.isArray(json.glosses)) return []
298 return json.glosses
299 .filter((g: any) => typeof g?.term === 'string' && typeof g?.zh === 'string' && g.term.trim() && g.zh.trim())
300 .slice(0, GLOSSES_PER_BLOCK)
301 .map((g: any) => ({ term: g.term.trim(), zh: g.zh.trim() }))
302}
303
304const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
305
306// Puts "term(释义)" after the first use of each term, outside code, links and URLs.
307const applyGlosses = (markdown: string, glosses: Gloss[]) => {
308 const parts = markdown.split(/(```[\s\S]*?```|`[^`\n]*`|\]\([^)]*\)|https?:\/\/\S+)/)
309 const done = new Set<string>()
310 return parts
311 .map((part, i) => {
312 if (i % 2 === 1) return part
313 let out = part
314 for (const g of glosses) {
315 const key = g.term.toLowerCase()
316 if (done.has(key)) continue
317 const re = new RegExp(`(?<![\\p{L}\\p{N}])(${escapeRegExp(g.term)})(?![\\p{L}\\p{N}(])`, 'iu')
318 if (!re.test(out)) continue
319 done.add(key)
320 out = out.replace(re, `$1(${g.zh})`)
321 }
322 return out
323 })
324 .join('')
325}
326
327const HELP = async ($: EngineInterface, s: Settings) =>
328 [
329 `English coach is ${s.isOn ? 'on' : 'off'} · level ${await levelLabel($, s)} · goal ${s.goal} · glosses ${s.gloss ? 'on' : 'off'} · Chinese input: ${s.chinese} · feedback ${s.checkWhen}`,
330 ``,
331 `/english on | off turn the mode on or off`,
332 `/english level auto measure your level from what you write (default)`,
333 `/english level A2|B1|B2|C1 fix the level of Claude's English`,
334 `/english goal <text> your target, e.g. IELTS 7.5`,
335 `/english gloss on|off Chinese glosses after hard words`,
336 `/english chinese review Chinese input becomes an English draft in the box for you to check (default)`,
337 `/english chinese auto Chinese input is translated and sent at once`,
338 `/english chinese off Chinese input is sent as typed`,
339 `/english feedback before check your English when you press Enter; the fix goes in the box (default)`,
340 `/english feedback after send at once, show the feedback afterwards`,
341 `/english feedback off no feedback on your English`,
342 `/english demo show a sample feedback card`,
343 `/en <text> turn Chinese or rough English into an English draft in the box`,
344 `Start a prompt with "${RAW_PREFIX}" to send it as typed.`,
345 ].join('\n')
346
347const DEMO: Feedback = {
348 kind: 'checked',
349 isNatural: false,
350 original: 'The 135 score is in college entrance exam, and i made a mistake. The suggestions only shows when the message is send.',
351 better: 'The 135 score is from the college entrance exam, and I made a mistake. The suggestions only appear when the message is sent.',
352 tips: ['表示来源用 from,不用 in', 'suggestions 是复数,动词用 appear', '被动语态:is sent,不是 is send'],
353 upgrade: 'only appear when → only show up once',
354 isHeld: true,
355}
356
357const applyArgs = (s: Settings, args: string): Settings | string => {
358 const trimmed = args.trim()
359 const [key = '', value = ''] = trimmed.toLowerCase().split(/\s+/)
360 const flag = value === 'on' ? true : value === 'off' ? false : undefined
361 if (key === 'on' || key === 'off') return { ...s, isOn: key === 'on' }
362 if (key === 'level') {
363 if (value === 'auto') return { ...s, level: 'auto' }
364 const level = value.toUpperCase() as Level
365 return LEVELS.includes(level) ? { ...s, level } : `Level must be auto or one of ${LEVELS.join(', ')}.`
366 }
367 if (key === 'goal') {
368 const goal = trimmed.slice(4).trim()
369 return goal ? { ...s, goal } : 'Give a goal, e.g. /english goal IELTS 7.5'
370 }
371 if (key === 'gloss' && flag !== undefined) return { ...s, gloss: flag }
372 if (key === 'feedback') {
373 const when = value === 'on' ? 'before' : value
374 if (when === 'before' || when === 'after' || when === 'off') return { ...s, checkWhen: when }
375 }
376 if (key === 'chinese' && (value === 'review' || value === 'auto' || value === 'off')) return { ...s, chinese: value }
377 return `Unknown option "${trimmed}".`
378}
379
380// Reply blocks drawn, newest last, with their latest text; and terms already glossed.
381const seen = new Map<string, string>()
382const glossedTerms = new Set<string>()
383
384const glossNewBlocks = async ($: EngineInterface) => {
385 const s = await loadSettings($)
386 if (!s.isOn || !s.gloss) return
387 const level = await currentLevel($, s)
388 const made = await read($, replyGlosses)
389 const todo = [...seen].filter(([id, text]) => made[id]?.text !== text && text.trim().length >= 40).slice(-BLOCKS_PER_TURN)
390 for (const [id, text] of todo) {
391 const glosses = await pickGlosses($, text, level, s, [...glossedTerms].slice(-100))
392 glosses.forEach(g => glossedTerms.add(g.term.toLowerCase()))
393 await update($, replyGlosses, all => {
394 const next = { ...all, [id]: { text, glosses } }
395 const ids = Object.keys(next)
396 for (const old of ids.slice(0, Math.max(0, ids.length - GLOSSED_KEPT))) delete next[old]
397 return next
398 })
399 }
400}
401
402// Hides the card later, unless another one has taken its place by then.
403const hideLater = ($: EngineInterface, card: Feedback, ms: number) => {
404 const was = JSON.stringify(card)
405 $.clock.after(ms, () => void update($, feedback, shown => (JSON.stringify(shown) === was ? null : shown)))
406}
407
408// The key that swaps a held message between the fix and the person's own: the engine keeps
409// Tab and the arrows, so a mod sees only typed characters. Backtick, by Tab; a Chinese input
410// method types it as "·".
411const SWAP_KEY = '`'
412const isSwap = (typed: string) => typed === SWAP_KEY || typed === '·'
413
414export const register: Register = on => {
415 // The text a held message has in the box: Enter on it sends it unchecked.
416 let held: string | null = null
417 on('session.start', async ($, e, next) => {
418 await $.command.register({
419 name: 'english',
420 description: 'English coach: show or change its settings',
421 argumentHint: '[on|off|level auto|goal IELTS 7.5|gloss on|chinese review|feedback before|demo]',
422 })
423 await $.command.register({
424 name: 'en',
425 description: 'Turn Chinese or rough English into an English draft in the prompt box',
426 argumentHint: '<text>',
427 })
428 // The level is drawn under the prompt now; clear the status line of earlier versions.
429 $.ui.status(undefined)
430 await publishLevel($, await loadSettings($))
431 return next(e)
432 })
433
434 on('command.run', { command: 'english' }, async ($, e) => {
435 const s = await loadSettings($)
436 if (!e.args.trim()) return { text: await HELP($, s) }
437 if (e.args.trim().toLowerCase() === 'demo') {
438 await update($, feedback, () => DEMO)
439 return { text: 'A sample feedback card is above the prompt. Press ✕ to close it.' }
440 }
441 const changed = applyArgs(s, e.args)
442 if (typeof changed === 'string') return { text: `${changed}\n\n${await HELP($, s)}` }
443 await saveSettings($, changed)
444 if (!changed.isOn) await update($, feedback, () => null)
445 return { text: (await HELP($, changed)).split('\n')[0] ?? '' }
446 })
447
448 on('command.run', { command: 'en' }, async ($, e) => {
449 const text = e.args.trim()
450 if (!text) return { text: 'Usage: /en <Chinese or rough English>' }
451 const s = await loadSettings($)
452 const t = await translate($, text, await currentLevel($, s))
453 if (!t) return { text: 'Could not make an English draft just now. Try again.' }
454 const card: Feedback = { kind: 'translated', original: text, ...t }
455 await update($, feedback, () => card)
456 hideLater($, card, HIDE_DRAFT_MS)
457 $.clock.after(50, () => void $.prompt.fill({ text: t.english }))
458 return { text: 'English draft is in the prompt box: edit it, then press Enter.' }
459 })
460
461 on('prompt.compose', async ($, e, next) => {
462 const composed = await next(e)
463 const s = await loadSettings($)
464 if (!s.isOn) return composed
465 const section = { id: 'english-coach:mode', text: systemSection(), scope: 'session' as const }
466 return { sections: [...composed.sections, section] }
467 })
468
469 on('prompt.submit', async ($, e, next) => {
470 if (e.command || e.isMeta || !isPerson(e.origin)) return next(e)
471 const s = await loadSettings($)
472 if (!s.isOn) return next(e)
473
474 if (e.text.startsWith(RAW_PREFIX)) return next({ ...e, text: e.text.slice(RAW_PREFIX.length).trimStart() })
475
476 const level = await currentLevel($, s)
477
478 if (s.chinese !== 'off' && hasChinese(e.text)) {
479 const t = await translate($, e.text, level)
480 if (!t) {
481 $.ui.toast('English coach: translation failed, sent as typed.')
482 return next(e)
483 }
484 const card: Feedback = { kind: 'translated', original: e.text, ...t }
485 await update($, feedback, () => card)
486 hideLater($, card, HIDE_DRAFT_MS)
487 if (s.chinese === 'auto') return next({ ...e, text: t.english })
488 $.clock.after(50, () => void $.prompt.fill({ text: t.english }))
489 return { drop: `Not sent: your English draft is in the box. (Start with "${RAW_PREFIX}" to send Chinese as typed.)` }
490 }
491
492 if (s.checkWhen === 'off' || !isCheckable(e.text)) return next(e)
493
494 const original = e.text
495 const record = async (r: Awaited<ReturnType<typeof check>>, isHeld: boolean) => {
496 if (r && wordCount(original) >= WORDS_TO_ESTIMATE) {
497 await addEstimate($, r.cefr)
498 await publishLevel($, s)
499 }
500 const card: Feedback | null = r
501 ? {
502 kind: 'checked',
503 original,
504 isNatural: r.isNatural,
505 better: r.better,
506 tips: r.tips,
507 upgrade: r.upgrade,
508 isHeld,
509 ...(isHeld ? { inBox: 'better' as const } : {}),
510 }
511 : null
512 await update($, feedback, shown => (shown?.original !== original ? shown : card))
513 if (card && !isHeld) hideLater($, card, HIDE_CHECKED_MS)
514 }
515
516 // A message with images is never held: filling the box again would lose them.
517 if (s.checkWhen === 'after' || (e.attachments?.length ?? 0) > 0) {
518 await update($, feedback, (): Feedback => ({ kind: 'pending', original }))
519 // Off the dispatch, so the turn is not held up by the check.
520 $.clock.after(0, async () => record(await check($, original, s, level), false))
521 return next(e)
522 }
523
524 // Before sending: Enter on a held message, the fix or the person's own, sends it unchecked.
525 if (held !== null && original.trim() === held.trim()) {
526 held = null
527 const shown = await read($, feedback)
528 if (shown?.kind === 'checked' && shown.isHeld) {
529 const card: Feedback = { ...shown, isHeld: false }
530 await update($, feedback, () => card)
531 hideLater($, card, HIDE_CHECKED_MS)
532 }
533 return next(e)
534 }
535
536 await update($, feedback, (): Feedback => ({ kind: 'pending', original }))
537 const r = await check($, original, s, level, HOLD_TIMEOUT_MS)
538 const isHeld = r !== null && !r.isNatural && r.better !== '' && r.better.trim() !== original.trim()
539 await record(r, isHeld)
540 if (!isHeld) return next(e)
541
542 // The fix goes straight into the box, as a Chinese draft does.
543 const fixed = r.better
544 held = fixed
545 $.clock.after(50, () => void $.prompt.fill({ text: fixed }))
546 return { drop: `Not sent yet: the corrected message is in the box. Enter sends it; ${SWAP_KEY} brings yours back.` }
547 })
548
549 // The swap key on an untouched held message trades the fix and the person's own; Enter sends either unchecked.
550 on('prompt.edit', async ($, e, next) => {
551 if (!isSwap(e.inputText) || e.start !== e.end) return next(e)
552 const fb = await read($, feedback)
553 if (fb?.kind !== 'checked' || !fb.isHeld) return next(e)
554 const box = e.text.trim()
555 const inBox = box === fb.better.trim() ? 'original' : box === fb.original.trim() ? 'better' : null
556 if (inBox === null) return next(e)
557 const text = inBox === 'better' ? fb.better : fb.original
558 held = text
559 await update($, feedback, (shown): Feedback | null => (shown?.kind === 'checked' && shown.isHeld ? { ...shown, inBox } : shown))
560 return { text, cursor: text.length }
561 })
562
563 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
564 const fb = await read($, feedback)
565 if (e.props.hasSurvey || fb === null) return next(e)
566
567 const { Box, Button, Text } = $.ui.resolve(e)
568 const hide = <Button key="hide" plain label="✕" onPress={() => update($, feedback, () => null)} />
569
570 const card = (color: string, title: string, hint: string, ...body: RenderChildren[]) => (
571 <Box key="card" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
572 <Box justifyContent="space-between">
573 <Text>
574 <Text color={color} bold>
575 {title}
576 </Text>
577 <Text dimColor>{hint ? ` ${hint}` : ''}</Text>
578 </Text>
579 {hide}
580 </Box>
581 {body.filter(Boolean)}
582 </Box>
583 )
584
585 if (fb.kind === 'pending') {
586 return <Text dimColor>✎ Checking your English…</Text>
587 }
588
589 if (fb.kind === 'translated') {
590 return card(
591 'cyan',
592 '中 → EN',
593 'Your English draft is in the box: edit it, then Enter',
594 fb.phrases.length > 0 && (
595 <Box key="phrases" flexDirection="column">
596 {fb.phrases.map(p => (
597 <Text>
598 <Text dimColor>• </Text>
599 {p.zh}
600 <Text dimColor> → </Text>
601 <Text color="cyan">{p.en}</Text>
602 </Text>
603 ))}
604 </Box>
605 ),
606 )
607 }
608
609 const upgrade = fb.upgrade !== '' && (
610 <Text key="upgrade">
611 <Text color="magenta" bold>
612 ↑{' '}
613 </Text>
614 {fb.upgrade}
615 </Text>
616 )
617
618 // All good: one line, no border.
619 if (fb.isNatural) {
620 return (
621 <Box key="natural" justifyContent="space-between">
622 <Text>
623 <Text color="green" bold>
624 ✓ Natural English
625 </Text>
626 {upgrade ? <Text dimColor>{' '}</Text> : ''}
627 {upgrade}
628 </Text>
629 {hide}
630 </Box>
631 )
632 }
633
634 return card(
635 'yellow',
636 fb.isHeld && fb.inBox === 'better' ? '✎ Fixed in the box' : '✎ Almost there',
637 !fb.isHeld ? '' : fb.inBox === 'better' ? `Enter send it · ${SWAP_KEY} back to yours` : `${SWAP_KEY} use the fix · Enter send yours as is`,
638 <Box key="diff">
639 <Text>
640 {diffWords(fb.original, fb.better).map(p =>
641 p.kind === 'same' ? (
642 p.text
643 ) : p.kind === 'del' ? (
644 <Text color="red" strikethrough>
645 {p.text}
646 </Text>
647 ) : (
648 <Text color="green" bold>
649 {p.text}
650 </Text>
651 ),
652 )}
653 </Text>
654 </Box>,
655 fb.tips.length > 0 && (
656 <Box key="tips" flexDirection="column">
657 {fb.tips.map((tip, i) => (
658 <Text>
659 {i === 0 ? '💡 ' : ' '}
660 {fb.tips.length > 1 ? <Text dimColor>{i + 1}. </Text> : ''}
661 {tip}
662 </Text>
663 ))}
664 </Box>
665 ),
666 upgrade,
667 )
668 })
669
670 // Glosses are made once the turn's reply is whole, off the dispatch.
671 on('turn.complete', async ($, e, next) => {
672 const done = await next(e)
673 if (e.agentId === undefined) $.clock.after(300, () => void glossNewBlocks($))
674 return done
675 })
676
677 // Draws a reply with its glosses; the stored reply, which Claude reads, is unchanged.
678 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
679 if (e.props.isSummary) return next(e)
680 seen.delete(e.requestId)
681 seen.set(e.requestId, e.props.text)
682 if (seen.size > 200) seen.delete(seen.keys().next().value!)
683
684 const s = await loadSettings($)
685 const made = (await read($, replyGlosses))[e.requestId]
686 if (!s.isOn || !s.gloss || !made || made.text !== e.props.text || made.glosses.length === 0) return next(e)
687 return next({ ...e, props: { ...e.props, text: applyGlosses(e.props.text, made.glosses) } })
688 })
689
690 // The level slider, at the right of the footer under the prompt.
691 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
692 const v = await read($, levelView)
693 if (!v?.isOn) return next(e)
694
695 const { Text } = $.ui.resolve(e)
696 // The desktop cuts the footer label short: there, only the stretch from the level to the goal.
697 const isNarrow = e.surface !== 'terminal'
698 const here = SCALE.indexOf(v.level as never)
699 const goal = v.goalLevel ? SCALE.indexOf(v.goalLevel as never) : -1
700 const shown = isNarrow ? SCALE.slice(Math.min(here, goal < 0 ? here : goal), Math.max(here, goal) + 1) : SCALE
701 const marks = shown.flatMap((c, i) => {
702 const isYou = c === v.level
703 const isGoal = c === v.goalLevel
704 const mark = isYou ? (
705 <Text color="cyan" bold inverse>
706 {` ${c}${v.isMeasuring ? '?' : ''}${isGoal ? '★' : ''} `}
707 </Text>
708 ) : isGoal ? (
709 <Text color="magenta" bold>
710 {`${c}★`}
711 </Text>
712 ) : (
713 <Text dimColor>{c}</Text>
714 )
715 return i === 0 ? [mark] : [<Text dimColor>─</Text>, mark]
716 })
717
718 return (
719 <Text>
720 {e.props.modes.length > 0 ? <Text dimColor>{`${e.props.modes.join(' & ')} · `}</Text> : ''}
721 <Text dimColor>EN </Text>
722 {marks}
723 {v.isMeasuring && !isNarrow ? <Text dimColor>{` measuring ${v.samples}/${v.needed}`}</Text> : ''}
724 </Text>
725 )
726 })
727}
728types/index.d.ts 43 lines1export type Phrase = { zh: string; en: string }
2
3export type Feedback =
4 | { kind: 'pending'; original: string }
5 | { kind: 'translated'; original: string; english: string; phrases: Phrase[] }
6 | {
7 kind: 'checked'
8 original: string
9 isNatural: boolean
10 better: string
11 // Short tips in Chinese, one per main mistake.
12 tips: string[]
13 upgrade: string
14 // True while the message waits in the box for the person to send it.
15 isHeld: boolean
16 // Which version a held message has in the box: the fix (the default) or the person's own.
17 inBox?: 'better' | 'original'
18 }
19
20// What the level slider under the prompt draws.
21export type LevelView = {
22 isOn: boolean
23 level: string
24 isMeasuring: boolean
25 samples: number
26 needed: number
27 goal: string
28 // The goal on the CEFR scale (IELTS 7.5 is C1), null when it cannot be read.
29 goalLevel: string | null
30}
31
32// A Chinese gloss for a word or phrase in one of Claude's replies.
33export type Gloss = { term: string; zh: string }
34
35// The glosses of each reply block, by its requestId, for the text they were made from.
36export type ReplyGlosses = Record<string, { text: string; glosses: Gloss[] }>
37
38declare module 'claude-code' {
39 interface PluginState {
40 'english-coach': { feedback: Feedback | null; level: LevelView | null; glosses: ReplyGlosses }
41 }
42}
43