SLOPSHOPPER

What's Agent Doing (한국어판)

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

newbandguardtimeragents
v0.4.3-ko.1MITupdated 2026-10-15min050410/claude-mods-whats-agent-doing-ko
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whats-agent-doing
› 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. ✻ Worked for 42s · done 4:20 PM ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ○ Claude: 대기 중 (지난 턴 완료, 0.7초, 도구 9번) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ○ Claude: 대기 중 (지난 턴 완료, 0.7초, 도구 9번) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

What's Agent Doing

Claude가 지금 뭘 하고 있는지, 프롬프트 바로 위에서 한국어로.

Claude Code mod

version license network 원작

<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/cartbisect 정리하고 장바구니 테스트 다시 돌리기

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개 실행 중
스킬·MCPreview 스킬 사용 중 · github/create_issue 호출 중
승인을 기다릴 때승인 대기: 테스트 실행 · 승인 대기: 테스트 실행 외 2건
질문에 답을 기다릴 때선택 기다리는 중
도구 결과를 볼 때결과 3개 확인 중
답을 쓸 때답변 작성 중 (120단어)
대화 압축대화 압축 중
쉬고 있을 때대기 중 (지난 턴 완료, 42초, 도구 6번)
턴이 끝나는 방식완료 · 3분 05초 만에 중단됨 · 12초 만에 오류로 멈춤 · 요청 거절

모든 문구와 원문, 코드 위치는 strings.ko.json에 정리돼 있습니다.


단축키

하는 일방법
지금까지 한 일 펼치기·접기▸ 클릭, 또는 ctrl+x tab으로 상자에 포커스를 두고 Enter
프롬프트 위 영역 전체 접기ctrl+x ctrl+a

설문 같은 다른 화면이 프롬프트 위를 쓰는 동안에는 상자가 잠깐 비켜 줍니다. 프롬프트 위에 그리는 다른 mod가 있으면 그 상자는 이 상자 아래에 쌓입니다.


안전한가요?

  • 보여 주기만 합니다. 모든 hook은 이벤트를 읽고 받은 그대로 넘깁니다. 설정, 지시문, 도구 호출과 결과, 모델 출력이 이 mod가 없을 때와 똑같이 Claude에게 갑니다.
  • 승인 창에 대신 답하지 않습니다. 승인 요청을 가로채지만 허용·거부·변경은 하지 않고, 상자에 승인 대기: …만 띄웁니다.
  • 밖으로 나가는 것이 없습니다. 네트워크, 파일 읽기·쓰기, 프로세스 실행, 모델 호출, 세션을 넘는 저장 모두 없습니다. 상태는 그 세션의 $.state에만 두고 세션이 끝나면 사라집니다. 직접 확인하려면:
  claude plugin validate .
  # calls: $.agent.list, $.clock.*, $.state.get/set, $.ui.invalidate, $.ui.resolve
  • 화면에는 작업 내용이 보입니다. 프롬프트, 파일 경로, URL, 명령 설명(없으면 명령 자체), MCP 도구 이름, 생각 한 줄이 상자에 나옵니다. 화면 공유 중이라면 참고하세요. 제어 문자와 보이지 않는 문자는 지우고 그립니다.

원작과 다른 점

원작한국어판
화면 문구영어한국어 (문구 46개)
시간 표시3m 05s3분 05초
구분 기호·`\`
설치 이름whats-agent-doing@tzafrirwhats-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 .        # 이 폴더에서 바로 불러온다, 고치면 다시 읽음
  1. strings.ko.json에서 바꾸고 싶은 문구의 file과 line을 찾습니다.
  2. hooks/register.tsx 또는 hooks/activity-of.ts의 그 줄을 고칩니다.
  3. 확인합니다. ``bash claude plugin validate . claude plugin test . ``

업데이트·삭제

claude plugin marketplace update min050410             # 업데이트
claude plugin uninstall whats-agent-doing@min050410    # 삭제

크레딧

문제나 제안은 Issues에 남겨 주세요. 동작 자체의 버그라면 원작 저장소에도 알려 주시면 좋습니다.

라이선스

MIT. 원작자의 저작권 표시를 그대로 유지합니다.

Source 4 files
hooks/register.tsx 948 lines
1import { 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 lines
1import { 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}
97
hooks/text.ts 99 lines
1/** 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}
99
types/index.d.ts 58 lines
1/**
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