Check the English in your prompt box and offer a better phrasing, on demand

已废弃:parrot-translate v0.4.3 起在提交时自动完成「外语→英文 + 英文修语法」(见同仓库 parrot-translate 的「出站」一节),本插件已从
CLAUDE_CODE_PLUGIN_DIRS和keybindings.json移除,代码留作参考。Ctrl+G恢复为 Claude Code 默认的外部编辑器。
检查输入框里的英文,指出语法问题,并给出更自然的写法。手动触发,结果可直接替换回输入框。
写给母语是中文、但想用英文跟 Claude 对话的人:不确定语法对不对,或者某个中文词该用哪个英文词时,按一下键就知道了。
已经装好了。两种方式:
1. 全局加载(当前采用) —— 写进 ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/jhao/Projects/parrot/integrations/claude-grammar"
}
}
2. 单次会话加载(开发时改代码会热重载):
claude --plugin-dir ~/Projects/parrot/integrations/claude-grammar
在输入框里写好英文(可以夹中文),然后:
| 触发 | 操作 |
|---|---|
Ctrl+G | 检查当前输入框(单键,推荐) |
Ctrl+X Ctrl+G | 同上(备用) |
/grammar | 同上 |
/grammar <文本> | 直接检查给定的文本(不读输入框) |
Ctrl+G原本是 Claude Code 的“用外部编辑器打开输入框”(chat:externalEditor),现在被本插件占用。外部编辑器仍在Ctrl+X Ctrl+E(Claude Code 的另一个默认绑定,已实测确认仍可用)。不想要这个取舍的话,把
~/.claude/keybindings.json里的ctrl+g换成别的没被占用的键(ctrl+e、ctrl+k、ctrl+y之类),或者删掉那行、继续用Ctrl+X Ctrl+G。
面板里(界面和解释都是英文):
| 按键 | 操作 |
|---|---|
a | Replace prompt —— 用改写稿替换输入框,然后按 Enter 发送 |
c | Copy —— 只复制改写稿 |
o | Revert —— 还原成你原来写的原文 |
q / Esc | Close —— 关闭并把原文还回输入框 |
面板打开时输入框是空的,这是故意的:Claude Code 只在输入框为空时把键盘交给面板(否则
focus会被拒),所以检查期间草稿先收进面板,Esc/q会原样还回去。如果面板没拿到焦点(终端太窄等),面板里会提示按
Ctrl+X Tab手动聚焦。
面板列出三块:逐条问题(原文 → 修改 + 英文说明)、Chinese → English(你夹在里面的中文词对应哪些英文说法)、以及 Rewritten prompt(整段改写稿)。
在 claude -p / SDK 这类没有界面的场合,报告会作为文本直接返回,不画面板。
快捷键定义在 ~/.claude/keybindings.json,改键就改那里。
Claude Code 的 mods API 提供了三个刚好够用的能力,不需要额外的 API key:
$.prompt.read() —— 读当前草稿(含光标位置)$.prompt.fill({ text, mode }) —— 把文本写回输入框(replace / append / insert)$.model.complete({ model, system, prompt }) —— 走本会话自己的模型凭证跑一次无历史、无工具的补全,不占对话上下文、不产生 transcript模型固定用 haiku(快且便宜)。改 hooks/register.js 里的 MODEL / MAX_TOKENS 即可。
解释语言由 SYSTEM 提示词里的这条控制(现在要求全英文):
- Explain every "why" and "note" in plain English, not Chinese.
想改回中文解释,把这条改成 in Simplified Chinese,以及同段的 "why" in Simplified Chinese / "note" in Simplified Chinese 注释。
prompt.edit 钩子有 50ms 预算,装不下一次 LLM 调用,所以不做实时下划线;改成手动触发时读草稿。prompt.read() 在命令已提交后可能拿到空串,因此用 prompt.edit 维护一份 lastDraft 兜底,并支持 /grammar <文本> 显式传入。$.session.surfaces().length === 0($.ui.open() 在没有界面时也返回 isPlaced: true,不能用它判断)。integrations/claude-grammar/
├── .claude-plugin/
│ ├── plugin.json # 插件清单
│ └── types/ # Claude Code 自动生成的 mods API 类型(勿手改)
├── hooks/
│ ├── hooks.json # 指向下面的模块
│ └── register.js # 全部实现
└── README.md
claude plugin validate ./integrations/claude-grammar # 静态检查
claude --debug # 看 keybindings / mod 加载错误
在会话里 /plugin → Installed 可以看到它是否加载。
keybindings.json 要重启会话(新建的文件尤其):文件在启动时读入,热重载对已存在的文件才可靠。Option+G 这类 meta 绑定在本机无效:终端没把 Option 当 Meta 发送,macOS 直接把 Option+G 变成了字符 ©。这是终端设置问题,不是插件问题,所以改用 Ctrl+G。想用 meta 得先在终端里打开 “Option as Meta”(Terminal.app / iTerm2 / Ghostty / WezTerm 各有开关)。Ctrl+G 占用了原来的外部编辑器快捷键;外部编辑器保留在 Ctrl+X Ctrl+E。/grammar 一样”执行命令(会短暂把 /grammar 放进输入框),但已被实测确认不会吃掉你的草稿。用 expect 驱动真实的 TUI 会话验证过(非 headless):
Ctrl+X Ctrl+G ✓ 打开面板,草稿保留Ctrl+G ✓ 单键生效,覆盖了默认的 chat:externalEditorOption+G(\x1bg)✓ 在能发送 Meta 的终端里有效;本机终端不发送,会打出 ©Ctrl+X Ctrl+E ✓ 外部编辑器仍能打开(未被破坏)a ✓ 关面板 + toast + 输入框变成改写稿hooks/register.js 284 lines1/**
2 * parrot-grammar — 检查输入框里的英文,并给出更自然的写法。
3 *
4 * 手动触发:/grammar 命令,或把键位绑到 command:grammar(见 README)。
5 *
6 * 为什么能读输入框:mods API 的 $.prompt.read() 读当前草稿、
7 * $.prompt.fill() 写回;$.model.complete() 走本会话自己的模型凭证,
8 * 不需要额外的 API key,也不占用对话上下文。
9 */
10
11const PANE = 'parrot-grammar'
12const MODEL = 'haiku' // 便宜、快;够做语法判断
13const MAX_TOKENS = 2000
14const TIMEOUT_MS = 30_000
15
16// 最后一次非斜杠命令的草稿。prompt.read() 在命令已提交后可能拿到空串,
17// 用它兜底。
18let lastDraft = ''
19
20// 面板状态,一次检查一份
21let state = null // { phase: 'checking' | 'done' | 'error', draft, result, error }
22
23const SYSTEM = `You are a writing assistant inside a coding agent's prompt box.
24The user writes prompts to an AI coding agent, usually in English, and is a Chinese speaker.
25Rewrite the draft into natural, correct English, and report what was wrong.
26
27Return STRICT JSON only, no markdown fence, no prose, exactly this shape:
28{
29 "ok": boolean, // true when the English is already correct and natural
30 "corrected": string, // the whole draft in natural English
31 "issues": [{ "wrong": string, "right": string, "why": string }], // "why" in English, one short clause
32 "terms": [{ "zh": string, "en": string[], "note": string }] // one entry per Chinese fragment in the draft; "note" in English
33}
34
35Rules:
36- Keep code, identifiers, file paths, commands, flags, URLs and numbers byte-for-byte unchanged.
37- Preserve the request's meaning, scope and tone. Do not add or drop requests.
38- If the draft contains Chinese, render it as natural English inside "corrected", and list each Chinese fragment in "terms" with the English the user most likely wants.
39- A Chinese fragment is NOT a grammar issue: list it only in "terms", never in "issues".
40- "corrected" is the full draft, ready to send, not a diff.
41- Explain every "why" and "note" in plain English, not Chinese.
42- Keep every explanation under 15 words, no jargon, no grammar terminology.`
43
44/** 从模型回复里抠出 JSON,容忍 ``` 围栏和前后废话。 */
45function parseResult(text) {
46 const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/)
47 const body = (fenced ? fenced[1] : text).trim()
48 const start = body.indexOf('{')
49 const end = body.lastIndexOf('}')
50 if (start === -1 || end === -1) throw new Error('the model did not return JSON')
51 const raw = JSON.parse(body.slice(start, end + 1))
52 const arr = (v) => (Array.isArray(v) ? v : [])
53 return {
54 ok: raw?.ok === true,
55 corrected: typeof raw?.corrected === 'string' ? raw.corrected : '',
56 issues: arr(raw?.issues).map((i) => ({ wrong: String(i?.wrong ?? ''), right: String(i?.right ?? ''), why: String(i?.why ?? '') })),
57 terms: arr(raw?.terms).map((t) => ({ zh: String(t?.zh ?? ''), en: arr(t?.en).map(String), note: String(t?.note ?? '') })),
58 }
59}
60
61async function check($, draft) {
62 const r = await $.model.complete({
63 model: MODEL,
64 system: SYSTEM,
65 prompt: draft,
66 maxTokens: MAX_TOKENS,
67 timeoutMs: TIMEOUT_MS,
68 })
69 if (!r.isAnswered) throw new Error(`the model did not answer (${r.reason ?? 'unknown'})`)
70 const result = parseResult(r.text)
71 // 模型没给出改写稿时退回原文,"替换"永远不把输入框清空
72 if (!result.corrected.trim()) result.corrected = draft
73 return result
74}
75
76/** 没有面板可用时(-p、SDK、Desktop 不支持 pane)退回纯文本报告。 */
77function formatReport(draft, result) {
78 const { ok, corrected, issues = [], terms = [] } = result
79 const lines = ['parrot-grammar']
80 if (ok && issues.length === 0 && terms.length === 0) lines.push('✓ No grammar issues.')
81 for (const it of issues) lines.push(`- ${it.wrong} → ${it.right} (${it.why})`)
82 for (const t of terms) lines.push(`- ${t.zh} → ${t.en.join(' / ')}${t.note ? ' ' + t.note : ''}`)
83 lines.push('', 'Rewritten:', corrected)
84 return lines.join('\n')
85}
86
87/** 触发检查:读草稿 → 开面板 → 调模型。 */
88async function run($, e) {
89 const read = (await $.prompt.read()).text
90 const draft = (e.args || '').trim() || (read && !read.startsWith('/') ? read : '') || lastDraft
91 if (!draft.trim()) {
92 $.ui.toast('parrot-grammar: the prompt box is empty — write something first')
93 return {}
94 }
95
96 const headless = (await $.session.surfaces()).length === 0
97 let focused = false
98
99 state = { phase: 'checking', draft, focused: false }
100 if (!headless) {
101 // A pane only gets the keyboard while the composer is empty, so move the
102 // draft out of the box first. state.draft keeps it until we put it back.
103 await $.prompt.fill({ text: '', mode: 'replace' })
104 await $.ui.open({ id: PANE, title: 'parrot · English check', focus: true, closeOnEscape: true })
105 focused = (await $.ui.panes()).some((p) => p.id === PANE && p.isFocused)
106 // No focus (narrow terminal, or the pane was refused): never leave the box empty.
107 if (!focused) await $.prompt.fill({ text: draft, mode: 'replace' })
108 state = { phase: 'checking', draft, focused }
109 $.ui.invalidate('ui.render')
110 }
111
112 let result, error
113 try {
114 result = await check($, draft)
115 } catch (err) {
116 error = err instanceof Error ? err.message : String(err)
117 }
118
119 state = error ? { phase: 'error', draft, error, focused } : { phase: 'done', draft, result, focused }
120
121 // 没有可视界面(-p / SDK):把报告当文本返回,让 headless 也能用
122 if (headless) {
123 return { text: error ? `parrot-grammar: ${error}` : formatReport(draft, result) }
124 }
125
126 $.ui.invalidate('ui.render')
127 return {}
128}
129
130function renderPane($, e) {
131 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
132 const close = () => $.ui.close({ id: PANE })
133
134 if (!state) {
135 return Box({ flexDirection: 'column', children: [Text({ children: ['Run /grammar to check the prompt box.'] })] })
136 }
137
138 const header = Text({
139 bold: true,
140 children: [state.phase === 'checking' ? 'Checking…' : state.phase === 'error' ? 'Check failed' : 'English check'],
141 })
142
143 if (state.phase === 'checking') {
144 return Box({ flexDirection: 'column', children: [header, Text({ dimColor: true, children: ['Reading the prompt box and asking the model…'] })] })
145 }
146
147 if (state.phase === 'error') {
148 return Box({
149 flexDirection: 'column',
150 children: [header, Text({ color: 'red', children: [state.error] }), Button({ key: 'close', label: 'Close', hotkey: 'q', plain: true, onPress: close })],
151 })
152 }
153
154 const { ok, corrected, issues = [], terms = [] } = state.result
155 const children = [header]
156
157 if (ok && issues.length === 0 && terms.length === 0) {
158 children.push(Text({ color: 'green', children: ['✓ No grammar issues — send it.'] }))
159 }
160
161 if (issues.length > 0) {
162 children.push(Text({ children: [' '] }))
163 for (const [i, it] of issues.entries()) {
164 children.push(
165 Box({
166 key: 'issue-' + i,
167 flexDirection: 'column',
168 children: [
169 Box({
170 flexDirection: 'row',
171 columnGap: 1,
172 children: [Text({ color: 'red', children: [it.wrong] }), Text({ dimColor: true, children: ['→'] }), Text({ color: 'green', children: [it.right] })],
173 }),
174 Text({ dimColor: true, children: [' ' + it.why] }),
175 ],
176 }),
177 )
178 }
179 }
180
181 if (terms.length > 0) {
182 children.push(Text({ children: [' '] }))
183 children.push(Text({ bold: true, children: ['Chinese → English'] }))
184 for (const [i, t] of terms.entries()) {
185 children.push(
186 Text({
187 key: 'term-' + i,
188 children: [t.zh + ' → ' + t.en.join(' / ') + (t.note ? ' ' + t.note : '')],
189 }),
190 )
191 }
192 }
193
194 children.push(Text({ children: [' '] }))
195 children.push(Text({ dimColor: true, children: ['Rewritten prompt:'] }))
196 children.push(Markdown({ key: 'corrected', text: corrected }))
197 children.push(Text({ children: [' '] }))
198 if (!state.focused) {
199 children.push(Text({ dimColor: true, children: ['This panel has no keyboard focus — press Ctrl+X Tab to focus it, then a / c / o / q.'] }))
200 children.push(Text({ children: [' '] }))
201 }
202 children.push(
203 Box({
204 flexDirection: 'row',
205 columnGap: 2,
206 children: [
207 Button({
208 key: 'apply',
209 label: 'Replace prompt',
210 hotkey: 'a',
211 autoFocus: true,
212 onPress: async () => {
213 const filled = await $.prompt.fill({ text: corrected, mode: 'replace' })
214 await close()
215 $.ui.toast(filled.isFilled ? 'Prompt replaced — press Enter to send' : 'No prompt box to write to')
216 },
217 }),
218 Button({
219 key: 'copy',
220 label: 'Copy',
221 hotkey: 'c',
222 plain: true,
223 onPress: async () => {
224 await $.ui.copy({ text: corrected, surface: e.surface })
225 $.ui.toast('Rewritten prompt copied')
226 },
227 }),
228 Button({
229 key: 'restore',
230 label: 'Revert',
231 hotkey: 'o',
232 plain: true,
233 onPress: async () => {
234 const filled = await $.prompt.fill({ text: state.draft, mode: 'replace' })
235 await close()
236 $.ui.toast(filled.isFilled ? 'Original restored' : 'No prompt box to write to')
237 },
238 }),
239 Button({ key: 'close', label: 'Close', hotkey: 'q', plain: true, onPress: close }),
240 ],
241 }),
242 )
243
244 return Box({ flexDirection: 'column', children })
245}
246
247export function register(on) {
248 // 记住草稿,供命令触发时兜底
249 on('prompt.edit', async ($, e, next) => {
250 const r = await next(e)
251 const text = (r && r.text) ?? e.text ?? ''
252 if (text && !text.trimStart().startsWith('/')) lastDraft = text
253 return r
254 })
255
256 on('session.start', async ($, e, next) => {
257 await $.command.register({ name: 'grammar', description: 'Check the English in the prompt box and offer a better phrasing', argumentHint: '[optional text to check]' })
258 return next(e)
259 })
260
261 on('command.run', { command: 'grammar' }, async ($, e) => {
262 try {
263 return await run($, e)
264 } catch (err) {
265 // 不让错误静默:命令 hook 抛错会被跳过,什么都看不到
266 return { text: 'parrot-grammar error: ' + (err instanceof Error ? err.message : String(err)) }
267 }
268 })
269
270 // 面板关闭时,如果输入框还是空的(用户按了 Esc / 点了 ✕),把原文还回去
271 on('ui.close', { id: PANE }, async ($, e, next) => {
272 const original = state?.draft
273 if (original && !(await $.prompt.read()).text.trim()) {
274 await $.prompt.fill({ text: original, mode: 'replace' })
275 }
276 return next(e)
277 })
278
279 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
280 if (e.requestId !== PANE) return next(e)
281 return renderPane($, e)
282 })
283}
284