한글 프롬프트를 쓰면 영어로 어떻게 쓸지 입력창 위에 추천

Claude Code에서 한글로 프롬프트를 쓰면, 영어로 어떻게 쓰면 좋을지 입력창 위에 추천해 주는 mod입니다. 영어로 프롬프트를 쓰는 연습을 하려고 만들었습니다.
번역 버튼과 3초 카운트다운이 뜹니다. 0이 되면 자동으로, 버튼을 누르면 바로 번역합니다.. 앞까지)은 그대로 두고 한글 부분만 번역합니다.넣기를 누르거나 Option(Alt)+1, 2를 누르면 그 문장으로 입력창을 바꿉니다. Option+3은 카운트다운을 기다리지 않고 바로 번역합니다. 영문 자판에서 Option+숫자가 ¡ ™ £로 입력돼도 같은 키로 받습니다. 입력창이 비어 있으면 숫자만 눌러도 됩니다. 영어로 보내면 띠가 사라집니다.macos-option-as-alt = left, Orca는 설정 → 터미널의 Option as Alt를 left로 둡니다.기록을 누르면 오른쪽 패널에 지난 추천과 이 세션의 Haiku 토큰 사용량이 나옵니다. 문장마다 복사 버튼이 있습니다. 터미널에선 /en-history로 열고 닫습니다./en으로 켜고 끕니다. /en-history는 기록 패널을 열고 닫습니다./en-term 씨미=ci.me로 고유명사·은어를 등록하면 번역할 때 늘 그 표기를 씁니다. /en-term은 목록, /en-term -씨미는 삭제입니다. 용어집은 JSON 파일(~/.claude/en-coach-glossary.json, 환경 변수 EN_COACH_GLOSSARY로 변경)에 저장돼 세션이 바뀌어도 남고, dotfiles 같은 git 저장소에 두면 머신 간에도 동기화됩니다.추천 한 번마다 Haiku를 한 번 호출합니다.
터미널에서 연 Claude Code 세션의 프롬프트에 입력합니다.
/plugin install en-coach --marketplace Jeong-Jae-Hun/en-coach
y로 마켓플레이스 추가를 승인하고 user 스코프를 고르면 됩니다. user 스코프로 설치하면 데스크톱 앱의 Code 탭에서도 동작합니다.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 471 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, UiPressArgument } from 'claude-code'
3
4import type { Suggestion } from '../types'
5
6const AUTO_SECONDS = 3
7// 가장 싼 모델($0.10/$0.50 per MTok). 구형 Haiku 4.5가 10배 비싸 별칭 대신 ID로 고정한다
8const MODEL = 'claude-haiku-5-5'
9const HISTORY_LIMIT = 50
10const PANE = 'en-coach-history'
11const HISTORY_PANE = { id: PANE, title: '영어 추천 기록', closeOnEscape: true } as const
12const MIN_LENGTH = 6
13
14const suggestion = atom({ plugin: 'en-coach', key: 'suggestion' } as const, null)
15const isBusy = atom({ plugin: 'en-coach', key: 'isBusy' } as const, false)
16const isHidden = atom({ plugin: 'en-coach', key: 'isHidden' } as const, false)
17const history = atom({ plugin: 'en-coach', key: 'history' } as const, [])
18const draft = atom({ plugin: 'en-coach', key: 'draft' } as const, null)
19const usage = atom({ plugin: 'en-coach', key: 'usage' } as const, { calls: 0, input: 0, output: 0 })
20
21const SYSTEM = `You help a Korean developer learn to write prompts for an AI coding agent in English.
22Given a Korean prompt, reply in exactly this format and nothing else:
231. <natural English rewrite, as a fluent developer would type it>
242. <a shorter, more casual alternative>
25---
26- <tip in Korean on one key expression, e.g. "보기가 불편하다 → hard to read">
27- <optional second tip in Korean>
28Write the tips in Korean, never English.
29Then, for each proper noun, product or internal name, or slang whose English form you are not sure of and that the glossary does not cover, add one line: ? <the Korean term as written>
30If the prompt spans several lines, translate every line and keep the line breaks inside each numbered item; write any list in it with "-" bullets, not numbers.
31Keep code identifiers, file paths, commands and English terms unchanged.`
32
33// 넣기로 넣은 추천 뒤에 한글을 이어 쓰면 앞의 영어 문장은 그대로 두고 한글이 시작된 문장부터 번역한다.
34// 경계는 줄바꿈이나 문장부호+공백이다. 한계: 'e.g. ' 같은 약어도 경계로 본다
35export const splitEnglishHead = (text: string) => {
36 const first = text.search(/[가-힣]/)
37 if (first <= 0) {
38 return { head: '', rest: text }
39 }
40 const before = text.slice(0, first)
41 const cut = Math.max(
42 before.lastIndexOf('\n') + 1,
43 ...[...before.matchAll(/[.!?]\s+/g)].map(m => m.index + m[0].length),
44 )
45
46 return { head: text.slice(0, cut), rest: text.slice(cut) }
47}
48
49export const isKorean = (text: string) =>
50 !text.trimStart().startsWith('/') &&
51 /[가-힣]/.test(text) &&
52 splitEnglishHead(text).rest.trim().length >= MIN_LENGTH
53
54// 여러 줄 프롬프트는 번호 줄 아래로 문장이 이어진다 — 다음 번호 줄 전까지 앞 문장에 붙인다.
55// 구분선(---)을 빼먹는 응답이 있어, 그때는 글머리표·모르는 용어 줄에서 문장을 끊는다
56export const parseReply = (source: string, reply: string): Suggestion => {
57 const lines = reply.split('\n').map(line => line.trim())
58 const sep = lines.indexOf('---')
59 const tail = sep === -1 ? lines : lines.slice(sep + 1)
60 const options: string[] = []
61 for (const line of sep === -1 ? lines : lines.slice(0, sep)) {
62 if (/^\d+[.)]\s+/.test(line)) {
63 options.push(line.replace(/^\d+[.)]\s+/, ''))
64 } else if (sep === -1 && /^([-*·•]|\?)\s+/.test(line)) {
65 break
66 } else if (options.length > 0) {
67 options[options.length - 1] += `\n${line}`
68 }
69 }
70 const notes = tail
71 .filter(line => /^[-*·•]\s+/.test(line))
72 .map(line => line.replace(/^[-*·•]\s+/, ''))
73 const unknown = tail
74 .filter(line => /^\?\s+/.test(line))
75 .map(line => line.replace(/^\?\s+/, ''))
76 .filter(term => term.length > 0 && source.includes(term))
77
78 return { source, options: options.slice(0, 2).map(option => option.trim()), notes, unknown }
79}
80
81const copyText = async ($: EngineInterface, text: string, surface: UiPressArgument['surface']) => {
82 const r = await $.ui.copy({ text, surface })
83 $.ui.toast(r.isCopied ? '복사했습니다' : '이 화면에선 복사할 수 없습니다')
84}
85
86// ─── 숫자 키 ───
87// 맥 영문 자판에서 Option+1·2·3 은 수정키 없이 ¡ ™ £ 로 들어온다(한글 자판은 meta 가 붙은 숫자).
88// 그래서 두 모양을 다 받는다. 한계: 이 세 글자를 일부러 칠 수는 없게 된다
89const OPTION_DIGITS: Record<string, number> = { '¡': 1, '™': 2, '£': 3 }
90
91export const pickedNumber = (key: { key: string; ctrl?: true; meta?: true } | undefined, inputText: string) => {
92 if (key === undefined) {
93 return NaN
94 }
95 if (key.ctrl || key.meta) {
96 return Number(key.key)
97 }
98
99 return OPTION_DIGITS[inputText] ?? NaN
100}
101
102// ─── 용어집 ───
103// 파일로 둬서 dotfiles 처럼 git 으로 머신 간 동기화할 수 있게 한다.
104// EN_COACH_GLOSSARY 로 경로를 바꾼다(앞의 ~ 는 HOME). 없으면 ~/.claude/en-coach-glossary.json
105const LEGACY_STORE_KEY = 'glossary'
106
107type Glossary = Record<string, string>
108
109export type TermCommand =
110 | { kind: 'list' }
111 | { kind: 'add'; from: string; to: string }
112 | { kind: 'remove'; from: string }
113
114export const parseTermArgs = (args: string): TermCommand => {
115 const text = args.trim()
116 if (text === '') {
117 return { kind: 'list' }
118 }
119 if (text.startsWith('-')) {
120 return { kind: 'remove', from: text.slice(1).trim() }
121 }
122 const [from = '', ...rest] = text.split('=')
123 const to = rest.join('=').trim()
124
125 return to === '' ? { kind: 'list' } : { kind: 'add', from: from.trim(), to }
126}
127
128// 문장에 나온 용어만 보낸다 — 용어집이 커져도 호출마다 전부 싣지 않게
129export const glossaryPrompt = (text: string, glossary: Glossary) => {
130 const used = Object.entries(glossary).filter(([from]) => text.includes(from))
131 if (used.length === 0) {
132 return text
133 }
134
135 return `Glossary (always use exactly these English forms):\n${used
136 .map(([from, to]) => `${from} → ${to}`)
137 .join('\n')}\n\nKorean prompt:\n${text}`
138}
139
140const glossaryPath = async ($: EngineInterface) => {
141 const home = (await $.env.get('HOME')) ?? ''
142 const raw = (await $.env.get('EN_COACH_GLOSSARY')) ?? '~/.claude/en-coach-glossary.json'
143
144 return raw.replace(/^~(?=\/)/, home)
145}
146
147export const sortGlossary = (glossary: Glossary): Glossary =>
148 Object.fromEntries(Object.entries(glossary).sort(([a], [b]) => a.localeCompare(b, 'ko')))
149
150const writeGlossary = async ($: EngineInterface, glossary: Glossary) =>
151 $.fs.write(await glossaryPath($), `${JSON.stringify(sortGlossary(glossary), null, 2)}\n`)
152
153// 예전 버전이 $.store 에 넣어 둔 용어는 파일로 합친 뒤 지운다 — 등록한 용어를 잃지 않게
154const readGlossary = async ($: EngineInterface): Promise<Glossary> => {
155 const path = await glossaryPath($)
156 const fromFile = (await $.fs.exists(path)) ? (JSON.parse(await $.fs.read(path)) as Glossary) : {}
157 const legacy = (await $.store.get(LEGACY_STORE_KEY)) as Glossary | undefined
158 if (legacy === undefined) {
159 return fromFile
160 }
161 const merged = { ...legacy, ...fromFile }
162 await writeGlossary($, merged)
163 await $.store.delete(LEGACY_STORE_KEY)
164
165 return merged
166}
167
168const addTerm = async ($: EngineInterface, from: string, to: string) =>
169 writeGlossary($, { ...(await readGlossary($)), [from]: to })
170
171export const formatTokens = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`)
172
173// 새 번역이 시작되면 이전 요청은 끊는다
174let stop = new AbortController()
175
176const suggest = async ($: EngineInterface, text: string, force = false) => {
177 const current = await read($, suggestion)
178 if (!force && current?.source === text) {
179 return
180 }
181
182 stop.abort()
183 const mine = new AbortController()
184 stop = mine
185 await update($, isBusy, () => true)
186 const { head, rest } = splitEnglishHead(text)
187 try {
188 const r = await $.model.complete(
189 { model: MODEL, system: SYSTEM, prompt: glossaryPrompt(rest, await readGlossary($)), effort: 'low', maxTokens: 800, timeoutMs: 15000 },
190 { signal: mine.signal },
191 )
192 // 끊긴 요청도 비용은 든다 — 응답 여부와 상관없이 센다
193 await update($, usage, u => ({
194 calls: u.calls + 1,
195 input: u.input + r.usage.input_tokens + (r.usage.cache_read_input_tokens ?? 0) + (r.usage.cache_creation_input_tokens ?? 0),
196 output: u.output + r.usage.output_tokens,
197 }))
198 if (r.isAnswered && stop === mine) {
199 const reply = parseReply(text, r.text)
200 const next = { ...reply, options: reply.options.map(option => head + option) }
201 await update($, suggestion, () => next)
202 await update($, history, list =>
203 [next, ...list.filter(one => one.source !== text)].slice(0, HISTORY_LIMIT),
204 )
205 }
206 if (!r.isAnswered && r.reason !== 'aborted') {
207 $.ui.toast(`en-coach: ${r.reason}`)
208 }
209 } finally {
210 // 끊긴 이전 요청이 새 요청의 '번역 중' 표시를 지우거나 남기지 않게, 마지막 요청만 끈다
211 if (stop === mine) {
212 await update($, isBusy, () => false)
213 }
214 }
215}
216
217// 띠에서 바로 등록한 용어로 같은 문장을 다시 번역한다
218const saveTerm = async ($: EngineInterface, term: string, english: string, source: string) => {
219 const to = english.trim()
220 if (to === '') {
221 return
222 }
223 await addTerm($, term, to)
224 await suggest($, source, true)
225}
226
227// 타이핑이 멈추면 초를 세다가 0이 되면 번역한다 — 버튼으로 바로 당길 수 있다
228let tick: Timer | undefined
229
230const cancelDraft = async ($: EngineInterface) => {
231 tick?.cancel()
232 tick = undefined
233 await update($, draft, () => null)
234}
235
236const translateNow = async ($: EngineInterface, text: string) => {
237 await cancelDraft($)
238 await suggest($, text)
239}
240
241const countDown = async ($: EngineInterface) => {
242 const d = await read($, draft)
243 if (d === null) {
244 return
245 }
246 if (d.secondsLeft <= 1) {
247 await translateNow($, d.text)
248 return
249 }
250 await update($, draft, () => ({ ...d, secondsLeft: d.secondsLeft - 1 }))
251}
252
253export const register: Register = on => {
254
255 on('session.start', async ($, e, next) => {
256 await $.command.register({
257 name: 'en',
258 description: '영어 프롬프트 추천 켜기/끄기',
259 })
260 await $.command.register({
261 name: 'en-history',
262 description: '영어 추천 기록 패널 열기/닫기',
263 })
264 await $.command.register({
265 name: 'en-term',
266 description: '영어 추천 용어집: 목록 / 한글=영어 추가 / -한글 삭제',
267 argumentHint: '[씨미=ci.me | -씨미]',
268 })
269
270 return next(e)
271 })
272
273 on('command.run', { command: 'en-term' }, async ($, e) => {
274 const cmd = parseTermArgs(e.args)
275 const glossary = await readGlossary($)
276 if (cmd.kind === 'add') {
277 await addTerm($, cmd.from, cmd.to)
278 return { text: `용어 추가: ${cmd.from} → ${cmd.to}` }
279 }
280 if (cmd.kind === 'remove') {
281 const { [cmd.from]: removed, ...rest } = glossary
282 await writeGlossary($, rest)
283 return { text: removed === undefined ? `없는 용어: ${cmd.from}` : `용어 삭제: ${cmd.from}` }
284 }
285 const lines = Object.entries(glossary).map(([from, to]) => `- ${from} → ${to}`)
286
287 return {
288 text: lines.length === 0 ? '용어집이 비었습니다. `/en-term 씨미=ci.me` 처럼 추가하세요.' : `용어집\n${lines.join('\n')}`,
289 }
290 })
291
292 on('command.run', { command: 'en' }, async $ => {
293 await update($, isHidden, was => !was)
294 const hidden = await read($, isHidden)
295
296 return { text: hidden ? '영어 추천을 껐습니다.' : '영어 추천을 켰습니다.' }
297 })
298
299 // 터미널에선 띠의 버튼이 Ctrl+X Tab 으로 초점을 줘야 눌려서, 명령으로도 여닫게 한다
300 on('command.run', { command: 'en-history' }, async $ => {
301 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
302 await $.ui.close({ id: PANE })
303 return { text: '기록 패널을 닫았습니다.' }
304 }
305 await $.ui.open(HISTORY_PANE)
306
307 return { text: '기록 패널을 열었습니다.' }
308 })
309
310 // 키 입력마다 카운트다운을 처음부터 다시 센다 — 타이핑 중엔 Haiku를 부르지 않게
311 // 입력창에 글이 있을 땐 숫자가 그대로 입력돼야 하니 Option+숫자만 가로챈다.
312 // prompt.edit 은 글자를 넣는 키에만 온다: Ctrl+숫자는 아무것도 넣지 않아 데스크톱 앱에선 오지 않는다.
313 // 그래도 Ctrl+숫자를 편집으로 보내는 터미널(kitty 키 프로토콜 등)을 위해 ctrl 도 받아 둔다
314 // 입력창이 비었을 땐 띠의 버튼 hotkey 가 맨 숫자를 받는다 (엔진 규칙)
315 on('prompt.edit', async ($, e, next) => {
316 const picked = pickedNumber(e.key, e.inputText)
317 if (picked === 3) {
318 const d = await read($, draft)
319 if (d !== null) {
320 void translateNow($, d.text)
321 return { text: e.text, cursor: e.cursor }
322 }
323 }
324 const option = (await read($, suggestion))?.options[picked - 1]
325 if (option !== undefined) {
326 return { text: option, cursor: option.length }
327 }
328
329 const box = await next(e)
330 tick?.cancel()
331 if (isKorean(box.text) && (await read($, suggestion))?.source !== box.text) {
332 await update($, draft, () => ({ text: box.text, secondsLeft: AUTO_SECONDS }))
333 tick = $.clock.every(1000, () => void countDown($))
334 } else {
335 await cancelDraft($)
336 }
337
338 return box
339 })
340
341 // 엔터를 쳤으면 그 문장은 끝났다 — 카운트다운·진행 중 번역을 멈추고 띠를 치운다.
342 // 컨트롤러를 갈아 끼워, 끊겼는데도 답이 와 버린 요청이 띠를 다시 채우지 못하게 한다
343 on('prompt.submit', async ($, e, next) => {
344 await cancelDraft($)
345 stop.abort()
346 stop = new AbortController()
347 await update($, isBusy, () => false)
348 await update($, suggestion, () => null)
349
350 return next(e)
351 })
352
353 // 오른쪽 패널 대신 입력창 바로 위 띠에 띄운다
354 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
355 const s = await read($, suggestion)
356 const busy = await read($, isBusy)
357 const d = await read($, draft)
358 if (e.props.hasSurvey || (await read($, isHidden)) || (s === null && !busy && d === null)) {
359 return next(e)
360 }
361
362 const table = $.ui.resolve(e)
363 const { Box, Text, Button } = table
364 // 입력칸이 없는 화면(모바일 등)에선 명령어 안내로 대신한다
365 const Input = 'Input' in table ? table.Input : undefined
366 // 터미널은 왼쪽 Option 을 Alt(meta) 로 보내 글자가 안 들어온다 — 오른쪽 Option 만 글자를 넣게 설정해 둔다
367 const opt = e.surface === 'terminal' ? '오른쪽 ⌥' : '⌥'
368
369 return (
370 <Box flexDirection="column" gap={1}>
371 <Box justifyContent="space-between">
372 {d !== null ? (
373 <Box>
374 <Button key="translate" label="번역" hotkey="3" onPress={() => translateNow($, d.text)} />
375 <Text dimColor> {d.secondsLeft}초 뒤 자동 번역 · {opt}3 바로 번역</Text>
376 </Box>
377 ) : (
378 <Text dimColor>{busy ? '번역 중…' : `English · ${opt}+숫자로 넣기 · /en-history 기록`}</Text>
379 )}
380 <Box gap={1}>
381 <Button key="history" label="기록" onPress={() => $.ui.open(HISTORY_PANE)} />
382 {s !== null && <Button key="clear" label="닫기" onPress={() => update($, suggestion, () => null)} />}
383 </Box>
384 </Box>
385 {/* 문장은 Text 로 그려 끝까지 줄바꿈되게 한다(데스크톱 버튼은 한 줄로 잘린다). 번호는 같은 Text 안이라 첫 줄에 붙는다 */}
386 {s?.options.map((option, i) => (
387 <Text wrap="wrap">
388 <Text color="suggestion">{i + 1} </Text>
389 {option}
390 </Text>
391 ))}
392 {s !== null && s.options.length > 0 && (
393 <Box gap={1}>
394 {s.options.map((option, i) => (
395 <Button key={`use-${i}`} hotkey={`${i + 1}`} label={`${i + 1}번 넣기`} onPress={() => $.prompt.fill({ text: option, mode: 'replace' })} />
396 ))}
397 </Box>
398 )}
399 {s?.notes.map(note => (
400 <Text dimColor>· {note}</Text>
401 ))}
402 {s !== null && s.unknown.length > 0 && (
403 <Box flexDirection="column">
404 <Text color="warning">모르는 용어가 있어요. 영어 표기를 적어 용어집에 추가하면 다시 번역합니다.</Text>
405 {s.unknown.map(term =>
406 Input ? (
407 <Input
408 key={`term-${term}`}
409 label={term}
410 placeholder="영어 표기"
411 submitLabel="추가"
412 onSubmit={(value: string) => void saveTerm($, term, value, s.source)}
413 />
414 ) : (
415 <Text dimColor>/en-term {term}=영어표기</Text>
416 ),
417 )}
418 </Box>
419 )}
420 </Box>
421 )
422 })
423
424 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
425 const { Box, Text, Button } = $.ui.resolve(e)
426 const list = await read($, history)
427 const u = await read($, usage)
428
429 return (
430 <Box flexDirection="column" gap={1}>
431 <Box justifyContent="space-between">
432 <Text bold>기록 {list.length}개</Text>
433 <Box gap={1}>
434 <Text dimColor>
435 Haiku {u.calls}회 · 입력 {formatTokens(u.input)} · 출력 {formatTokens(u.output)}
436 </Text>
437 <Button key="close-history" label="닫기" onPress={() => $.ui.close({ id: PANE })} />
438 </Box>
439 </Box>
440 {list.length === 0 && <Text dimColor>한글로 프롬프트를 쓰면 여기에 쌓입니다.</Text>}
441 {list.map((one, n) => (
442 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
443 <Text dimColor>{one.source}</Text>
444 <Box flexDirection="column" marginTop={1}>
445 {one.options.map((option, i) => (
446 <Box>
447 <Text color="suggestion">{i + 1} </Text>
448 <Text bold={i === 0}>{option}</Text>
449 </Box>
450 ))}
451 </Box>
452 {one.notes.length > 0 && (
453 <Box flexDirection="column" marginTop={1}>
454 {one.notes.map(note => (
455 <Text color="subtle">팁 · {note}</Text>
456 ))}
457 </Box>
458 )}
459 {/* 패널 글자는 선택이 안 되는 화면이 있어 복사 버튼을 따로 둔다 */}
460 <Box gap={1} marginTop={1}>
461 {one.options.map((option, i) => (
462 <Button key={`copy-${n}-${i}`} dimColor label={`${i + 1} 복사`} onPress={press => void copyText($, option, press.surface)} />
463 ))}
464 </Box>
465 </Box>
466 ))}
467 </Box>
468 )
469 })
470}
471types/index.d.ts 17 lines1export type Usage = { calls: number; input: number; output: number }
2
3export type Draft = { text: string; secondsLeft: number }
4
5export type Suggestion = {
6 source: string
7 options: string[]
8 notes: string[]
9 unknown: string[]
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'en-coach': { suggestion: Suggestion | null; isBusy: boolean; isHidden: boolean; history: Suggestion[]; usage: Usage; draft: Draft | null }
15 }
16}
17