SLOPSHOPPER

english-coach

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.

newbandspinnerrowscommandtoast
v0.6.1MITupdated 2026-10-16chinayuren2022-2025/claude-english-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · english-coach
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /english ⎿ english-coach: English coach is on · level B1? (measuring) · goal IELTS 7.5 · glosses on · Chinese input: review · feedback b ⎿ english-coach: ⎿ english-coach: /english on | off turn the mode on or off ⎿ english-coach: /english level auto measure your level from what you write (default) ⎿ english-coach: /english level A2|B1|B2|C1 fix the level of Claude's English ⎿ english-coach: /english goal <text> your target, e.g. IELTS 7.5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · EN A1─A2─ B1? ─B2─C1★─C2 measuring 0/3
README

English Coach for Claude Code

Practise English by working with Claude in English only. Made for Chinese-speaking learners (for example, aiming for IELTS 7.5).

在 Claude Code 里只用英语和 Claude 交流,练英语。为中文母语的学习者设计。

What it does

  • Claude replies in English. Its system prompt gets one line, "write every reply in English", and nothing else, so no learner rules shape how it thinks or writes. After each reply, Haiku picks the hard words and the mod adds a short Chinese gloss to what you see: idempotent(幂等的). Claude's stored reply, which it reads back later, stays exactly as it wrote it. Code, links and URLs are never glossed.
  • Your level is measured automatically from what you write (CEFR A2–C1), and shown as a slider under the prompt, with your goal marked: 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★).
  • Check before you send. When you press Enter, your English is checked first, with Claude's last reply as context, so "you" and "I" keep their meaning. Haiku lists each error as an exact quote with its fix, and the mod applies only the ones it can trust: real errors (grammar, wrong word, spelling, collocation), never style rewrites, casing or punctuation, and never a change to who does what ("you", "I", "we"). If there is something to fix, the message is not sent: the corrected version goes straight into the box, as a Chinese draft does, and a card above the prompt shows your sentence with the mistakes struck out in red and the fixes in green, and short numbered tips in Chinese. Press Enter to send the fix, type ` (or · with a Chinese input method) to swap the box back to your own version and Enter to send that, or edit it and it is checked again. (Tab can't be used: Claude Code keeps Tab and the arrow keys for itself, and a plugin only sees typed characters.) A message with images is sent at once and checked afterwards, so nothing is lost.
  • Out of your way. A "natural" result is one line, and cards hide by themselves once nothing waits on them (8 seconds; 20 for a Chinese draft). A held card stays until you send.
  • Level up. Even when your English is natural, you get one stronger word or phrase toward your goal: fix this bug → track down this bug.
  • Write in Chinese when you're stuck. Chinese or mixed input becomes an English draft in the box for you to check, with the key phrases shown: 优化 → 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).

Install

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.

Commands

CommandWhat it does
/englishShow settings and help
/english on / offTurn the mode on or off
/english level autoMeasure your level from your writing (default)
`/english level A2\B1\B2\C1`Fix the level of Claude's English
/english goal IELTS 7.5Set 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 demoShow a sample feedback card
/en <text>Turn Chinese or rough English into an English draft in the box

Settings are kept across sessions.

Notes

  • Where an organization manages Claude Code, the English-only system prompt part does not run for plugins a person installs; the input help still works.
  • Develop: claude --plugin-dir . loads it from this folder; claude plugin test . runs the tests.

License

MIT

Source 2 files
hooks/register.tsx 728 lines
1import { 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}
728
types/index.d.ts 43 lines
1export 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