Claude Code 프롬프트 위 작은 상자가 지금 에이전트가 하는 일(읽기·생각·작성·도구 실행·승인 대기)을 한국어로 알려 주고, 펼치면 지금까지 한 일을 보여 줍니다. tzafrir/whats-agent-doing 한국어판.

Claude가 지금 뭘 하고 있는지, 프롬프트 바로 위에서 한국어로.
<img width="602" height="74" alt="image" src="https://github.com/user-attachments/assets/f23722df-92b0-4d78-97b3-ee0b75feb058" />
긴 작업을 맡기면 Claude는 몇 분씩 bisect를 돌리고, 일회용 스크립트를 짜고, 플래그가 줄줄이 붙은 명령을 실행합니다. 그동안 화면에는 스피너와 지나가는 명령뿐이라 지금 뭘 하려는 건지, 저 명령은 왜 돌리는 건지, 멈춘 건지 느린 건지 알기 어렵습니다.
이 mod는 프롬프트 위에 작은 상자 하나를 띄워 지금 하고 있는 일과 걸린 시간을 한 줄로 보여 줍니다. ▸를 누르면 이번 턴에 한 일이 전부 펼쳐집니다.
tzafrir/whats-agent-doing의 화면 문구를 자연스러운 한국어로 옮긴 버전입니다. 동작은 원작과 같습니다.
Claude Code 안에서:
/plugin marketplace add min050410/claude-mods-whats-agent-doing-ko
/plugin install whats-agent-doing@min050410
/reload-plugins
터미널에서:
claude plugin marketplace add min050410/claude-mods-whats-agent-doing-ko
claude plugin install whats-agent-doing@min050410
설치한 뒤 새 세션을 열거나 /reload-plugins를 실행하면 상자가 뜹니다. 여는 명령은 따로 없습니다.
[!IMPORTANT] 원작 영어판(
whats-agent-doing@tzafrir)이 설치돼 있다면 먼저 지우세요. 둘 다 켜면 상자가 두 개 뜹니다.claude plugin uninstall whats-agent-doing@tzafrir
필요한 것: 터미널의 Claude Code, 또는 데스크톱 앱의 Code 탭. function hook 기반 mod라 function hook이 켜진 곳에서만 동작하고, 이 API는 아직 early access라 Claude Code 버전에 따라 바뀔 수 있습니다.
쿠폰을 적용하면 장바구니 합계가 틀려. 고쳐 줘.
Claude가 실행하는 명령과 상자가 보여 주는 문구:
| Claude가 실행하는 것 | 상자에 보이는 것 | |
|---|---|---|
npx tsx -e "import {cartTotal} from './src/cart'; …" | 쿠폰 10달러로 합계 재현하기 | |
git bisect start HEAD v2.3.0 && git bisect run npx vitest run src/cart -t "fixed coupon" | 쿠폰 금액이 깨진 커밋 찾기 \ | 1분 48초 |
git bisect reset && npx vitest run src/cart | bisect 정리하고 장바구니 테스트 다시 돌리기 |
bisect가 2분 가까이 돌아도 상자는 멈춘 게 아니라 bisect 중이라고 알려 줍니다. 명령 옆의 설명은 Claude가 명령마다 붙이는 설명을 그대로 보여 주는 것이라, Claude가 한국어로 대화하고 있으면 한국어로 나옵니다.
▸를 누르면 지금까지 한 일이 펼쳐집니다:
╭──────────────────────────────────────────────────────────────╮
│ ▾ ○ Claude: 대기 중 (지난 턴 완료, 2분 40초, 도구 5번) │
│ │
│ › 쿠폰을 적용하면 장바구니 합계가 틀려. 고쳐 줘. │
│ ✓ cart.ts 읽는 중 0.6초 │
│ ✓ 쿠폰 10달러로 합계 재현하기 2.1초 │
│ ✓ 쿠폰 금액이 깨진 커밋 찾기 1분 48초 │
│ ∴ 생각 (52단어) 4.0초 │
│ ✓ cart.ts 수정 중 0.3초 │
│ ✓ bisect 정리하고 장바구니 테스트 다시 돌리기 31초 │
│ ✎ 답변 (38단어) 2.4초 │
│ ✓ 완료, 2분 40초, 도구 5번 │
╰──────────────────────────────────────────────────────────────╯
✓ 끝남 · ✗ 실패 · ⊘ 거절됨 · ■ 중단됨 · ∴ 생각 · ✎ 답변 · ⇣ 대화 압축
서브에이전트가 돌고 있으면 에이전트마다 한 줄씩 붙습니다:
╭──────────────────────────────────────────────────────────────╮
│ ▸ ● Claude: 에이전트 2개 실행 중 | 1분 12초 │
│ ◆ 계획 초안 › README.md 읽는 중 1분 12초 │
│ ◆ 쿠폰 테스트 확인 › coupon.test.ts 읽는 중 33초 │
╰──────────────────────────────────────────────────────────────╯
| 상황 | 상자에 보이는 것 | |
|---|---|---|
| 요청을 막 받았을 때 | 메시지 읽는 중 | |
| 생각할 때 | 생각 중 "…hook이 next를 기다려야 하니까" | |
| 응답을 기다릴 때 | 응답 대기 중 | |
| 파일을 다룰 때 | app.ts 읽는 중 · app.ts 수정 중 · app.ts 작성 중 | |
| 큰 파일을 쓸 때 | `register.tsx 작성 중 \ | 2.3 KB` |
| 검색할 때 | "loadConfig" 검색 중 · *.test.ts 파일 찾는 중 · "vitest coverage" 웹 검색 중 | |
| 웹에 접속할 때 | github.com 접속 중 | |
| 명령을 실행할 때 | Claude가 붙인 설명, 없으면 npm test 실행 중 | |
| 서브에이전트 | 버그 찾기 에이전트 실행 중 · 에이전트 3개 실행 중 | |
| 스킬·MCP | review 스킬 사용 중 · github/create_issue 호출 중 | |
| 승인을 기다릴 때 | 승인 대기: 테스트 실행 · 승인 대기: 테스트 실행 외 2건 | |
| 질문에 답을 기다릴 때 | 선택 기다리는 중 | |
| 도구 결과를 볼 때 | 결과 3개 확인 중 | |
| 답을 쓸 때 | 답변 작성 중 (120단어) | |
| 대화 압축 | 대화 압축 중 | |
| 쉬고 있을 때 | 대기 중 (지난 턴 완료, 42초, 도구 6번) | |
| 턴이 끝나는 방식 | 완료 · 3분 05초 만에 중단됨 · 12초 만에 오류로 멈춤 · 요청 거절 |
모든 문구와 원문, 코드 위치는 strings.ko.json에 정리돼 있습니다.
| 하는 일 | 방법 |
|---|---|
| 지금까지 한 일 펼치기·접기 | ▸ 클릭, 또는 ctrl+x tab으로 상자에 포커스를 두고 Enter |
| 프롬프트 위 영역 전체 접기 | ctrl+x ctrl+a |
설문 같은 다른 화면이 프롬프트 위를 쓰는 동안에는 상자가 잠깐 비켜 줍니다. 프롬프트 위에 그리는 다른 mod가 있으면 그 상자는 이 상자 아래에 쌓입니다.
승인 대기: …만 띄웁니다.$.state에만 두고 세션이 끝나면 사라집니다. 직접 확인하려면: claude plugin validate .
# calls: $.agent.list, $.clock.*, $.state.get/set, $.ui.invalidate, $.ui.resolve
| 원작 | 한국어판 | ||
|---|---|---|---|
| 화면 문구 | 영어 | 한국어 (문구 46개) | |
| 시간 표시 | 3m 05s | 3분 05초 | |
| 구분 기호 | · | `\ | ` |
| 설치 이름 | whats-agent-doing@tzafrir | whats-agent-doing@min050410 | |
| 동작·권한·hook | — | 원작과 같음 |
상자 앞의 Claude:와, Claude가 직접 쓴 텍스트(명령 설명, 생각 내용)는 번역하지 않습니다. Claude가 쓰는 언어 그대로 나옵니다.
문구는 전부 코드 안의 문자열이라 고치기 쉽습니다.
git clone https://github.com/min050410/claude-mods-whats-agent-doing-ko
cd claude-mods-whats-agent-doing-ko
claude --plugin-dir . # 이 폴더에서 바로 불러온다, 고치면 다시 읽음
strings.ko.json에서 바꾸고 싶은 문구의 file과 line을 찾습니다.hooks/register.tsx 또는 hooks/activity-of.ts의 그 줄을 고칩니다.bash claude plugin validate . claude plugin test . ``claude plugin marketplace update min050410 # 업데이트
claude plugin uninstall whats-agent-doing@min050410 # 삭제
1efd306) 기준문제나 제안은 Issues에 남겨 주세요. 동작 자체의 버그라면 원작 저장소에도 알려 주시면 좋습니다.
MIT. 원작자의 저작권 표시를 그대로 유지합니다.
hooks/register.tsx 948 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, Timer, TurnCompleteReason, TurnStepChunk } from 'claude-code'
3
4import type {
5 ActivityAgent,
6 ActivityEntry,
7 ActivityNow,
8 ActivityOutcome,
9 ActivityPhase,
10} from '../types'
11import { activityOf, partialArgsOf } from './activity-of'
12import { clip, durationOf, oneLine, printable, sizeOf, thoughtOf, wordsAdded } from './text'
13
14const IDLE: ActivityNow = {
15 phase: 'idle',
16 label: '대기 중',
17 sinceMs: 0,
18}
19
20const MAX_HISTORY = 100
21const MAX_HISTORY_ROWS = 12
22const MAX_THOUGHT_CHARS = 70
23const MAX_THOUGHT_BUFFER = 2000
24const MAX_INPUT_HEAD = 4000
25const LARGE_INPUT_CHARS = 1024
26const MAX_LABEL_CHARS = 120
27const MAX_AGENT_NAME_CHARS = 40
28const MAX_AGENT_ROWS = 6
29const STREAM_THROTTLE_MS = 250
30const TICK_MS = 1000
31
32const now = atom({ plugin: 'whats-agent-doing', key: 'now' } as const, IDLE)
33
34const history = atom(
35 { plugin: 'whats-agent-doing', key: 'history' } as const,
36 [] as readonly ActivityEntry[],
37)
38
39const team = atom(
40 { plugin: 'whats-agent-doing', key: 'agents' } as const,
41 [] as readonly ActivityAgent[],
42)
43
44const isExpanded = atom(
45 { plugin: 'whats-agent-doing', key: 'isExpanded' } as const,
46 false,
47)
48
49const PHASE_COLORS: Record<ActivityPhase, string> = {
50 idle: 'gray',
51 requesting: 'cyan',
52 thinking: 'magenta',
53 writing: 'green',
54 composing: 'blue',
55 tool: 'cyan',
56 agent: 'cyan',
57 approval: 'yellow',
58 question: 'yellow',
59 compacting: 'blue',
60}
61
62const OUTCOME_MARKS: Record<ActivityOutcome, { mark: string; color: string }> = {
63 ok: { mark: '✓', color: 'green' },
64 error: { mark: '✗', color: 'red' },
65 denied: { mark: '⊘', color: 'yellow' },
66 interrupted: { mark: '■', color: 'gray' },
67}
68
69const KIND_MARKS: Partial<Record<ActivityEntry['kind'], { mark: string; color: string }>> = {
70 turn: { mark: '›', color: 'white' },
71 thought: { mark: '∴', color: 'magenta' },
72 reply: { mark: '✎', color: 'green' },
73 compact: { mark: '⇣', color: 'blue' },
74}
75
76/** What the model is doing in the main loop, between and around tool calls. */
77type ModelPhase = 'idle' | 'requesting' | 'thinking' | 'writing' | 'composing' | 'compacting'
78
79/** A main-loop tool call in flight. */
80type Call = {
81 tool: string
82 label: string
83 startMs: number
84 isAwaitingApproval: boolean
85}
86
87/**
88 * A subagent at work, from its spawn (or its first call, when the spawn came
89 * before a reload) to its stop. `toolUseId` names the Agent call that started
90 * it; `activeMs` is when it last did something.
91 */
92type Agent = {
93 name: string
94 label: string
95 startMs: number
96 activeMs: number
97 toolUseId: string | null
98 isBackground: boolean
99}
100
101/**
102 * The module's live view of the session, from which the headline is drawn:
103 * started over on a reload, while the headline and history in `$.state` stay.
104 */
105type Live = {
106 calls: Map<string, Call>
107 agents: Map<string, Agent>
108 agentNames: Map<string, string>
109 described: Map<string, string>
110 phase: ModelPhase
111 phaseStartMs: number
112 turnStartMs: number | null
113 turnActions: number
114 isFirstRequest: boolean
115 resultsToReview: number
116 thought: string
117 thoughtWords: number
118 isThoughtInWord: boolean
119 replyWords: number
120 isReplyInWord: boolean
121 composing: { tool: string; head: string; chars: number } | null
122 lastAction: string | null
123 lastTurn: string | null
124 lastPublishMs: number
125 trailing: Timer | null
126 isSweeping: boolean
127 ticker: Timer | null
128}
129
130/**
131 * Registers What's Agent Doing: a box above the prompt naming what Claude is
132 * doing right now, and, behind its triangle, what it has done.
133 *
134 * The model's own stream (`turn.step`) says whether it is reading, thinking
135 * (the thought's latest sentence), writing the reply or composing a tool's
136 * input; `tool.call` says which tool runs, `classic.PermissionRequest` when
137 * one waits for approval, and a subagent's calls show under its Agent call.
138 *
139 * @param on the engine's registrar
140 */
141export const register: Register = on => {
142 const live: Live = {
143 calls: new Map(),
144 agents: new Map(),
145 agentNames: new Map(),
146 described: new Map(),
147 phase: 'idle',
148 phaseStartMs: 0,
149 turnStartMs: null,
150 turnActions: 0,
151 isFirstRequest: false,
152 resultsToReview: 0,
153 thought: '',
154 thoughtWords: 0,
155 isThoughtInWord: false,
156 replyWords: 0,
157 isReplyInWord: false,
158 composing: null,
159 lastAction: null,
160 lastTurn: null,
161 lastPublishMs: 0,
162 trailing: null,
163 isSweeping: false,
164 ticker: null,
165 }
166
167 on('session.start', async ($, e, next) => {
168 const result = await next(e)
169
170 await publish($, live)
171
172 return result
173 })
174
175 on('turn.start', async ($, e, next) => {
176 if (live.turnStartMs !== null) {
177 return next(e)
178 }
179
180 live.turnStartMs = await $.clock.now()
181 live.turnActions = 0
182 live.isFirstRequest = true
183 live.resultsToReview = 0
184 live.lastAction = null
185 live.calls.clear()
186
187 const prompt = oneLine(e.text)
188
189 if (prompt !== '') {
190 await remember($, { kind: 'turn', label: clip(prompt, 60), durationMs: null, outcome: 'ok' })
191 }
192
193 await enter($, live, 'requesting')
194
195 keepTicking($, live)
196 await publish($, live)
197
198 return next(e)
199 })
200
201 on('turn.step', async function* ($, e, next) {
202 if (e.agentId !== undefined) {
203 return yield* next(e)
204 }
205
206 await enter($, live, 'requesting')
207 await publish($, live)
208
209 try {
210 for await (const chunk of next(e)) {
211 await follow($, live, chunk)
212 yield chunk
213 }
214 } finally {
215 live.isFirstRequest = false
216 live.composing = null
217 await enter($, live, 'requesting')
218 await publish($, live)
219 }
220 })
221
222 on('tool.call', async ($, e, next) => {
223 const label = activityOf(e.tool, e as unknown as Record<string, unknown>)
224
225 if (e.agentId !== undefined) {
226 const agent = await agentOf($, live, e.agentId)
227
228 // Kept after the call: the agent's last action stands while it thinks.
229 agent.label = label
230 agent.activeMs = await $.clock.now()
231 await publish($, live, false)
232
233 try {
234 return await next(e)
235 } finally {
236 agent.label = label
237 await publish($, live, false)
238 }
239 }
240
241 const startMs = await $.clock.now()
242
243 live.calls.set(e.tool_use_id, { tool: e.tool, label, startMs, isAwaitingApproval: false })
244 live.lastAction = label
245 live.turnActions += 1
246 await publish($, live)
247
248 let outcome: ActivityOutcome = 'interrupted'
249
250 try {
251 const result = await next(e)
252
253 outcome = result.deny !== undefined ? 'denied' : result.isError ? 'error' : 'ok'
254
255 if (isAgentTool(e.tool)) {
256 const args = e as unknown as Record<string, unknown>
257 const handle = args['name']
258 const description = args['description']
259
260 // A named agent runs as a teammate, which the engine lists by the
261 // call's `name`; the call's `description` is what to show for it.
262 if (typeof handle === 'string' && typeof description === 'string' && oneLine(description) !== '') {
263 live.described.set(handle, description)
264 void sweep($, live)
265 }
266
267 await launched($, live, result.result, description)
268 }
269
270 return result
271 } finally {
272 live.calls.delete(e.tool_use_id)
273 live.resultsToReview += 1
274
275 await remember($, {
276 kind: 'tool',
277 label,
278 durationMs: (await $.clock.now()) - startMs,
279 outcome,
280 })
281
282 await publish($, live)
283 }
284 })
285
286 on('classic.PermissionRequest', async ($, e, next) => {
287 if (e.agent_id !== undefined) {
288 const agent = await agentOf($, live, e.agent_id)
289
290 agent.label = `승인 대기: ${agent.label}`
291 } else {
292 const waiting = [...live.calls.values()]
293 .reverse()
294 .find(call => call.tool === e.tool_name && !call.isAwaitingApproval)
295
296 if (waiting) {
297 waiting.isAwaitingApproval = true
298 }
299 }
300
301 await publish($, live)
302
303 return next(e)
304 })
305
306 // The run-in-background pill shows only under a call that is running, so
307 // its drawing means the person has answered the approval it waited on.
308 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
309 const call = live.calls.get(e.props.tool_use_id)
310
311 if (call?.isAwaitingApproval) {
312 call.isAwaitingApproval = false
313 $.clock.after(0, () => void publish($, live))
314 }
315
316 return next(e)
317 })
318
319 on('agent.spawn', async ($, e, next) => {
320 const result = await next(e)
321
322 if (result.agentId !== undefined) {
323 const startMs = await $.clock.now()
324 const name = e.description || e.name || '에이전트'
325
326 live.agentNames.set(result.agentId, name)
327 live.agents.set(result.agentId, {
328 name,
329 label: '준비 중',
330 startMs,
331 activeMs: startMs,
332 toolUseId: e.tool_use_id,
333 isBackground: e.background,
334 })
335 keepTicking($, live)
336 await publish($, live)
337 }
338
339 return result
340 })
341
342 on('classic.SubagentStop', async ($, e, next) => {
343 await finish($, live, e.agent_id, 'ok')
344
345 return next(e)
346 })
347
348 on('classic.PreCompact', async ($, e, next) => {
349 await enter($, live, 'compacting')
350 await publish($, live)
351
352 return next(e)
353 })
354
355 on('classic.PostCompact', async ($, e, next) => {
356 const durationMs = (await $.clock.now()) - live.phaseStartMs
357
358 live.phase = live.turnStartMs === null ? 'idle' : 'requesting'
359 await remember($, { kind: 'compact', label: '대화 압축 완료', durationMs, outcome: 'ok' })
360 await publish($, live)
361
362 return next(e)
363 })
364
365 on('turn.complete', async ($, e, next) => {
366 if (e.agentId !== undefined) {
367 await finish($, live, e.agentId, outcomeOf(e.reason))
368
369 return next(e)
370 }
371
372 if (live.turnStartMs === null) {
373 return next(e)
374 }
375
376 const durationMs = (await $.clock.now()) - live.turnStartMs
377 const took = durationOf(durationMs)
378
379 const [label, outcome]: [string, ActivityOutcome] =
380 e.reason === 'aborted'
381 ? [`${took} 만에 중단됨`, 'interrupted']
382 : e.reason === 'error'
383 ? [`${took} 만에 오류로 멈춤`, 'error']
384 : e.reason === 'refusal'
385 ? ['요청 거절', 'denied']
386 : [`완료, ${took}, 도구 ${live.turnActions}번`, 'ok']
387
388 await enter($, live, 'idle')
389 await remember($, { kind: 'end', label: capitalized(label), durationMs, outcome })
390
391 live.lastTurn = label
392 live.turnStartMs = null
393 live.calls.clear()
394
395 keepTicking($, live)
396 await publish($, live)
397
398 return next(e)
399 })
400
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 if (e.props.hasSurvey) {
403 return next(e)
404 }
405
406 // The band is one site for every plugin: what the plugins beneath draw
407 // stays, under the box, so another mod's band is never hidden.
408 const below = await next(e)
409
410 const { Box, Text, Button } = $.ui.resolve(e)
411
412 const headline = await read($, now)
413 const entries = await read($, history)
414 const working = await read($, team)
415 const isOpen = await read($, isExpanded)
416 const nowMs = await $.clock.now()
417
418 const isWorking = headline.phase !== 'idle'
419 const color = PHASE_COLORS[headline.phase]
420 const elapsed = isWorking ? ` | ${durationOf(nowMs - headline.sinceMs)}` : ''
421
422 // One agent the headline names; more, or one beside the main loop's
423 // work, get a row each.
424 const isTeamListed = working.length > 1 || (working.length === 1 && headline.phase !== 'agent')
425 const listed = isTeamListed ? working.slice(-MAX_AGENT_ROWS) : []
426 const unlisted = isTeamListed ? working.length - listed.length : 0
427
428 const agentRows = listed.map((agent, i) => (
429 <Box key={`agent-${i}`} flexDirection="row">
430 <Box flexShrink={0}>
431 <Text color={PHASE_COLORS.agent}>{'◆ '}</Text>
432 </Box>
433 <Text bold wrap="truncate-end">{printable(agent.name, MAX_AGENT_NAME_CHARS)}</Text>
434 <Text wrap="truncate-end">{` › ${printable(agent.label, MAX_LABEL_CHARS)}`}</Text>
435 <Box flexShrink={0}>
436 <Text dimColor>{` ${durationOf(nowMs - agent.sinceMs)}`}</Text>
437 </Box>
438 </Box>
439 ))
440
441 const moreAgents = unlisted > 0 ? [<Text dimColor>{`… 외 ${unlisted}개`}</Text>] : []
442
443 const teamBox = isTeamListed
444 ? [
445 <Box key="agents" flexDirection="column">
446 {agentRows}
447 {moreAgents}
448 </Box>,
449 ]
450 : []
451
452 const teamRows = listed.length + moreAgents.length
453 const rows = Math.min(MAX_HISTORY_ROWS, Math.max(1, e.props.maxRows - 4 - teamRows))
454 const shown = entries.slice(-rows)
455 const hidden = entries.length - shown.length
456
457 const historyRows = shown.map((entry, i) => {
458 const { mark, color: markColor } = KIND_MARKS[entry.kind] ?? OUTCOME_MARKS[entry.outcome]
459 const isQuiet = entry.kind === 'thought' || entry.kind === 'end'
460 const took = entry.durationMs === null ? '' : ` ${durationOf(entry.durationMs)}`
461
462 return (
463 <Box key={`row-${i}`} flexDirection="row">
464 <Box flexShrink={0}>
465 <Text color={markColor}>{`${mark} `}</Text>
466 </Box>
467 <Text bold={entry.kind === 'turn'} dimColor={isQuiet} wrap="truncate-end">
468 {printable(entry.label, MAX_LABEL_CHARS)}
469 </Text>
470 <Box flexShrink={0}>
471 <Text dimColor>{took}</Text>
472 </Box>
473 </Box>
474 )
475 })
476
477 const earlier = hidden > 0 ? [<Text dimColor>{`… 이전 ${hidden}개`}</Text>] : []
478 const empty = entries.length === 0 ? [<Text dimColor>No actions yet</Text>] : []
479
480 const body = isOpen
481 ? [
482 <Box key="history" flexDirection="column" marginTop={1}>
483 {earlier}
484 {historyRows}
485 {empty}
486 </Box>,
487 ]
488 : []
489
490 return (
491 <Box flexDirection="column">
492 <Box
493 flexDirection="column"
494 borderStyle="round"
495 borderColor={isWorking ? color : 'gray'}
496 paddingX={1}
497 alignSelf="flex-start"
498 >
499 <Box key="activity" flexDirection="row">
500 <Button
501 key="toggle"
502 plain
503 label={isOpen ? '▾' : '▸'}
504 onPress={() => update($, isExpanded, open => !open)}
505 />
506 {/* On a narrow screen only the label gives way: the rest would wrap. */}
507 <Box flexShrink={0}>
508 <Text color={color}>{isWorking ? ' ● ' : ' ○ '}</Text>
509 <Text bold>Claude: </Text>
510 </Box>
511 <Text wrap="truncate-end">{printable(headline.label, MAX_LABEL_CHARS)}</Text>
512 <Box flexShrink={0}>
513 <Text dimColor>{elapsed}</Text>
514 </Box>
515 </Box>
516 {teamBox}
517 {body}
518 </Box>
519 {below}
520 </Box>
521 )
522 })
523}
524
525/** The headline as the live view has it. */
526function current(live: Live): ActivityNow {
527 if (live.phase === 'compacting') {
528 return { phase: 'compacting', label: '대화 압축 중', sinceMs: live.phaseStartMs }
529 }
530
531 const open = [...live.calls.entries()]
532 const [newestId, newest] = open.at(-1) ?? []
533
534 if (newestId !== undefined && newest !== undefined) {
535 const more = open.length > 1 ? ` 외 ${open.length - 1}건` : ''
536
537 if (newest.isAwaitingApproval) {
538 return {
539 phase: 'approval',
540 label: `승인 대기: ${newest.label}${more}`,
541 sinceMs: newest.startMs,
542 }
543 }
544
545 if (newest.tool === 'AskUserQuestion') {
546 return { phase: 'question', label: newest.label, sinceMs: newest.startMs }
547 }
548
549 const inner = isAgentTool(newest.tool) ? agentUnder(live, newestId)?.label : undefined
550
551 return inner === undefined
552 ? { phase: 'tool', label: `${newest.label}${more}`, sinceMs: newest.startMs }
553 : { phase: 'agent', label: `${newest.label} › ${inner}${more}`, sinceMs: newest.startMs }
554 }
555
556 if (live.phase === 'idle' && live.agents.size > 0) {
557 // Between turns, background agents may still be at work.
558 const working = [...live.agents.values()]
559 const sinceMs = Math.min(...working.map(agent => agent.startMs))
560 const busiest = working.reduce((a, b) => (b.activeMs > a.activeMs ? b : a))
561
562 return {
563 phase: 'agent',
564 label: working.length === 1
565 ? `${busiest.name} › ${busiest.label}`
566 : `에이전트 ${working.length}개 실행 중`,
567 sinceMs,
568 }
569 }
570
571 const sinceMs = live.phaseStartMs
572
573 switch (live.phase) {
574 case 'requesting':
575 return {
576 phase: 'requesting',
577 label: live.isFirstRequest
578 ? '메시지 읽는 중'
579 : live.resultsToReview > 0
580 ? `결과 ${live.resultsToReview}개 확인 중`
581 : '응답 대기 중',
582 sinceMs,
583 }
584 case 'thinking': {
585 // With no thought text to quote, the last action stands, the dot
586 // turned to thinking's color; before any, the prompt is what it reads.
587 const snippet = thoughtOf(live.thought, MAX_THOUGHT_CHARS)
588 const label = snippet ? `생각 중 "${snippet}"` : standingLabel(live)
589
590 return { phase: 'thinking', label, sinceMs }
591 }
592 case 'writing':
593 return {
594 phase: 'writing',
595 label: `답변 작성 중 (${live.replyWords}단어)`,
596 sinceMs,
597 }
598 case 'composing': {
599 // A call is named once its input says what it does: a file tool by
600 // its path, a command by its description, which streams after the
601 // command itself. Until then the last action stands.
602 const { composing } = live
603 const args = composing ? partialArgsOf(composing.head) : {}
604 const isDescribed = composing !== null && (isShellTool(composing.tool)
605 ? typeof args['description'] === 'string'
606 : Object.keys(args).length > 0)
607
608 if (!composing || !isDescribed) {
609 return { phase: 'composing', label: standingLabel(live), sinceMs }
610 }
611
612 const size = composing.chars >= LARGE_INPUT_CHARS ? ` | ${sizeOf(composing.chars)}` : ''
613
614 return { phase: 'composing', label: `${activityOf(composing.tool, args)}${size}`, sinceMs }
615 }
616 case 'idle':
617 return live.lastTurn === null
618 ? IDLE
619 : { ...IDLE, label: `대기 중 (지난 턴 ${live.lastTurn})` }
620 }
621}
622
623/**
624 * Writes the headline. A streaming update (`isUrgent` false) lands at most
625 * every STREAM_THROTTLE_MS, the last one held on a timer.
626 */
627async function publish($: EngineInterface, live: Live, isUrgent = true): Promise<void> {
628 const nowMs = await $.clock.now()
629
630 if (!isUrgent && nowMs - live.lastPublishMs < STREAM_THROTTLE_MS) {
631 live.trailing ??= $.clock.after(STREAM_THROTTLE_MS, () => {
632 live.trailing = null
633 void publish($, live)
634 })
635
636 return
637 }
638
639 live.lastPublishMs = nowMs
640 const headline = current(live)
641 const working = [...live.agents.values()].map(agent => ({
642 name: agent.name,
643 label: agent.label,
644 sinceMs: agent.startMs,
645 }))
646
647 await update($, now, () => headline)
648 await update($, team, () => working)
649}
650
651/** The agent at work under `agentId`, taken up if its spawn came before a reload. */
652async function agentOf($: EngineInterface, live: Live, agentId: string): Promise<Agent> {
653 const known = live.agents.get(agentId)
654
655 if (known) {
656 return known
657 }
658
659 const startMs = await $.clock.now()
660 const agent: Agent = {
661 name: live.agentNames.get(agentId) ?? '에이전트',
662 label: '준비 중',
663 startMs,
664 activeMs: startMs,
665 toolUseId: null,
666 isBackground: true,
667 }
668
669 live.agents.set(agentId, agent)
670 keepTicking($, live)
671
672 return agent
673}
674
675/**
676 * Lets an agent go. A background agent's run is recorded in the history; a
677 * foreground one's is its Agent call's row.
678 */
679async function finish(
680 $: EngineInterface,
681 live: Live,
682 agentId: string,
683 outcome: ActivityOutcome,
684): Promise<void> {
685 const agent = live.agents.get(agentId)
686
687 if (!agent) {
688 return
689 }
690
691 live.agents.delete(agentId)
692
693 if (agent.isBackground) {
694 await remember($, {
695 kind: 'tool',
696 label: `${agent.name} 에이전트`,
697 durationMs: (await $.clock.now()) - agent.startMs,
698 outcome,
699 })
700 }
701
702 keepTicking($, live)
703 await publish($, live)
704}
705
706/**
707 * Names agents first met by their calls, and lets go of agents the engine
708 * lists as no longer running, in case their stop never reached the box.
709 */
710async function sweep($: EngineInterface, live: Live): Promise<void> {
711 if (live.agents.size === 0 || live.isSweeping) {
712 return
713 }
714
715 live.isSweeping = true
716
717 try {
718 await sweepListed($, live, await $.agent.list())
719 } catch {
720 // The list is a backstop; the box keeps going without it.
721 } finally {
722 live.isSweeping = false
723 }
724}
725
726async function sweepListed($: EngineInterface, live: Live, listed: readonly AgentInfo[]): Promise<void> {
727 const statuses = new Map(listed.map(info => [info.id, info.status]))
728 let isRenamed = false
729
730 for (const [agentId, agent] of [...live.agents.entries()]) {
731 const status = statuses.get(agentId)
732
733 // An agent first met by its calls is named once the engine lists it.
734 if (!live.agentNames.has(agentId)) {
735 const handle = listed.find(info => info.id === agentId)?.name
736 const described = handle === undefined ? undefined : live.described.get(handle)
737
738 if (described !== undefined) {
739 live.agentNames.set(agentId, described)
740 }
741
742 const name = described ?? handle
743
744 if (name !== undefined && name !== agent.name) {
745 agent.name = name
746 isRenamed = true
747 }
748 }
749
750 if (status !== undefined && status !== 'running') {
751 await finish($, live, agentId, status === 'completed' ? 'ok' : status === 'failed' ? 'error' : 'interrupted')
752 }
753 }
754
755 if (isRenamed) {
756 await publish($, live)
757 }
758}
759
760/**
761 * Keeps the elapsed time ticking while a turn runs or an agent works, and
762 * stops it once neither does.
763 */
764function keepTicking($: EngineInterface, live: Live): void {
765 const isBusy = live.turnStartMs !== null || live.agents.size > 0
766
767 if (isBusy && live.ticker === null) {
768 live.ticker = $.clock.every(TICK_MS, () => {
769 $.ui.invalidate('ui.render')
770 void sweep($, live)
771 })
772 } else if (!isBusy && live.ticker !== null) {
773 live.ticker.cancel()
774 live.ticker = null
775 }
776}
777
778async function remember($: EngineInterface, entry: ActivityEntry): Promise<void> {
779 await update($, history, entries => [...entries, entry].slice(-MAX_HISTORY))
780}
781
782/** Enters a model phase, recording the thinking or reply block it closes. */
783async function enter($: EngineInterface, live: Live, next: ModelPhase): Promise<void> {
784 if (next === live.phase) {
785 return
786 }
787
788 const nowMs = await $.clock.now()
789 const durationMs = nowMs - live.phaseStartMs
790
791 if (live.phase === 'thinking') {
792 const words = live.thoughtWords
793
794 await remember($, {
795 kind: 'thought',
796 label: words > 0 ? `생각 (${words}단어)` : '생각',
797 durationMs,
798 outcome: 'ok',
799 })
800 }
801
802 if (live.phase === 'writing' && live.replyWords > 0) {
803 await remember($, {
804 kind: 'reply',
805 label: `답변 (${live.replyWords}단어)`,
806 durationMs,
807 outcome: 'ok',
808 })
809 }
810
811 if (next === 'thinking') {
812 live.thought = ''
813 live.thoughtWords = 0
814 live.isThoughtInWord = false
815 }
816
817 if (next === 'writing') {
818 live.replyWords = 0
819 live.isReplyInWord = false
820 }
821
822 live.phase = next
823 live.phaseStartMs = nowMs
824}
825
826/** Follows one chunk of the main loop's response. */
827async function follow($: EngineInterface, live: Live, chunk: TurnStepChunk): Promise<void> {
828 switch (chunk.kind) {
829 case 'thinking': {
830 const isNew = live.phase !== 'thinking'
831
832 await enter($, live, 'thinking')
833 live.resultsToReview = 0
834 const { added, isInWord } = wordsAdded(chunk.text, live.isThoughtInWord)
835
836 live.thought = (live.thought + chunk.text).slice(-MAX_THOUGHT_BUFFER)
837 live.thoughtWords += added
838 live.isThoughtInWord = isInWord
839 await publish($, live, isNew)
840
841 return
842 }
843 case 'text': {
844 const isNew = live.phase !== 'writing'
845
846 await enter($, live, 'writing')
847 live.resultsToReview = 0
848 const { added, isInWord } = wordsAdded(chunk.text, live.isReplyInWord)
849
850 live.replyWords += added
851 live.isReplyInWord = isInWord
852 await publish($, live, isNew)
853
854 return
855 }
856 case 'tool':
857 await enter($, live, 'composing')
858 live.resultsToReview = 0
859 live.composing = { tool: chunk.name, head: '', chars: 0 }
860 await publish($, live)
861
862 return
863 case 'input': {
864 const { composing } = live
865
866 if (composing) {
867 composing.chars += chunk.json.length
868
869 if (composing.head.length < MAX_INPUT_HEAD) {
870 composing.head += chunk.json
871 }
872
873 await publish($, live, false)
874 }
875
876 return
877 }
878 }
879}
880
881/**
882 * What the box keeps saying while Claude thinks or writes a call it cannot
883 * name yet: the turn's last action, or the prompt before there is one.
884 */
885function standingLabel(live: Live): string {
886 return live.lastAction ?? '메시지 읽는 중'
887}
888
889/**
890 * Names a background agent by the Agent call that launched it: the call's
891 * short `description`, keyed by the id its result carries (the result's own
892 * `description` can hold the whole prompt). Its own calls may already have
893 * shown it.
894 */
895async function launched(
896 $: EngineInterface,
897 live: Live,
898 result: unknown,
899 asked: unknown,
900): Promise<void> {
901 if (typeof result !== 'object' || result === null) {
902 return
903 }
904
905 const { agentId, description, status } = result as Record<string, unknown>
906
907 if (typeof agentId !== 'string' || status !== 'async_launched') {
908 return
909 }
910
911 const name = [asked, description].find(
912 (text): text is string => typeof text === 'string' && oneLine(text) !== '',
913 )
914
915 if (name === undefined) {
916 return
917 }
918
919 const agent = await agentOf($, live, agentId)
920
921 live.agentNames.set(agentId, name)
922 agent.name = name
923 await publish($, live)
924}
925
926/** The agent an Agent call started, or the one that last did something. */
927function agentUnder(live: Live, toolUseId: string): Agent | undefined {
928 const working = [...live.agents.values()]
929
930 return working.find(agent => agent.toolUseId === toolUseId)
931 ?? working.reduce<Agent | undefined>((a, b) => (a === undefined || b.activeMs > a.activeMs ? b : a), undefined)
932}
933
934function outcomeOf(reason: TurnCompleteReason): ActivityOutcome {
935 return reason === 'aborted' ? 'interrupted' : reason === 'error' ? 'error' : reason === 'refusal' ? 'denied' : 'ok'
936}
937
938function isShellTool(tool: string): boolean {
939 return tool === 'Bash' || tool === 'PowerShell'
940}
941
942function isAgentTool(tool: string): boolean {
943 return tool === 'Agent' || tool === 'Task'
944}
945
946function capitalized(text: string): string {
947 return text.charAt(0).toUpperCase() + text.slice(1)
948}hooks/activity-of.ts 97 lines1import { clip, oneLine } from './text'
2
3const MAX_DETAIL_CHARS = 60
4
5/**
6 * Says in a few words what a tool call is doing, for What's Agent Doing.
7 *
8 * @param tool the tool's name; a string, as the declared names are only the
9 * tools of the build that wrote the types (Grep and Glob are on some only)
10 * @param args the call's arguments, whole or as far as they have streamed
11 * @returns a short label such as `Reading register.tsx`
12 */
13export function activityOf(tool: string, args: Record<string, unknown>): string {
14 const arg = (key: string): string => {
15 const value = args[key]
16
17 return typeof value === 'string' ? clip(oneLine(value), MAX_DETAIL_CHARS) : ''
18 }
19
20 const file = (key: string): string => baseName(arg(key))
21
22 switch (tool) {
23 case 'Bash':
24 case 'PowerShell':
25 return arg('description') || `${arg('command')} 실행 중`
26 case 'Read':
27 return `${file('file_path')} 읽는 중`
28 case 'Edit':
29 return `${file('file_path')} 수정 중`
30 case 'Write':
31 return `${file('file_path')} 작성 중`
32 case 'NotebookEdit':
33 return `${file('notebook_path')} 수정 중`
34 case 'Grep':
35 return `"${arg('pattern')}" 검색 중`
36 case 'Glob':
37 return `${arg('pattern')} 파일 찾는 중`
38 case 'WebFetch':
39 return `${hostOf(arg('url'))} 접속 중`
40 case 'WebSearch':
41 return `"${arg('query')}" 웹 검색 중`
42 case 'Agent':
43 case 'Task':
44 return `${arg('description')} 에이전트 실행 중`
45 case 'TodoWrite':
46 return '할 일 정리 중'
47 case 'AskUserQuestion':
48 return '선택 기다리는 중'
49 case 'Skill':
50 return `${arg('skill')} 스킬 사용 중`
51 }
52
53 if (tool.startsWith('mcp__')) {
54 const [, server = '', name = ''] = tool.split('__')
55
56 return `${server}/${name} 호출 중`
57 }
58
59 return `${tool} 사용 중`
60}
61
62/**
63 * The string arguments a tool call's input has streamed so far, read from
64 * its partial JSON: enough to name the file or command before it is whole.
65 */
66export function partialArgsOf(json: string): Record<string, unknown> {
67 const args: Record<string, unknown> = {}
68
69 for (const match of json.matchAll(/"([a-z_]+)"\s*:\s*"((?:[^"\\]|\\.)*)/g)) {
70 const [, key = '', raw = ''] = match
71
72 args[key] ??= unescaped(raw)
73 }
74
75 return args
76}
77
78function unescaped(raw: string): string {
79 try {
80 return JSON.parse(`"${raw}"`) as string
81 } catch {
82 return raw
83 }
84}
85
86function baseName(path: string): string {
87 return path.split(/[\\/]/).pop() || path
88}
89
90function hostOf(url: string): string {
91 try {
92 return new URL(url).host
93 } catch {
94 return url
95 }
96}
97hooks/text.ts 99 lines1/** Collapses runs of whitespace, newlines included, to single spaces. */
2export function oneLine(text: string): string {
3 return text.replace(/\s+/g, ' ').trim()
4}
5
6/** Cuts `text` to `max` characters, marking the cut with an ellipsis. */
7export function clip(text: string, max: number): string {
8 return text.length > max ? `${text.slice(0, max - 1)}…` : text
9}
10
11/**
12 * The sentence Claude is thinking right now: the tail of the thought so far,
13 * with the sentence before it when the current one has barely started.
14 *
15 * @param thought the thinking block's text so far
16 * @param max the longest snippet, cut from the front
17 */
18export function thoughtOf(thought: string, max: number): string {
19 const flat = oneLine(thought.replace(/[*_`#>]/g, ''))
20
21 if (flat === '') {
22 return ''
23 }
24
25 const sentences = flat.split(/(?<=[.!?:])\s+/)
26 const last = sentences.at(-1) ?? ''
27 const before = sentences.at(-2)
28 const tail = last.length < 25 && before !== undefined ? `${before} ${last}` : last
29
30 return tail.length > max ? `…${tail.slice(-(max - 1))}` : tail
31}
32
33export function wordsIn(text: string): number {
34 return text.split(/\s+/).filter(Boolean).length
35}
36
37export function plural(count: number, word: string): string {
38 return `${count} ${word}${count === 1 ? '' : 's'}`
39}
40
41/** `840 B`, `2.3 KB`, `1.1 MB`. */
42export function sizeOf(chars: number): string {
43 if (chars < 1024) {
44 return `${chars} B`
45 }
46
47 if (chars < 1024 * 1024) {
48 return `${(chars / 1024).toFixed(1)} KB`
49 }
50
51 return `${(chars / 1024 / 1024).toFixed(1)} MB`
52}
53
54/** `0.4s` under ten seconds, `12s` under a minute, `3m 05s` after. */
55export function durationOf(ms: number): string {
56 const seconds = Math.max(0, ms) / 1000
57
58 if (seconds < 10) {
59 return `${seconds.toFixed(1)}초`
60 }
61
62 if (seconds < 60) {
63 return `${Math.floor(seconds)}초`
64 }
65
66 const minutes = Math.floor(seconds / 60)
67 const rest = Math.floor(seconds % 60)
68
69 return `${minutes}분 ${String(rest).padStart(2, '0')}초`
70}
71
72/**
73 * A label safe to draw: control, format and line-separator characters out
74 * (the engine refuses a drawing holding one), whitespace collapsed, and cut
75 * to `max`. Labels carry paths, commands and thoughts the model wrote.
76 */
77export function printable(text: string, max: number): string {
78 const spaced = text.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').replace(/\p{Cf}/gu, '')
79
80 return clip(oneLine(spaced), max)
81}
82
83/**
84 * Counts the words a streamed chunk adds, carrying whether the text so far
85 * ends inside a word, so a reply is never counted again from its start.
86 *
87 * @param chunk the newly streamed text
88 * @param isInWord whether the text before it ended inside a word
89 */
90export function wordsAdded(chunk: string, isInWord: boolean): { added: number; isInWord: boolean } {
91 const words = chunk.match(/\S+/g)?.length ?? 0
92 const continues = isInWord && /^\S/.test(chunk)
93
94 return {
95 added: continues ? words - 1 : words,
96 isInWord: chunk === '' ? isInWord : /\S$/.test(chunk),
97 }
98}
99types/index.d.ts 58 lines1/**
2 * What the box says Claude is doing: `idle` between turns, a model phase
3 * (`requesting`, `thinking`, `writing`, `composing`), a tool phase (`tool`,
4 * `agent`, `approval`, `question`), or `compacting`.
5 */
6export type ActivityPhase =
7 | 'idle'
8 | 'requesting'
9 | 'thinking'
10 | 'writing'
11 | 'composing'
12 | 'tool'
13 | 'agent'
14 | 'approval'
15 | 'question'
16 | 'compacting'
17
18/** The box's headline: the phase, its words, and when it began. */
19export type ActivityNow = {
20 phase: ActivityPhase
21 label: string
22 sinceMs: number
23}
24
25/**
26 * A subagent at work: what it was asked to do, its latest action, and when
27 * it started.
28 */
29export type ActivityAgent = {
30 name: string
31 label: string
32 sinceMs: number
33}
34
35export type ActivityOutcome = 'ok' | 'error' | 'denied' | 'interrupted'
36
37/**
38 * One row of the expanded box: a prompt that started a turn, a stretch of
39 * thinking, a reply, a tool call, a compaction, or how a turn ended.
40 */
41export type ActivityEntry = {
42 kind: 'turn' | 'thought' | 'reply' | 'tool' | 'compact' | 'end'
43 label: string
44 durationMs: number | null
45 outcome: ActivityOutcome
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'whats-agent-doing': {
51 now: ActivityNow
52 history: readonly ActivityEntry[]
53 agents: readonly ActivityAgent[]
54 isExpanded: boolean
55 }
56 }
57}
58