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

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.
| Mod | What it does |
|---|---|
english-coach | Checks 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 |
toolbar | A 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 |
statusline | A 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 |
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.
Both mods make extra model requests on your account. Read this before installing.
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.
! shell lines, and prompts with fewer than four of your own words left after stripping./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.SYSTEM in english-coach/hooks/register.tsx to describe yourself.Adds a Toolbar button at the right of the hint row under the prompt (also /toolbar). It opens a card above the prompt to:
/model and one /effort when the card closes;open -a "Visual Studio Code", so macOS only);/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.
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.
hooks/register.tsx 199 lines1import { 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}
199types/index.d.ts 10 lines1export 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