SLOPSHOPPER

parrot-grammar

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

newpanecommandtoastpromptmodel
★ 7v0.1.0no licenseupdated 2026-10-06jhao0413/parrot-agent-extensions/claude/parrot-grammar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · parrot-grammar
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ parrot-grammar │ ⏺ Read(src/auth.ts) │ parrot-grammar: the prompt box is empty — │ ⎿ Read 6 lines │ write something first │ ⏺ 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 › /grammar ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

parrot-grammar (Claude Code)

已废弃: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。

面板里(界面和解释都是英文):

按键操作
aReplace prompt —— 用改写稿替换输入框,然后按 Enter 发送
cCopy —— 只复制改写稿
oRevert —— 还原成你原来写的原文
q / EscClose —— 关闭并把原文还回输入框

面板打开时输入框是空的,这是故意的: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。
  • 快捷键触发时 Claude Code 是“像输入了 /grammar 一样”执行命令(会短暂把 /grammar 放进输入框),但已被实测确认不会吃掉你的草稿。
  • 面板按钮的热键只在面板拿到键盘时生效 —— 所以才有“检查期间输入框为空”这个设计。

实测记录

用 expect 驱动真实的 TUI 会话验证过(非 headless):

  • Ctrl+X Ctrl+G ✓ 打开面板,草稿保留
  • Ctrl+G ✓ 单键生效,覆盖了默认的 chat:externalEditor
  • Option+G(\x1bg)✓ 在能发送 Meta 的终端里有效;本机终端不发送,会打出 ©
  • Ctrl+X Ctrl+E ✓ 外部编辑器仍能打开(未被破坏)
  • 面板拿到键盘焦点,a ✓ 关面板 + toast + 输入框变成改写稿
  • 窄终端(80 列)面板以“输入框上方的框”呈现,内容会被截断
Source 1 files
hooks/register.js 284 lines
1/**
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