Haiku reviews each long prompt you send and shows English tips above the prompt

A Claude Code mod that reviews the English of each long prompt you send. Haiku 5.5 checks the prompt and shows the fixes and a better version above the prompt box. You can copy the better version.

Type this line at the Claude Code prompt in a terminal:
/plugin install english-coach --marketplace franc1sc0sv/english-coach
Type y to add the marketplace, then select the user scope. The mod starts immediately.
Tell Claude Code: "Install the english-coach plugin from github.com/franc1sc0sv/english-coach". Claude runs:
claude plugin marketplace add franc1sc0sv/english-coach
claude plugin install english-coach@english-coach --scope user
Then run /reload-plugins, or start a new session.
/english-coach turns the tips on or off.claude plugin update english-coach
Then run /reload-plugins in an open session.
hooks/register.tsx 176 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import { REWRITE_SYSTEM, SYSTEM, isWorthReview, parse, prose } from "../lib/coach.ts";
5import type { TipKind } from "../types";
6
7const COMMAND = "english-coach";
8
9const ENABLED_KEY = "enabled";
10
11const MODEL = "claude-haiku-5-5";
12
13const REVIEW_MAX_TOKENS = 500;
14
15const REWRITE_MAX_TOKENS = 4000;
16
17const KIND_LABELS: Record<TipKind, string> = {
18 grammar: "Grammar",
19 word: "Word",
20 pronunciation: "Misheard",
21 clarity: "Clearer",
22};
23
24const LABEL_COLUMNS = 10;
25
26const review = atom({ plugin: "english-coach", key: "review" } as const, null);
27
28let isInteractive = false;
29
30let epoch = 0;
31
32async function isEnabled($: EngineInterface): Promise<boolean> {
33 return (await $.store.get(ENABLED_KEY)) !== false;
34}
35
36async function coach($: EngineInterface, text: string, at: number): Promise<void> {
37 const prompt = prose(text);
38 const reply = await $.model.complete({
39 model: MODEL,
40 system: SYSTEM,
41 prompt,
42 maxTokens: REVIEW_MAX_TOKENS,
43 effort: "low",
44 timeoutMs: 30000,
45 });
46
47 if (!reply.isAnswered || at !== epoch) return;
48
49 const tips = parse(reply.text);
50
51 if (tips !== undefined) await update($, review, () => ({ tips, prompt }));
52}
53
54async function rewrite($: EngineInterface, prompt: string): Promise<string | undefined> {
55 const reply = await $.model.complete({
56 model: MODEL,
57 system: REWRITE_SYSTEM,
58 prompt,
59 maxTokens: REWRITE_MAX_TOKENS,
60 effort: "low",
61 timeoutMs: 30000,
62 });
63
64 return reply.isAnswered ? reply.text.trim() : undefined;
65}
66
67export const register: Register = (on) => {
68 isInteractive = false;
69 epoch = 0;
70
71 on("session.start", async ($, e, next) => {
72 isInteractive = e.isInteractive;
73 await $.command.register({
74 name: COMMAND,
75 description: "Turn English tips on or off",
76 });
77
78 return next(e);
79 });
80
81 on("command.run", { command: COMMAND }, async ($) => {
82 const isOn = !(await isEnabled($));
83 await $.store.set(ENABLED_KEY, isOn);
84
85 if (!isOn) await update($, review, () => null);
86
87 return { text: `English tips ${isOn ? "on" : "off"}.` };
88 });
89
90 on("prompt.submit", async ($, e, next) => {
91 epoch += 1;
92 const at = epoch;
93 await update($, review, () => null);
94
95 const isOwnWords = e.origin.kind === "composer";
96
97 if (isInteractive && isOwnWords && isWorthReview(e.text) && (await isEnabled($))) {
98 $.clock.after(0, () => {
99 void coach($, e.text, at).catch(() => undefined);
100 });
101 }
102
103 return next(e);
104 });
105
106 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
107 const shown = await read($, review);
108
109 if (shown === null || e.props.hasSurvey) return next(e);
110
111 const beneath = await next(e);
112 const { Box, Button, Text } = $.ui.resolve(e);
113
114 const dismiss = () => update($, review, () => null);
115
116 const copy = async () => {
117 const text = await rewrite($, shown.prompt);
118
119 if (text === undefined || text === "") {
120 $.ui.toast("English coach could not write the rewrite.");
121
122 return;
123 }
124
125 await $.ui.copy({ text, surface: e.surface });
126 await update($, review, () => null);
127 };
128
129 return (
130 <Box flexDirection="column">
131 {beneath}
132 <Box flexDirection="column" paddingX={1} paddingTop={1}>
133 <Text>
134 <Text color="suggestion" bold>
135 English tip
136 </Text>
137 <Text dimColor>{` ${shown.tips.length} ${shown.tips.length === 1 ? "fix" : "fixes"}`}</Text>
138 </Text>
139 <Box flexDirection="column" paddingTop={1}>
140 {shown.tips.map((tip, index) => (
141 <Box key={`tip-${index}`} paddingLeft={2}>
142 <Box width={LABEL_COLUMNS} flexShrink={0}>
143 <Text color="warning">{KIND_LABELS[tip.kind]}</Text>
144 </Box>
145 <Box flexDirection="column">
146 <Text>
147 <Text color="error" strikethrough>
148 {tip.said}
149 </Text>
150 <Text dimColor>{" -> "}</Text>
151 <Text color="success" bold>
152 {tip.better}
153 </Text>
154 </Text>
155 <Text dimColor>{tip.why}</Text>
156 </Box>
157 </Box>
158 ))}
159 </Box>
160 <Box paddingLeft={2} paddingTop={1} columnGap={2}>
161 <Button key="copy" label="Copy full rewrite" hotkey="1" plain onPress={copy} />
162 <Button
163 key="dismiss"
164 label="Dismiss"
165 hotkey="2"
166 plain
167 role="dismiss"
168 onPress={dismiss}
169 />
170 </Box>
171 </Box>
172 </Box>
173 );
174 });
175};
176lib/coach.ts 92 lines1import type { Tip, TipKind } from "../types";
2
3type Json = null | boolean | number | string | Json[] | JsonObject;
4
5type JsonObject = { [key: string]: Json };
6
7const MIN_WORDS = 8;
8
9const MAX_PROSE_CHARS = 8000;
10
11const MAX_TIPS = 3;
12
13const WHY_CHARS = 80;
14
15const KINDS: readonly TipKind[] = ["grammar", "word", "pronunciation", "clarity"];
16
17export const SYSTEM = `English coach for a non-native speaker who may dictate prompts by voice. Find up to 3 real errors in the prompt: grammar, wrong words or literal translations from their first language, words the dictation misheard, or unclear phrasing. Ignore code, paths, names, punctuation, capitalization, filler words and casual tone.
18Write "why" for a learner who knows no grammar words: never say singular, plural, participle, tense, subject, verb, noun or preposition. Say "After 'this', use 'takes'", not "Third-person singular needs an s".
19Reply with JSON only:
20{"tips":[{"kind":"grammar|word|pronunciation|clarity","said":"2-5 exact words","better":"the fix","why":"plain reason under 60 chars"}]}
21No errors: {"tips":[]}`;
22
23export const REWRITE_SYSTEM =
24 "Rewrite the prompt in clear, natural English. Keep every request, detail, code, path and name. Reply with the rewrite only.";
25
26export function prose(text: string): string {
27 return text
28 .replace(/```[\s\S]*?```/g, " ")
29 .replace(/`[^`\n]*`/g, " ")
30 .trim()
31 .slice(0, MAX_PROSE_CHARS);
32}
33
34export function isWorthReview(text: string): boolean {
35 if (text.trimStart().startsWith("/")) return false;
36
37 return prose(text).split(/\s+/).filter(Boolean).length >= MIN_WORDS;
38}
39
40export function parse(reply: string): Tip[] | undefined {
41 const source = /\{[\s\S]*\}/.exec(reply)?.[0];
42
43 if (source === undefined) return undefined;
44
45 let value: Json;
46
47 try {
48 value = JSON.parse(source);
49 } catch {
50 return undefined;
51 }
52
53 if (!isJsonObject(value) || !Array.isArray(value.tips)) return undefined;
54
55 const tips = value.tips.flatMap(toTip).slice(0, MAX_TIPS);
56
57 return tips.length === 0 ? undefined : tips;
58}
59
60function toTip(value: Json): Tip[] {
61 if (!isJsonObject(value)) return [];
62
63 const { kind, said, better, why } = value;
64
65 if (!isString(said) || !isString(better) || !isString(why)) return [];
66
67 if (said.trim() === "" || better.trim() === "") return [];
68
69 if (said.trim() === better.trim()) return [];
70
71 return [
72 {
73 kind: KINDS.find((one) => one === kind) ?? "clarity",
74 said: said.trim(),
75 better: better.trim(),
76 why: clip(why.trim(), WHY_CHARS),
77 },
78 ];
79}
80
81function isJsonObject(value: Json | undefined): value is JsonObject {
82 return value === Object(value) && !Array.isArray(value);
83}
84
85function isString(value: Json | undefined): value is string {
86 return value === String(value);
87}
88
89function clip(text: string, limit: number): string {
90 return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
91}
92types/index.d.ts 12 lines1export type TipKind = "grammar" | "word" | "pronunciation" | "clarity";
2
3export type Tip = { kind: TipKind; said: string; better: string; why: string };
4
5export type Review = { tips: Tip[]; prompt: string };
6
7declare module "claude-code" {
8 interface PluginState {
9 "english-coach": { review: Review | null };
10 }
11}
12