SLOPSHOPPER

english-coach

Checks the grammar and fluency of each prompt you type, in parallel, and shows corrections and a word or expression to learn above the prompt

newbandcommandpromptmodel
v0.3.1MITupdated 2026-10-11rafagomes/claude-code-mods/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 ● english-coach: english-coach: unreadable reply: OK ⏺ 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-coach ⎿ english-coach: English coach off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Mods for Claude Code: function-hook plugins that run inside the session and change what you see around the prompt. Each one installs separately — take only what you want.

Built and tested on Claude Code 2.1.296; mods need a version with function-hook plugins (2.1.275 or later). The status line is a plain script and only needs jq.

ModWhat it does
english-coachChecks the grammar and fluency of each prompt you write and shows corrections, plus a word or expression to learn, in a band above the prompt
toolbarA Toolbar button under the prompt: model and effort picker, open in VS Code, and a clean view that replaces the work log with a step tracker
statuslineA status line that shows where you are and what you are driving, and a rate-limit row only when a limit needs attention. A script, not a plugin — install it by hand

Install

In a session:

/plugin marketplace add rafagomes/claude-code-mods
/plugin install english-coach@claude-code-mods
/plugin install toolbar@claude-code-mods

Or from a shell:

claude plugin marketplace add rafagomes/claude-code-mods
claude plugin install english-coach@claude-code-mods
claude plugin install toolbar@claude-code-mods

Turn one off with /plugin disable <mod>@claude-code-mods; remove it with claude plugin uninstall <mod>@claude-code-mods. Plugins are installed per profile — set CLAUDE_CONFIG_DIR in front of the commands for each extra profile.

What each mod sends, and where

Both mods make extra model requests on your account. Read this before installing.

english-coach

Each prompt you write is checked for grammar and fluency — phrasing that is correct but not what a native speaker would say — by a parallel Haiku call, so the turn never waits. Corrections appear as colour-coded bullets ([grammar] / [fluency], original → improved, reason) in a dismissable band above the prompt, plus a "Natural:" rewrite when a short message (under 300 characters) has two or more issues. The last line is a "Learn:" tip — one word, phrasal verb or idiom a native speaker would have used in that message, with its meaning — so the band also appears, with only that line, when there is nothing to correct. A bulleted copy is written to the transcript as a log entry for you, not as part of the conversation.

  • Checked: prompts typed at the terminal or sent through Remote Control. Prompts from the SDK, background tasks, peers and schedules are not.
  • Sent: your own words from the prompt — pasted blocks and fenced code are stripped first — capped at 3,000 characters, one Haiku request per prompt.
  • Skipped entirely: slash commands, ! shell lines, and prompts with fewer than four of your own words left after stripping.
  • Switched off and on: /english-coach off stops the checks for the rest of the session and takes the band down; /english-coach on resumes them, and with no argument it toggles; anything else changes nothing. Every session starts with it on. With the toolbar mod installed, the same switch is a row in its card.
  • Tuned for Portuguese speakers: the coaching prompt describes the writer as a Brazilian software engineer and looks for literal translations and false friends from Portuguese. It works for anyone, but edit SYSTEM in english-coach/hooks/register.tsx to describe yourself.

toolbar

Adds a Toolbar button at the right of the hint row under the prompt (also /toolbar). It opens a card above the prompt to:

  • pick the model, the 1M-context variant and the effort — applied with one /model and one /effort when the card closes;
  • open the project root in VS Code (runs open -a "Visual Studio Code", so macOS only);
  • switch clean view;
  • switch the English coach on and off — a row drawn only when the english-coach mod is loaded, which runs its /english-coach command.

Clean view hides the work — tool rows, the spinner line, the turn-duration line, notifications of background tasks that completed — and shows a step tracker of the task list instead: a short heading, Step N of M with a progress bar, and one row per task. Of the model's text, only the answer to your last prompt stays drawn, plus the latest message of a turn a background task started.

  • Sent, only while clean view is on: each prompt you send (typed at the terminal, or through Remote Control or the SDK; up to 4,000 characters) goes to Haiku once for the band's 3-to-6-word heading. A turn started by a background task, a peer or a schedule is never sent.
  • Added to the system prompt, only while clean view is on: a short section telling the model that only its task list and final message are seen, and to keep both complete. The steps come from the model's own task tools.

Development

claude plugin test english-coach     # run a mod's tests
claude plugin test toolbar
claude --plugin-dir toolbar          # try a mod without installing it

To type-check a mod, load it once (installed, or with --plugin-dir): the engine writes its types and a tsconfig.json into the mod folder (both gitignored). Then run npx tsc -p <mod>.

After editing a mod installed from a local clone (claude plugin marketplace add /path/to/claude-code-mods), /reload-plugins picks up the change.

License

MIT

