SLOPSHOPPER

en-coach

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

panebandcommandtoastpromptmodel
★ 1v0.1.3MITupdated 2026-10-19Jeong-Jae-Hun/en-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · en-coach
│ ┃ 영어 추천 기록 ✕ › fix the failing auth test and add an audit log call │ ┃ 기록 0개Haiku 0회 · 입력 0 · 출력 0 [ 닫기 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ 한글로 프롬프트를 쓰면 여기에 쌓입니다. ⎿ Read 6 lines │ ⏺ 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 │ │ › /en │ ⎿ en-coach: 영어 추천을 껐습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 영어 추천 기록
기록 0개 Haiku 0회 · 입력 0 · 출력 0 [ 닫기 ] 한글로 프롬프트를 쓰면 여기에 쌓입니다.
README

en-coach

Claude Code에서 한글로 프롬프트를 쓰면, 영어로 어떻게 쓰면 좋을지 입력창 위에 추천해 주는 mod입니다. 영어로 프롬프트를 쓰는 연습을 하려고 만들었습니다.

  • 한글로 치다가 멈추면 입력창 위에 번역 버튼과 3초 카운트다운이 뜹니다. 0이 되면 자동으로, 버튼을 누르면 바로 번역합니다.
  • Haiku가 영어 문장 2개와 한글 팁을 만들어 입력창 위에 띄웁니다. 여러 줄 프롬프트는 줄바꿈을 살려 통째로 번역합니다.
  • 추천을 넣은 뒤 한글을 이어 쓰면 앞의 영어 문장(줄바꿈이나 . 앞까지)은 그대로 두고 한글 부분만 번역합니다.
  • 프롬프트를 보내면(Enter) 카운트다운과 진행 중인 번역을 멈추고 띠를 치웁니다. 보낸 문장은 번역하지 않습니다.
  • 넣기를 누르거나 Option(Alt)+1, 2를 누르면 그 문장으로 입력창을 바꿉니다. Option+3은 카운트다운을 기다리지 않고 바로 번역합니다. 영문 자판에서 Option+숫자가 ¡ ™ £로 입력돼도 같은 키로 받습니다. 입력창이 비어 있으면 숫자만 눌러도 됩니다. 영어로 보내면 띠가 사라집니다.
  • 터미널은 Option을 Alt(meta)로 보내면 글자가 안 들어와 mod가 못 받습니다. 오른쪽 Option만 글자를 넣게 설정하고 오른쪽 Option+숫자를 씁니다. Ghostty는 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 저장소에 두면 머신 간에도 동기화됩니다.
  • 번역 중 모르는 고유명사·은어가 나오면 띠에 입력칸이 뜹니다. 영어 표기를 적고 Enter를 누르면 용어집에 추가하고 다시 번역합니다.

추천 한 번마다 Haiku를 한 번 호출합니다.

설치

터미널에서 연 Claude Code 세션의 프롬프트에 입력합니다.

/plugin install en-coach --marketplace Jeong-Jae-Hun/en-coach

y로 마켓플레이스 추가를 승인하고 user 스코프를 고르면 됩니다. user 스코프로 설치하면 데스크톱 앱의 Code 탭에서도 동작합니다.

개발

claude plugin validate .
claude plugin test .

라이선스

MIT

Source 2 files
hooks/register.tsx 471 lines
1import { 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}
471
types/index.d.ts 17 lines
1export 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