작업 중 줄을 지금 하는 일로, 턴이 끝난 줄을 모델·시간·도구·캐시 한 줄로 한국어로 보여줘요

작업 중 줄을 "읽는 중 · page.tsx"처럼 지금 하는 일로, 턴이 끝난 줄을 "◇ Opus 5.5 1분 22초 · 도구 14 · 캐시 97% · 14:59"처럼 모델·시간·도구·캐시 한 줄로 한국어로 보여줘요.
/plugin marketplace add SeongGwangJu/k-mods
/plugin install status-ko@k-mods
따로 할 일이 없어요. 설치하면 바로 작업 중 줄과 턴 완료 줄이 한국어로 바뀌어요. 명령어도 없어요.
dark는 어두운 터미널용 파스텔이에요. 밝은 터미널이면 theme(Claude Code 테마 색을 따라감), 회색 배경 터미널(예: #bdbec6 + 야간 모드 필터)이면 gray로 바꾸세요.네 군데를 가로챕니다.
없어요. 네트워크도, 파일도, 모델 호출도 하지 않아요. 지금 진행 중인 턴·도구 호출의 정보만 메모리에서 읽고 화면 문구만 바꿔요.
메인테이너(SeongGwangJu)가 자기 기기에서 쓰던 오리지널 모드를 공개용으로 옮긴 것으로, 외부 upstream은 없습니다. 저장소 루트의 MIT 라이선스를 따릅니다.
테스트한 Claude Code 버전: 2.1.291
hooks/register.ts 141 lines1// status-ko: 작업 중 줄과 턴 완료 줄을 한국어 상태로 바꾼다.
2//
3// 작업 중 줄(Spinner): 엔진의 ✻ 애니메이션과 (19s · ↓ 1.1k tokens) 괄호는 그대로 두고,
4// 무작위 단어(Ruminating, Clauding…)만 지금 하는 일로 바꾼다.
5// 생각 중 / 작성 중 / 요청 중 / 읽는 중 · page.tsx / 실행 중 · pnpm check …
6// 완료 줄(TurnDuration): "Brewed for 1m 22s"를 "◇ Opus 5.5 1분 22초 · 도구 14 · 캐시 97% · 14:59"로.
7// 중단이면 ✕ 중단, 오류면 ! 오류, 거절이면 ⊘ 거절.
8// 도구 수·캐시는 turn.complete에서 모아 두고, 완료 줄을 그릴 때 걸린 시간(durationMs)으로 짝을 찾는다.
9// 짝이 없는 줄(모드가 다시 불리기 전의 턴)은 "◇ 완료 1분 22초"만 쓴다.
10//
11// 색은 기본(palette: "theme")은 테마 키, "gray"는 원본이 쓰던 회색 배경용 고정 색.
12import type { On, PluginOptions } from 'claude-code'
13
14import { duration, clockTime, modelName, targetOf, MODE_LABELS, TOOL_LABELS } from './format'
15import { paletteOf } from './palette'
16
17const KEEP_TURNS = 200
18
19type Running = { id: string; tool: string; target: string }
20type Finished = {
21 reason: string
22 tools: number
23 cache: number | null
24 model: string
25 endedAt: number
26}
27
28export function register(on: On, options: PluginOptions): void {
29 const palette = paletteOf(options.palette)
30
31 // 메인 루프에서 지금 돌고 있는 도구들 (병렬 호출이면 여러 개, 가장 최근 것을 보여준다)
32 let running: Running[] = []
33 // 이번 턴에 부른 도구 수 (서브에이전트 안의 호출 포함)
34 let toolsThisTurn = 0
35 // 끝난 턴의 요약: durationMs → 요약
36 const finished = new Map<number, Finished>()
37
38 on('turn.start', async ($, e, next) => {
39 toolsThisTurn = 0
40 running = []
41 return next(e)
42 })
43
44 on('tool.call', async ($, e, next) => {
45 toolsThisTurn += 1
46 if (e.agentId !== undefined) return next(e)
47 const entry: Running = { id: e.tool_use_id, tool: String(e.tool), target: targetOf(e) }
48 running.push(entry)
49 $.ui.invalidate('ui.render')
50 try {
51 return await next(e)
52 } finally {
53 running = running.filter((r) => r !== entry)
54 $.ui.invalidate('ui.render')
55 }
56 }).catch(async ($, e, next) => next(e))
57
58 on('turn.complete', async ($, e, next) => {
59 if (e.agentId === undefined) {
60 const u = e.usage
61 const input = u ? u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : 0
62 finished.set(e.durationMs, {
63 reason: e.reason,
64 tools: toolsThisTurn,
65 cache: u && input > 0 ? Math.round(((u.cache_read_input_tokens ?? 0) / input) * 100) : null,
66 model: u?.model ?? (await $.session.model()),
67 endedAt: await $.clock.now(),
68 })
69 if (finished.size > KEEP_TURNS) {
70 const oldest = finished.keys().next().value
71 if (oldest !== undefined) finished.delete(oldest)
72 }
73 running = []
74 }
75 return next(e)
76 })
77
78 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
79 // 엔진이 상태 문구(압축 중 등)를 띄울 때는 그대로 둔다
80 if (e.props.message) return next(e)
81 const current = running[running.length - 1]
82 if (e.props.mode === 'tool-use' && current) {
83 const label = TOOL_LABELS[current.tool] ?? (current.tool.startsWith('mcp__') ? 'MCP 호출 중' : `${current.tool} 실행 중`)
84 const word = current.target ? `${label} · ${current.target}` : label
85 return next({ ...e, props: { ...e.props, word, suffix: current.target ? '' : e.props.suffix } })
86 }
87 const word = MODE_LABELS[e.props.mode] ?? e.props.word
88 return next({ ...e, props: { ...e.props, word } })
89 })
90
91 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
92 const { Text } = $.ui.resolve(e)
93 const summary = finished.get(e.props.durationMs)
94 const time = duration(e.props.durationMs)
95 // Text에는 key가 없다(목록 재조정용이 아니라 Box·Button의 key는 hover 범위·주소 용도).
96 // 여기 span들은 찾아서 누르거나 입력할 대상이 아니라 그냥 글자 조각이라 key가 필요 없다.
97 const span = (color: string, text: string, bold = false) => Text({ color, bold, children: text })
98
99 if (!summary) {
100 return Text({
101 wrap: 'truncate-end',
102 children: [span(palette.mark, '◇ '), span(palette.text, '완료 '), span(palette.text, time, true)],
103 })
104 }
105 if (summary.reason === 'aborted') {
106 return Text({
107 wrap: 'truncate-end',
108 children: [
109 span(palette.aborted, '✕ '),
110 span(palette.text, '중단 '),
111 span(palette.text, time, true),
112 span(palette.subtle, ` · 도구 ${summary.tools}`),
113 ],
114 })
115 }
116 if (summary.reason === 'error' || summary.reason === 'refusal') {
117 const word = summary.reason === 'error' ? '오류' : '거절'
118 return Text({
119 wrap: 'truncate-end',
120 children: [
121 span(palette.failed, summary.reason === 'error' ? '! ' : '⊘ '),
122 span(palette.text, `${word} `),
123 span(palette.text, time, true),
124 ],
125 })
126 }
127 const details = [`도구 ${summary.tools}`]
128 if (summary.cache !== null) details.push(`캐시 ${summary.cache}%`)
129 details.push(clockTime(summary.endedAt))
130 return Text({
131 wrap: 'truncate-end',
132 children: [
133 span(palette.mark, '◇ '),
134 span(palette.text, `${modelName(summary.model)} `),
135 span(palette.text, time, true),
136 span(palette.subtle, ` · ${details.join(' · ')}`),
137 ],
138 })
139 })
140}
141hooks/format.ts 76 lines1// status-ko의 표시용 순수 함수. $를 받지 않아 테스트하기 쉽다.
2
3export const MODE_LABELS: Record<string, string> = {
4 thinking: '생각 중',
5 requesting: '요청 중',
6 responding: '작성 중',
7 'tool-input': '도구 준비 중',
8 'tool-use': '도구 실행 중',
9}
10
11export const TOOL_LABELS: Record<string, string> = {
12 Bash: '실행 중',
13 Read: '읽는 중',
14 Edit: '수정 중',
15 MultiEdit: '수정 중',
16 Write: '작성 중',
17 NotebookEdit: '수정 중',
18 Grep: '검색 중',
19 Glob: '찾는 중',
20 WebFetch: '웹 읽는 중',
21 WebSearch: '웹 검색 중',
22 Agent: '에이전트 실행 중',
23 Skill: '스킬 실행 중',
24 ToolSearch: '도구 불러오는 중',
25}
26
27/**
28 * 도구 호출 이벤트에서 "지금 무엇을 대상으로" 하는지 짧게 뽑아낸다.
29 * file_path/notebook_path/path(패턴 없을 때)는 파일 이름만, url은 프로토콜을 떼고,
30 * 나머지(pattern·query·subagent_type·skill·command·description)는 통째로 쓴다.
31 */
32export function targetOf(e: Record<string, unknown>): string {
33 const pick =
34 e.file_path ??
35 e.notebook_path ??
36 e.path ??
37 e.pattern ??
38 e.query ??
39 e.url ??
40 e.subagent_type ??
41 e.skill ??
42 e.command ??
43 e.description
44 if (pick === undefined || pick === null) return ''
45 let text = String(pick)
46 if (e.file_path || e.notebook_path || (e.path && !e.pattern)) {
47 text = text.split('/').filter(Boolean).pop() ?? text
48 }
49 if (e.url) text = text.replace(/^https?:\/\//, '')
50 text = text.replace(/\s+/g, ' ').trim()
51 return text.length > 40 ? text.slice(0, 39) + '…' : text
52}
53
54/** 밀리초를 한국식("1시간 12분", "3분 4초", "19초")으로. */
55export function duration(ms: number): string {
56 const s = Math.max(0, Math.round(ms / 1000))
57 if (s < 60) return `${s}초`
58 const m = Math.floor(s / 60)
59 if (m < 60) return `${m}분 ${s % 60}초`
60 return `${Math.floor(m / 60)}시간 ${m % 60}분`
61}
62
63/** 자정 이후 밀리초를 "HH:MM"으로. */
64export function clockTime(epochMs: number): string {
65 const d = new Date(epochMs)
66 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
67}
68
69/** claude-opus-5-5 → Opus 5.5. 이미 사람이 읽기 좋은 이름이면 꼬리의 괄호만 뗀다. */
70export function modelName(id: unknown): string {
71 const m = String(id).match(/claude-([a-z]+)-(\d+)-(\d+)/i)
72 if (!m) return String(id).replace(/\s*\(.*\)$/, '')
73 const [, kind, major, minor] = m as [string, string, string, string]
74 return `${kind.charAt(0).toUpperCase()}${kind.slice(1)} ${major}.${minor}`
75}
76hooks/palette.ts 47 lines1// status-ko의 색 팔레트.
2//
3// 원본(메인테이너 전용 모드)은 회색 배경(#bdbec6)에서 읽히도록 고른 고정 색을 썼다.
4// 공개판 기본값("dark")은 어두운 터미널 기준 파스텔이고, "theme"은 테마 키로 사용자의 테마를 따라가며(밝은 터미널),
5// "gray"를 고르면 원본과 똑같은 16진수 색으로 돌아간다.
6//
7// theme 쪽은 색상(hue)이 아니라 "의미"로 테마 키를 골랐다:
8// aborted(사용자가 중단시킴, 실패 아님) → warning
9// failed(모델 오류·거절, 실제 실패) → error
10// 그래서 원본에서 더 붉던 ABORT 색이 theme 모드에서는 error가 아니라 warning에 대응한다.
11export type Palette = {
12 mark: string
13 text: string
14 subtle: string
15 aborted: string
16 failed: string
17}
18
19// 어두운 터미널 기준 파스텔 (기본). ctx-strip과 같은 톤을 쓴다.
20const DARK: Palette = {
21 mark: '#C3B1F5',
22 text: '#E8EAED',
23 subtle: '#8A909B',
24 aborted: '#F0C987',
25 failed: '#F28B9B',
26}
27
28const THEME: Palette = {
29 mark: 'claude',
30 text: 'text',
31 subtle: 'subtle',
32 aborted: 'warning',
33 failed: 'error',
34}
35
36const GRAY: Palette = {
37 mark: '#6a52d0',
38 text: '#374151',
39 subtle: '#5b6070',
40 aborted: '#9f1239',
41 failed: '#7c4a03',
42}
43
44export function paletteOf(name: unknown): Palette {
45 return name === 'gray' ? GRAY : name === 'theme' ? THEME : DARK
46}
47