SLOPSHOPPER

ko-ui

Claude Code 의 슬래시 커맨드 설명, /config 항목, 일부 화면 문구를 한국어로 보여주는 번역 사전 mod (Korean UI translation dictionary mod)

newspinnerrowscommandtoasttimer
v0.1.0MITupdated 2026-10-04moduvoice/claude-code-ko-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ko-ui
› fix the failing auth test and add an audit log call ⏺ 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. ✻ 42초 동안 작업했습니다 › /ko-dump ⎿ ko-ui: 번역 안 된 항목: 커맨드 0개, 설정 0개, 화면 문구 0개 ✻ 생각하는 중… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✻ 42초 동안 작업했습니다
README

ko-ui — Claude Code 한국어 UI 번역 mod

Claude Code 의 / 슬래시 커맨드 설명, /config 항목, 일부 화면 문구를 한국어로 보여주는 사전 방식 mod 입니다. 번역은 dict.json 에 들어 있는 고정 사전만 사용합니다. 모델 호출도, 네트워크 요청도 없고, 모델에 보내는 프롬프트나 컨텍스트는 건드리지 않아서 토큰이 들지 않습니다.

English: A dictionary-based mod that shows Claude Code's slash-command descriptions, /config rows and some transcript lines in Korean. It only reads a static dict.json: no model calls, no network, nothing added to the model's context.

번역되는 것

대상방식상태
/ 입력 시 나오는 슬래시 커맨드 설명 (내장 커맨드, 스킬, 플러그인)command.describe확인함
/config 항목 이름 (44개)config.describe확인함
작업 중 표시 (Spinner) 의 단어ui.render Spinner확인함
턴 종료 줄 (Baked for 3s → ✻ 3초 동안 작업했습니다)ui.render TurnDuration확인함
접힌 도구 호출 요약 (Read 3 files → 파일 3개 읽음)ui.render ToolGroup확인함
(ctrl+b to run in background) 안내ui.render ToolProgress확인함
커맨드 출력 줄 (Set … to …, 색상 설정 등), 시작 알림, 푸터 모드 라벨, 백그라운드 작업 알림ui.render + dict.json 의 patterns일부 문구만 사전에 있음

"확인함" 은 Claude Code 2.1.289 (Windows) 의 실제 세션에서 한국어로 뜨는 것을 본 항목입니다. 마지막 줄의 출력 문구는 훅은 걸려 있지만 사전에 들어 있는 문구만 번역됩니다. 영어로 남은 문구는 /ko-dump 로 확인해 dict.json 에 추가하거나 이슈로 알려 주세요.

번역할 수 없는 것

mod API 에 훅이 없는 화면은 건드릴 수 없습니다. 번들 파일을 직접 패치하는 방식은 쓰지 않습니다.

  • 프롬프트 아래 줄의 알약(auto mode on, update installed · restart to update, (shift+tab to cycle) 등). 줄 전체를 바꾸는 훅만 있어서, 번역하면 현재 모드 표시가 사라지므로 영어로 둡니다.
  • 권한 확인 창, 환영 화면, /config·/model 하위 선택 화면, 선택지 값(auto, dark, plan 등), 에러 메시지, 팁.
  • 도구 호출 행 자체(Bash(ls), Interrupted)와 작업 중 줄의 (12s, 300 tokens).

설치

Claude Code 2.1.287 이상이 필요합니다. 2.1.289, Windows 에서 만들고 시험했습니다. 2.1.285 에서는 mod 가 기본으로 꺼져 있어서 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 환경 변수를 켜야 로드됩니다. 이 경우에도 /ko-dump 동작은 확인했습니다.

git clone https://github.com/moduvoice/claude-code-ko-ui

한 세션만 써 보기

claude --plugin-dir ./claude-code-ko-ui

모든 새 세션에 적용하기: ~/.claude/settings.json 의 env 에 CLAUDE_CODE_PLUGIN_DIRS 로 클론한 폴더를 등록합니다. 여러 개면 Windows 에서는 ; 로 구분합니다.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:\\path\\to\\claude-code-ko-ui"
  }
}

새 세션을 열면 적용됩니다.

번역 고치기, 추가하기

모든 번역은 dict.json 에 있습니다. 어색한 문장은 직접 고치면 됩니다. 새 세션부터 반영됩니다.

