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

Claude Code 의 / 슬래시 커맨드 설명, /config 항목, 일부 화면 문구를 한국어로 보여주는 사전 방식 mod 입니다. 번역은 dict.json 에 들어 있는 고정 사전만 사용합니다. 모델 호출도, 네트워크 요청도 없고, 모델에 보내는 프롬프트나 컨텍스트는 건드리지 않아서 토큰이 들지 않습니다.
English: A dictionary-based mod that shows Claude Code's slash-command descriptions,
/configrows and some transcript lines in Korean. It only reads a staticdict.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 | 푸터 모드 라벨 |
업데이트로 새 커맨드나 설정이 생기면, 세션 시작 약 8초 뒤에 번역이 없는 항목이 있을 때만 토스트로 알려 줍니다. 이 확인도 코드로만 하고 모델은 부르지 않습니다.
/ko-dump: 사전에 없는 영어 커맨드 설명, 설정 이름, 화면 문구 목록을 보여 줍니다. 이 목록을 번역해서 dict.json 에 추가하세요.$.store 에 최대 200개까지 모아 둡니다.claude plugin validate 가 보고하는 범위입니다.
dict.json, 커맨드·설정 목록 ($.command.list, $.config.list)$.store (번역하지 못한 문구 목록)ui.render, 토스트, /ko-dump 커맨드 등록MIT
hooks/register.tsx 323 lines1import 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