Source 2 files
hooks/register.tsx 199 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feedback, Issue, Tip } from '../types'
5
6const MAX_CHARS = 3000
7const last = atom({ plugin: 'english-coach', key: 'last' } as const, null)
8// The switch /english-coach flips. Another mod (the toolbar) reads it to draw
9// its own switch, so it is written at session start: unwritten means not loaded.
10const enabled = atom({ plugin: 'english-coach', key: 'on' } as const, true)
11
12export const SYSTEM = `You are an English coach for a Brazilian software engineer who wants to sound fluent and native, not just correct.
13You receive one message they wrote to a coding assistant. Review two things, then teach one:
141. grammar: tense, articles, prepositions, agreement, countable/uncountable nouns, spelling.
152. fluency: phrasing that is grammatical but unusual, literal translations from Portuguese, false friends, awkward word order, or word choices a native speaker wouldn't pick.
163. tip: one word, phrasal verb or idiom a native speaker would naturally use to say something in this message, and that the writer did not use. Common in everyday or workplace speech, upper-intermediate to advanced, never rare or literary. Don't repeat an "improved" phrase.
17Ignore code, file paths, identifiers, commands, technical jargon, casual lowercase, and missing final punctuation. Don't nitpick choices natives commonly make in quick chat.
18Reply with JSON only, no prose, no code fence:
19{"issues":[{"kind":"grammar"|"fluency","original":"<exact short phrase>","improved":"<better phrase>","why":"<max 8 words>"}],"natural":"<whole message as a native would write it>"|null,"tip":{"phrase":"<the word or expression>","meaning":"<max 10 words>"}}
20At most 5 issues. "natural" only when there are 2+ issues and the message is under 300 characters, else null. Always give a "tip", even with nothing to improve: {"issues":[],"natural":null,"tip":{...}}`
21
22// Drop pasted blocks and fenced code: they aren't the user's own writing.
23export function ownWords(text: string): string {
24  return text
25    .replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>/g, ' ')
26    .replace(/```[\s\S]*?```/g, ' ')
27    .trim()
28}
29
30// `raw` is what the user typed; `own` is it with pastes and code removed.
31export function shouldCheck(raw: string, own: string): boolean {
32  const head = raw.trimStart()
33  if (head.startsWith('/') || head.startsWith('!')) return false
34  return own.split(/\s+/).filter(Boolean).length >= 4
35}
36
37// Text from the model is drawn in the terminal and logged to the transcript:
38// control characters (escape sequences, line breaks, C1, bidi overrides) are
39// replaced before anything is checked, so what is validated is what is shown.
40const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g
41const plain = (v: unknown): string => (typeof v === 'string' ? v.replace(UNSAFE, ' ').trim() : '')
42
43// One line in the band: a tip that is malformed or too long to fit is dropped.
44function parseTip(raw: unknown): Tip | null {
45  if (typeof raw !== 'object' || raw === null) return null
46  const t = raw as { phrase?: unknown; meaning?: unknown }
47  const phrase = plain(t.phrase)
48  const meaning = plain(t.meaning)
49  if (phrase === '' || meaning === '' || phrase.length > 60 || meaning.length > 120) return null
50  return { phrase, meaning }
51}
52
53function parseJson(reply: string): unknown {
54  try {
55    return JSON.parse(reply.trim())
56  } catch {
57    // Prose around the object: retry from the first '{' to each '}' after it.
58    const start = reply.indexOf('{')
59    for (let end = reply.lastIndexOf('}'); start >= 0 && end > start; end = reply.lastIndexOf('}', end - 1)) {
60      try {
61        return JSON.parse(reply.slice(start, end + 1))
62      } catch {}
63    }
64    return null
65  }
66}
67
68// The model's reply is untrusted: keep only well-formed issues, and the
69// rewrite only when the rules allow one.
70export function parseFeedback(reply: string, textLength: number): Feedback | null {
71  const raw = parseJson(reply)
72  if (typeof raw !== 'object' || raw === null) return null
73  const r = raw as { issues?: unknown; natural?: unknown; tip?: unknown }
74  const issues: Issue[] = (Array.isArray(r.issues) ? r.issues : [])
75    .filter((i): i is Record<string, unknown> => typeof i === 'object' && i !== null)
76    .map(i => ({ kind: i.kind, original: plain(i.original), improved: plain(i.improved), why: plain(i.why) }))
77    .filter(
78      (i): i is Issue =>
79        (i.kind === 'grammar' || i.kind === 'fluency') &&
80        i.original !== '' && i.improved !== '' && i.original !== i.improved,
81    )
82    .slice(0, 5)
83  const wantsNatural = issues.length >= 2 && textLength < 300
84  return { issues, natural: (wantsNatural && plain(r.natural)) || null, tip: parseTip(r.tip) }
85}
86
87export function asText(f: Feedback): string {
88  const lines = f.issues.map(
89    i => `  • [${i.kind}] "${i.original}" → "${i.improved}"${i.why ? ` — ${i.why}` : ''}`,
90  )
91  if (f.natural) lines.push(`  ✓ Natural: ${f.natural}`)
92  if (f.tip) lines.push(`  ✦ Learn: "${f.tip.phrase}" — ${f.tip.meaning}`)
93  return `✎ English\n${lines.join('\n')}`
94}
95
96// Bumped per prompt so a slow reply for an older prompt is discarded.
97let generation = 0
98
99async function check($: EngineInterface, text: string, gen: number) {
100  const r = await $.model.complete({
101    model: 'haiku',
102    system: SYSTEM,
103    prompt: text.slice(0, MAX_CHARS),
104    maxTokens: 600,
105  })
106  if (!r.isAnswered) {
107    $.ui.log(`english-coach: no reply (${r.reason})`, { to: 'debug' })
108    return
109  }
110  if (gen !== generation) return
111  const feedback = parseFeedback(r.text, text.length)
112  if (feedback === null) {
113    $.ui.log(`english-coach: unreadable reply: ${r.text.slice(0, 200)}`, { to: 'debug' })
114    return
115  }
116  if (feedback.issues.length === 0 && feedback.tip === null) return
117  await update($, last, () => feedback)
118  $.ui.log(asText(feedback))
119}
120
121export const register: Register = on => {
122  on('session.start', async ($, e, next) => {
123    await update($, enabled, saved => saved ?? true)
124    await $.command.register({
125      name: 'english-coach',
126      description: 'Turn the English coach on or off for this session',
127      argumentHint: '[on|off]',
128    })
129    return next(e)
130  })
131
132  on('command.run', { command: 'english-coach' }, async ($, e) => {
133    const arg = e.args.trim()
134    if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: 'Usage: /english-coach [on|off]' }
135    const isOn = arg === 'on' || (arg === '' && !(await read($, enabled)))
136    await update($, enabled, () => isOn)
137    if (!isOn) {
138      // Off also takes down the feedback on show and any check in flight.
139      generation++
140      await update($, last, () => null)
141    }
142    return { text: `English coach ${isOn ? 'on' : 'off'}.` }
143  })
144
145  on('prompt.submit', async ($, e, next) => {
146    const kind = e.origin.kind
147    if (kind !== 'composer' && kind !== 'bridge') return next(e)
148    if (!(await read($, enabled))) return next(e)
149
150    // Any new prompt makes the previous feedback, and any check in flight, stale.
151    const gen = ++generation
152    await update($, last, () => null)
153    const text = ownWords(e.text)
154    if (shouldCheck(e.text, text)) {
155      // Fire and forget: the turn starts right away; the check runs beside it.
156      check($, text, gen).catch(err => $.ui.log(`english-coach: ${err}`, { to: 'debug' }))
157    }
158    return next(e)
159  })
160
161  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162    const f = await read($, last)
163    if (e.props.hasSurvey || f === null) return next(e)
164
165    const { Box, Button, Text } = $.ui.resolve(e)
166    return (
167      <Box flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}>
168        <Box justifyContent="space-between">
169          <Text bold color="suggestion">✎ English coach</Text>
170          <Button key="dismiss" label="Dismiss" onPress={() => update($, last, () => null)} />
171        </Box>
172        {f.issues.map((i, n) => (
173          <Box key={`issue-${n}`} flexWrap="wrap">
174            <Text>• </Text>
175            <Text color={i.kind === 'grammar' ? 'warning' : 'permission'}>{i.kind === 'grammar' ? '[grammar] ' : '[fluency] '}</Text>
176            <Text color="diffRemoved" strikethrough>{i.original}</Text>
177            <Text> → </Text>
178            <Text color="success" bold>{i.improved}</Text>
179            {i.why ? <Text dimColor>{`  ${i.why}`}</Text> : null}
180          </Box>
181        ))}
182        {f.natural ? (
183          <Box marginTop={1}>
184            <Text dimColor>Natural: </Text>
185            <Text italic>{f.natural}</Text>
186          </Box>
187        ) : null}
188        {f.tip ? (
189          <Box flexWrap="wrap" marginTop={f.issues.length > 0 && !f.natural ? 1 : 0}>
190            <Text color="suggestion">Learn: </Text>
191            <Text bold>{f.tip.phrase}</Text>
192            <Text dimColor>{`  ${f.tip.meaning}`}</Text>
193          </Box>
194        ) : null}
195      </Box>
196    )
197  })
198}
199
types/index.d.ts 10 lines
1export type Issue = { kind: 'grammar' | 'fluency'; original: string; improved: string; why: string }
2export type Tip = { phrase: string; meaning: string }
3export type Feedback = { issues: Issue[]; natural: string | null; tip: Tip | null }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'english-coach': { last: Feedback | null; on: boolean }
8  }
9}
10