키내용
commands슬래시 커맨드 이름 → 한국어 설명
config/config 키 → { label, description }
patterns커맨드 출력 줄에 적용할 정규식 { re, to } 목록
descPatterns현재 상태가 문구에 들어가는 커맨드 설명(예: /sandbox)에 적용할 정규식 목록. 고정 번역을 넣으면 상태가 바뀔 때 틀려지는 경우에 씁니다.
promptHints프롬프트 아래 힌트 문구 (문장 전체가 한국어가 될 때만 적용)
modes푸터 모드 라벨

Claude Code 업데이트 후

업데이트로 새 커맨드나 설정이 생기면, 세션 시작 약 8초 뒤에 번역이 없는 항목이 있을 때만 토스트로 알려 줍니다. 이 확인도 코드로만 하고 모델은 부르지 않습니다.

  • /ko-dump: 사전에 없는 영어 커맨드 설명, 설정 이름, 화면 문구 목록을 보여 줍니다. 이 목록을 번역해서 dict.json 에 추가하세요.
  • 번역되지 않은 화면 문구는 $.store 에 최대 200개까지 모아 둡니다.

mod 가 접근하는 범위

claude plugin validate 가 보고하는 범위입니다.

  • 읽기: 이 mod 폴더의 dict.json, 커맨드·설정 목록 ($.command.list, $.config.list)
  • 저장: 이 mod 의 $.store (번역하지 못한 문구 목록)
  • 화면: ui.render, 토스트, /ko-dump 커맨드 등록
  • 파일 쓰기, 프로세스 실행, 네트워크, 모델 호출: 없음

라이선스

MIT

