SLOPSHOPPER

status-ko

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

newspinnerguard
★ 1v1.1.0MITupdated 2026-10-06SeongGwangJu/k-mods/mods/status-ko
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-ko
› 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. ◇ Opus 5.5 42초 · 도구 9 · 캐시 93% · 08:53 ✻ 생각 중… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
◇ Opus 5.5 42초 · 도구 9 · 캐시 93% · 08:53
README

작업 상태 한국어

작업 중 줄을 "읽는 중 · page.tsx"처럼 지금 하는 일로, 턴이 끝난 줄을 "◇ Opus 5.5 1분 22초 · 도구 14 · 캐시 97% · 14:59"처럼 모델·시간·도구·캐시 한 줄로 한국어로 보여줘요.

설치

/plugin marketplace add SeongGwangJu/k-mods
/plugin install status-ko@k-mods

쓰는 법

따로 할 일이 없어요. 설치하면 바로 작업 중 줄과 턴 완료 줄이 한국어로 바뀌어요. 명령어도 없어요.

설정 (/config 또는 /plugin configure status-ko@k-mods)

  • palette: 턴 완료 줄의 색. 기본값 dark는 어두운 터미널용 파스텔이에요. 밝은 터미널이면 theme(Claude Code 테마 색을 따라감), 회색 배경 터미널(예: #bdbec6 + 야간 모드 필터)이면 gray로 바꾸세요.

어떻게 동작하나요

네 군데를 가로챕니다.

  • 턴이 시작할 때: 이번 턴에 부른 도구 수를 0으로 되돌려요.
  • 도구를 부를 때: 지금 돌고 있는 도구 이름과 대상(파일명 등)을 기억해 뒀다가, 끝나면 지워요. Claude가 실제로 도구를 쓰는 건 그대로 두고 지켜보기만 해요.
  • 턴이 끝날 때: 걸린 시간·도구 수·캐시 적중률·모델 이름을 기억해 둬요. 이 정보가 바로 다음에 그려질 완료 줄의 재료예요.
  • 화면을 그릴 때: Claude Code가 그리는 두 줄(작업 중 Spinner, 완료 TurnDuration)에 끼어들어 문구만 한국어로 바꿔요. Claude Code의 애니메이션·레이아웃은 그대로 두고 단어만 바꾸는 방식이라, 엔진이 바뀌어도 잘 안 깨져요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

없어요. 네트워크도, 파일도, 모델 호출도 하지 않아요. 지금 진행 중인 턴·도구 호출의 정보만 메모리에서 읽고 화면 문구만 바꿔요.

한계

  • 완료 줄의 도구 수·캐시 정보는 "같은 밀리초 길이(durationMs)"로 짝을 맞춰요. 아주 드물게 두 턴이 정확히 같은 길이로 끝나면 엉뚱한 요약이 붙을 수 있어요. 그래도 시간 자체는 항상 맞아요.
  • 완료 줄(TurnDuration)은 터미널에서만 보여요. Desktop 앱에는 안 그려져요(Claude Code 자체가 그 줄을 터미널에서만 그리기 때문). 작업 중 줄(Spinner)은 터미널·Desktop 둘 다 보여요.

출처·라이선스

메인테이너(SeongGwangJu)가 자기 기기에서 쓰던 오리지널 모드를 공개용으로 옮긴 것으로, 외부 upstream은 없습니다. 저장소 루트의 MIT 라이선스를 따릅니다.

테스트한 Claude Code 버전: 2.1.291

Source 3 files
hooks/register.ts 141 lines
1// 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}
141
hooks/format.ts 76 lines
1// 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}
76
hooks/palette.ts 47 lines
1// 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