Source 1 files
hooks/register.tsx 323 lines
1import type { Register } from 'claude-code'
2
3type Dict = {
4  commands: Record<string, string>
5  hints: Record<string, string>
6  config: Record<string, { label?: string; description?: string }>
7  patterns: { re: string; to: string }[]
8  descPatterns: { re: string; to: string }[]
9  promptHints: Record<string, string>
10  modes: Record<string, string>
11}
12
13const EMPTY: Dict = {
14  commands: {},
15  hints: {},
16  config: {},
17  patterns: [],
18  descPatterns: [],
19  promptHints: {},
20  modes: {},
21}
22
23const MISSING_MAX = 200
24const ANSI = new RegExp('\\u001b\\[[0-9;]*m', 'g')
25const HANGUL = /[가-힣]/
26const SPINNER: Record<string, string> = {
27  thinking: '생각하는 중',
28  requesting: '요청하는 중',
29  responding: '응답하는 중',
30  'tool-use': '도구 실행 중',
31  'tool-input': '도구 입력 준비 중',
32}
33
34let dict: Promise<Dict> | undefined
35let queue: Promise<unknown> = Promise.resolve()
36const seen = new Set<string>()
37
38function load($: any): Promise<Dict> {
39  dict ??= $.fs
40    .read(`${$.plugin.root}/dict.json`)
41    .then((t: string) => ({ ...EMPTY, ...JSON.parse(t) }))
42    .catch(() => EMPTY)
43  return dict
44}
45
46// 번역하지 못한 문구를 $.store 에 모아 두어 사전에 추가할 수 있게 한다.
47function record($: any, kind: string, text: string) {
48  const t = text.replace(ANSI, '').trim()
49  if (!t || HANGUL.test(t) || t.length > 2000) return
50  const line = `[${kind}] ${t}`
51  if (seen.has(line)) return
52  seen.add(line)
53  queue = queue.then(async () => {
54    const old = ((await $.store.get('missing')) as string[] | undefined) ?? []
55    if (old.includes(line) || old.length >= MISSING_MAX) return
56    await $.store.set('missing', [...old, line])
57  })
58}
59
60function tr(d: Dict, text: string): string {
61  let out = text.replace(ANSI, '')
62  out = out.replace(/^Set (\S+(?: \S+)*?) to (.+)$/gm, (_m, key: string, val: string) => {
63    return `설정 변경: ${d.config[key]?.label ?? key} → ${val}`
64  })
65  for (const p of d.patterns) {
66    try {
67      out = out.replace(new RegExp(p.re, 'gm'), p.to)
68    } catch {
69      // 잘못된 정규식은 건너뛴다
70    }
71  }
72  return out
73}
74
75// 상태가 문구에 들어가는 동적 커맨드 설명(예: /sandbox)을 패턴으로 번역한다.
76function trDesc(d: Dict, text: string): string {
77  let out = text
78  for (const p of d.descPatterns) {
79    try {
80      out = out.replace(new RegExp(p.re, 'g'), p.to)
81    } catch {
82      // 잘못된 정규식은 건너뛴다
83    }
84  }
85  return out
86}
87
88function duration(ms: number): string {
89  const s = Math.max(0, Math.round(ms / 1000))
90  const h = Math.floor(s / 3600)
91  const m = Math.floor((s % 3600) / 60)
92  const r = s % 60
93  if (h > 0) return `${h}시간 ${m}분`
94  if (m > 0) return `${m}분 ${r}초`
95  return `${r}초`
96}
97
98const GROUP_ORDER = ['search', 'read', 'list', 'shell']
99const GROUP_OF: Record<string, string> = {
100  Grep: 'search',
101  Glob: 'search',
102  Read: 'read',
103  LS: 'list',
104  Bash: 'shell',
105  PowerShell: 'shell',
106}
107const GROUP_TEXT: Record<string, (n: number, active: boolean) => string> = {
108  search: (n, a) => `패턴 ${n}개 ${a ? '검색 중' : '검색함'}`,
109  read: (n, a) => `파일 ${n}개 ${a ? '읽는 중' : '읽음'}`,
110  list: (n, a) => `디렉터리 ${n}개 ${a ? '나열 중' : '나열함'}`,
111  shell: (n, a) => `셸 명령 ${n}개 ${a ? '실행 중' : '실행함'}`,
112}
113
114// 접힌 도구 호출 요약 줄. 모르는 도구가 섞여 있으면 undefined (원문 유지).
115function groupLine(calls: ReadonlyArray<{ tool: string }>, active: boolean): string | undefined {
116  const count: Record<string, number> = {}
117  for (const c of calls) {
118    const g = GROUP_OF[c.tool]
119    if (!g) return undefined
120    count[g] = (count[g] ?? 0) + 1
121  }
122  const parts = GROUP_ORDER.filter(g => count[g]).map(g => GROUP_TEXT[g](count[g], active))
123  return parts.length > 0 ? parts.join(', ') : undefined
124}
125
126// 키 이름(esc, ctrl+c 등)을 뺀 뒤에도 영어 단어가 남으면 번역이 덜 된 것이다.
127function isFullyKorean(text: string): boolean {
128  const rest = text.replace(/\b(ctrl|shift|alt|esc|tab|enter|cmd|opt|fn)\b[+\w-]*/gi, '')
129  return !/[A-Za-z]{3,}/.test(rest)
130}
131
132function trHint(d: Dict, hint: string): string | undefined {
133  if (d.promptHints[hint]) return d.promptHints[hint]
134  const keys = Object.keys(d.promptHints).sort((a, b) => b.length - a.length)
135  let out = hint
136  for (const k of keys) out = out.split(k).join(d.promptHints[k])
137  return out !== hint && isFullyKorean(out) ? out : undefined
138}
139
140type Todo = { commands: Record<string, string>; config: Record<string, string>; texts: string[] }
141
142// 사전에 없는 영어 문구를 찾는다. 번역은 하지 않는다.
143async function scan($: any): Promise<Todo> {
144  const d = await load($)
145  const need = (t?: string) => !!t && t.trim() !== '' && !HANGUL.test(t)
146  const todo: Todo = { commands: {}, config: {}, texts: [] }
147  const commands: any[] = await $.command.list()
148  for (const c of commands) {
149    if (c.plugin !== 'ko-ui' && need(c.description) && !d.commands[c.name]) {
150      todo.commands[c.name] = c.description
151    }
152  }
153  const config: any[] = await $.config.list()
154  for (const r of config) {
155    if (need(r.label) && !d.config[r.key]?.label) todo.config[r.key] = r.label
156  }
157  todo.texts = ((await $.store.get('missing')) as string[] | undefined) ?? []
158  return todo
159}
160
161// 새 커맨드나 설정이 번역 안 된 채 보이면 한 번 알려 준다. (업데이트 직후에 해당)
162async function notify($: any) {
163  const t = await scan($)
164  const n = Object.keys(t.commands).length + Object.keys(t.config).length
165  if (n > 0) {
166    await $.ui.toast(
167      `ko-ui: 번역 안 된 새 항목 ${n}개 발견. /ko-dump 로 목록을 보고 dict.json 에 번역을 추가하세요`,
168    )
169  }
170}
171
172export const register: Register = on => {
173  on('session.start', async ($, e, next) => {
174    await $.command.register({
175      name: 'ko-dump',
176      description: '사전에 없는 영어 커맨드·설정·화면 문구 목록을 보여줍니다',
177    })
178    await $.ui.invalidate('command.describe')
179    await $.ui.invalidate('config.describe')
180    $.clock.after(8000, () => {
181      void notify($)
182    })
183    return next(e)
184  })
185
186  on('command.run', { command: 'ko-dump' }, async $ => {
187    const t = await scan($)
188    const cmds = Object.entries(t.commands).map(([k, v]) => `- /${k}: ${v}`)
189    const cfgs = Object.entries(t.config).map(([k, v]) => `- ${k}: ${v}`)
190    const lines = [
191      `번역 안 된 항목: 커맨드 ${cmds.length}개, 설정 ${cfgs.length}개, 화면 문구 ${t.texts.length}개`,
192      ...(cmds.length ? ['', '[커맨드]', ...cmds] : []),
193      ...(cfgs.length ? ['', '[설정]', ...cfgs] : []),
194      ...(t.texts.length ? ['', '[화면 문구]', ...t.texts.map(x => `- ${x}`)] : []),
195    ]
196    return { text: lines.join('\n') }
197  })
198
199  on('command.describe', async ($, e, next) => {
200    const d = await load($)
201    const r = await next(e)
202    return {
203      ...r,
204      description: d.commands[e.command] ?? trDesc(d, r.description),
205      argumentHint: r.argumentHint && (d.hints[e.command] ?? r.argumentHint),
206    }
207  })
208
209  on('config.describe', async ($, e, next) => {
210    const d = await load($)
211    const r = await next(e)
212    const t = d.config[e.key]
213    return {
214      ...r,
215      label: t?.label ?? r.label,
216      description: t?.description ?? r.description,
217    }
218  })
219
220  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
221    const d = await load($)
222    const text = e.props.text
223    const out = tr(d, text)
224    if (out === text.replace(ANSI, '')) {
225      record($, `${e.props.command}`, text)
226      return next(e)
227    }
228    return next({ ...e, props: { ...e.props, text: out } })
229  })
230
231  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
232    const d = await load($)
233    const text = e.props.text
234    const out = tr(d, text)
235    if (out === text.replace(ANSI, '')) {
236      record($, 'notice', text)
237      return next(e)
238    }
239    return next({ ...e, props: { ...e.props, text: out } })
240  })
241
242  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
243    const d = await load($)
244    const out = trHint(d, e.props.hint)
245    if (out === undefined) {
246      record($, 'hint', e.props.hint)
247      return next(e)
248    }
249    return next({ ...e, props: { ...e.props, hint: out } })
250  })
251
252  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
253    const d = await load($)
254    const modes = e.props.modes.map(m => {
255      if (d.modes[m]) return d.modes[m]
256      record($, 'mode', m)
257      return m
258    })
259    return next({ ...e, props: { ...e.props, modes } })
260  })
261
262  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
263    if (e.surface !== 'terminal') return next(e)
264    const d = await load($)
265    const p = e.props
266    let message = p.message
267    if (message !== null) {
268      const out = tr(d, message)
269      if (out === message.replace(ANSI, '')) record($, 'spinner', message)
270      else message = out
271    }
272    return next({ ...e, props: { ...p, word: SPINNER[p.mode] ?? p.word, message } })
273  })
274
275  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
276    const m = /^\((.+) to run in background\)$/.exec(e.props.hint)
277    if (!m) {
278      record($, 'progress', e.props.hint)
279      return next(e)
280    }
281    return next({ ...e, props: { ...e.props, hint: `(${m[1]} 로 백그라운드 실행)` } })
282  })
283
284  on(
285    'ui.render',
286    { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } },
287    async ($, e, next) => {
288      const d = await load($)
289      const text = e.props.text
290      const out = tr(d, text)
291      if (out === text.replace(ANSI, '')) {
292        record($, 'task-notification', text)
293        return next(e)
294      }
295      return next({ ...e, props: { ...e.props, text: out } })
296    },
297  )
298
299  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
300    if (e.props.isExpanded) return next(e)
301    const line = groupLine(e.props.calls, e.props.isActive)
302    if (line === undefined) {
303      record($, 'toolgroup', e.props.calls.map(c => c.tool).join(','))
304      return next(e)
305    }
306    const { Box, Text } = $.ui.resolve(e)
307    return (
308      <Box flexDirection="row">
309        <Text dimColor>
310          ● {line}
311          {e.props.isActive ? '…' : ''}
312        </Text>
313        <Text dimColor> (ctrl+o 로 펼치기)</Text>
314      </Box>
315    )
316  })
317
318  on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
319    const { Text } = $.ui.resolve(e)
320    return <Text dimColor>✻ {duration(e.props.durationMs)} 동안 작업했습니다</Text>
321  })
322}
323