Live status bar above the prompt for each turn: request, thinking, working, answer

프롬프트 위에 지금 턴의 진행 상태를 바 한 줄로 보여주는 Claude Code 모드입니다. 턴은 요청 → 생각 → 작업 → 답변 순서로 진행됩니다.
작업 중 2/3)를 표시하고, 오른쪽에 경과 시간을 표시Edit · register.tsx · 0.8s 같은 툴팁이 나옵니다.Claude Code에서:
/plugin marketplace add Dan-Millie-Front/turn-progress
/plugin install turn-progress@millie-mods
함수 훅 모드를 쓰므로 Claude Code 2.1.286 이상이 필요합니다. 긴 요청의 제목 요약에 Haiku를 턴당 약 100토큰 씁니다.
claude --plugin-dir ~/Github/turn-progress
데스크톱 앱에서 항상 켜려면 ~/.claude/settings.json에 아래를 추가합니다.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/Github/turn-progress" } }
/turnbar 표시/숨김/turnbar-clear 바 제거claude plugin validate .
claude plugin test .
npx -y -p typescript@5.6 tsc -p .
자세한 명세는 docs/SPEC.md에 있습니다.
UI는 MIT 라이선스인 plan-progress(Kirill Serditov)에서 가져와 고쳤습니다.
hooks/register.tsx 932 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Phase, ToolRun, TurnBar, TurnState } from '../types'
5
6const bars = atom({ plugin: 'turn-progress', key: 'bars' } as const, [])
7const isOpen = atom({ plugin: 'turn-progress', key: 'isOpen' } as const, true)
8const tick = atom({ plugin: 'turn-progress', key: 'tick' } as const, 0)
9
10const MAX_BARS = 1 // the current turn; a new turn replaces the finished one
11const TRACK_H = 18 // the same height as an agent strip, so the rows read as one stack
12const PX_ROWS = 5 // pixel rows inside the track: 3px pitch, centred
13const NARROW = 360
14const STRIP_H = TRACK_H
15const STRIP_GAP = 3
16const MAX_STRIPS = 4 // past this, the finished ones fold into one "+N more" strip
17const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar goes
18const ASK_DELAY_MS = 600 // a permission ask still open after this waits on the person
19const ANSWER_MIN_CHARS = 280 // this much text in a step with no tool call reads as the final answer
20// while text or thinking streams, the bar is written at most this often: every write is a new picture,
21// and a picture swapped many times a second restarts its animations and reads as flicker
22const FLUSH_MS = 800
23const CLOCK_EVERY = 8 // streamed chunks between looks at the clock
24const MAX_CALLS = 60 // tool calls kept per turn: one tick each on the track
25
26const STATE_COLOR: Record<TurnState, string> = { running: '#8B7CF6', needs_input: '#E09A1E', error: '#E5484D', stopped: '#8A8984', done: '#30A46C' }
27const STATE_GLYPH: Record<TurnState, string> = { running: '●', needs_input: '?', error: '!', stopped: '■', done: '✓' }
28
29const LABEL = {
30 request: '요청 받음',
31 thinking: '생각 중',
32 working: '작업 중',
33 answering: '답변 작성',
34 needs_input: '입력 대기',
35 question: '질문',
36 plan: '플랜 승인',
37 approval: '승인 대기',
38 error: '오류',
39 apiError: 'API 오류',
40 refused: '거절됨',
41 stopped: '중단됨',
42 done: '완료',
43 untitled: '계속',
44 agentsAlt: '에이전트',
45 agentStarting: '시작 중',
46 agentDone: '완료',
47 agentFailed: '실패',
48 agentStopped: '중단됨',
49 button: 'Progress',
50 calls: '도구 호출',
51 running: '실행 중',
52 writing: '준비 중',
53 failed: '실패',
54 tokens: '토큰',
55 input: '입력',
56 output: '출력',
57 cache: '캐시',
58}
59
60// the bar's three parts; request is the moment before the first chunk
61const SEG: Record<Phase, readonly [number, number]> = { request: [0, 0], thinking: [0, 0.25], working: [0.25, 0.85], answering: [0.85, 1] }
62const ORDER: Phase[] = ['request', 'thinking', 'working', 'answering']
63
64// ---------- the live turn: counters the stream moves, written to the bar now and then ----------
65
66type Live = {
67 turnId: string
68 phase: Phase
69 activity: string
70 thinkChars: number
71 stepText: number
72 isStepTool: boolean
73 answerChars: number
74 tools: number
75 calls: ToolRun[]
76}
77
78function fracOf(l: Live): number {
79 const [a, b] = SEG[l.phase]
80 const u =
81 l.phase === 'thinking'
82 ? 1 - Math.exp(-l.thinkChars / 2400)
83 : l.phase === 'working'
84 ? 1 - Math.pow(0.8, l.tools)
85 : l.phase === 'answering'
86 ? 1 - Math.exp(-l.answerChars / 1500)
87 : 0
88
89 return a + (b - a) * Math.min(0.97, u)
90}
91
92// the fill only moves forward: thinking after a tool call changes the label, not the phase
93const advance = (l: Live, to: Phase) => {
94 if (ORDER.indexOf(to) > ORDER.indexOf(l.phase)) l.phase = to
95}
96
97// what a call works on, in a few words: a file's name, a command, a pattern or query
98function targetOf(input: Record<string, unknown>): string {
99 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
100 const path = s('file_path') || s('notebook_path') || s('path')
101 if (path) return path.split('/').filter(Boolean).pop() ?? path
102 // a call's own description (Bash, Agent) reads better than the raw command
103 const text = s('description') || s('command') || s('pattern') || s('query') || s('url') || s('skill') || s('prompt')
104 return text.replace(/\s+/g, ' ').trim().slice(0, 48)
105}
106
107const firstLine = (text: string) => {
108 const line =
109 text
110 .split(/\r?\n/)
111 .map(s => s.trim())
112 .find(Boolean) ?? ''
113 return line
114 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
115 .replace(/[*_`#>"']/g, '')
116 .replace(/\s+/g, ' ')
117 .trim()
118}
119
120const SHORT_TITLE = 20 // a one-line prompt this short is its own title; a longer one is named
121const TITLE_MODEL = 'haiku'
122const TITLE_SYSTEM =
123 'You name a task for a status bar. Reply with only a title of 2 to 5 words that says what the request asks for, in the language of the request. No quotes, no trailing punctuation, no explanation.'
124
125// asks a small model for a 2-5 word title; the prompt's first line stands in when it has none
126async function nameTurn($: EngineInterface, turnId: string, text: string) {
127 const fallback = [...firstLine(text)].slice(0, 30).join('') || LABEL.untitled
128 const r = await $.model.complete({ model: TITLE_MODEL, system: TITLE_SYSTEM, prompt: text.slice(0, 2000), maxTokens: 40, effort: 'low', timeoutMs: 10_000 })
129 const named = r.isAnswered ? [...firstLine(r.text).replace(/[.。!?…]+$/, '')].slice(0, 30).join('') : ''
130 await update($, bars, list => list.map(b => (b.id === turnId && !b.title ? { ...b, title: named || fallback } : b)))
131}
132
133// ---------- drawing ----------
134
135const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
136const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
137const rgb = (c: number[]) => `rgb(${c.join(',')})`
138const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
139const hash = (a: number, b: number, k: number) => {
140 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
141 return x - Math.floor(x)
142}
143// Hangul, CJK and full-width forms are one em wide (the pill and strip text are 11.5px)
144const WIDE = /[ᄀ-ᇿ -鿿가--]/
145// an estimate of the drawn width, for sizing the pill and truncating
146const textWidth = (s: string, px = 6.7) =>
147 [...s].reduce((w, ch) => w + (WIDE.test(ch) ? 11.5 : ch === ' ' ? 3.3 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWШЩЖМ]/.test(ch) ? 9.5 : px), 0)
148
149// terminal cells: Hangul, CJK and full-width forms take two
150const cells = (s: string) => [...s].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
151// cut to `n` cells with an ellipsis, then pad with spaces to exactly `n`, so the rows line up
152function fitCells(s: string, n: number): string {
153 if (n <= 0) return ''
154 let out = ''
155 for (const ch of s) {
156 if (cells(out + ch) > n - (cells(s) > n ? 1 : 0)) break
157 out += ch
158 }
159 if (cells(out) < cells(s)) out += '…'
160 return out + ' '.repeat(Math.max(0, n - cells(out)))
161}
162
163const ICON_PATH: Partial<Record<TurnState, string>> = {
164 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
165 error: 'M18 6 6 18M6 6l12 12',
166 stopped: 'M8 8h8v8H8z',
167 done: 'M20 6 9 17l-5-5',
168}
169// while running, the icon names the part of the turn, so every pill reads icon + label
170const PHASE_ICON: Record<Phase, string> = {
171 request: 'M5 12h14M13 6l6 6-6 6',
172 thinking: 'M5.5 12h1M11.5 12h1M17.5 12h1',
173 working: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
174 answering: 'M4 6h16M4 12h16M4 18h10',
175}
176
177const clockText = (ms: number) => {
178 const sec = Math.max(0, Math.round(ms / 1000))
179 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`
180}
181
182// a call's length: tenths under ten seconds, then the clock
183const durationText = (ms: number) => (ms < 10_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : clockText(ms))
184
185const kilo = (n: number) => (n < 1000 ? String(n) : `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`)
186
187function callText(c: ToolRun, now: number): string {
188 const time =
189 c.startedAt === null ? LABEL.writing : c.endedAt === null ? `${LABEL.running} ${durationText(now - c.startedAt)}` : durationText(c.endedAt - c.startedAt)
190 return [c.name, c.target, time, c.isError ? LABEL.failed : ''].filter(Boolean).join(' · ')
191}
192
193function tokensText(b: TurnBar): string {
194 const t = b.tokens
195 if (!t) return ''
196 const input = t.input + t.cacheRead + t.cacheWrite
197 const cached = input > 0 ? Math.round((t.cacheRead / input) * 100) : 0
198 return `${LABEL.tokens} ${LABEL.input} ${kilo(input)} (${LABEL.cache} ${cached}%) · ${LABEL.output} ${kilo(t.output)}`
199}
200
201// the folded strips: how many more, and how many of them finished
202const moreAgents = (n: number, done: number) => `에이전트 ${n}개 더 · ${done}개 완료`
203
204function pillName(b: TurnBar): string {
205 if (b.state === 'done') return LABEL.done
206 if (b.state === 'stopped') return LABEL.stopped
207 if (b.state === 'error') return b.note ? `${LABEL.error} · ${b.note}` : LABEL.error
208 if (b.state === 'needs_input') return b.note ?? LABEL.needs_input
209
210 return b.activity
211}
212
213// the part of the turn as n/3 (thinking, working, answer) and nothing else: tool calls show as ticks,
214// subagents as their own strips under the bar
215function pillCount(b: TurnBar): string {
216 const parts = ORDER.length - 1
217 const part = b.state === 'done' ? parts : Math.max(1, ORDER.indexOf(b.phase))
218
219 return `${part}/${parts}`
220}
221
222// the icon follows what happens now (the label), not the furthest phase: thinking again after a tool call shows dots
223function activityPhase(b: TurnBar): Phase {
224 if (b.activity === LABEL.thinking) return 'thinking'
225 if (b.activity === LABEL.working) return 'working'
226 if (b.activity === LABEL.answering) return 'answering'
227 return b.phase
228}
229
230// the desktop clock: its own small picture, since a Text takes no CSS and the app's digits differ in width.
231// tabular-nums gives every digit one width and the text sits on the right edge, so the row never moves;
232// apart from the track, so a new second redraws only this
233const CLOCK_W = 44
234function clockSvg(text: string): string {
235 return `<svg xmlns="http://www.w3.org/2000/svg" width="${CLOCK_W}" height="${TRACK_H}" viewBox="0 0 ${CLOCK_W} ${TRACK_H}"><style>.c{font:400 13px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;font-variant-numeric:tabular-nums;fill:#8A8984}</style><text x="${CLOCK_W}" y="${TRACK_H / 2 + 4.5}" text-anchor="end" class="c">${esc(text)}</text></svg>`
236}
237
238// last drawn head position per bar, so a redraw glides from where the bar was
239const lastHead = new Map<string, number>()
240
241function trackSvg(b: TurnBar, W: number, now: number): string {
242 const H = TRACK_H
243 const done = b.state === 'done'
244 const frac = done ? 1 : Math.min(1, Math.max(0, b.frac))
245 // snapped to the 3px pixel grid, so a tiny change of the fill draws the same picture
246 const fx = Math.round((frac * W) / 3) * 3
247 const from = lastHead.get(b.id) ?? fx
248 lastHead.set(b.id, fx)
249
250 const acc = hex(STATE_COLOR[b.state])
251 const light = mix(acc, [255, 255, 255], 0.32)
252 const grey = [132, 130, 138]
253 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
254 const glide = Math.abs(from - fx) > 0.5
255
256 // pixels: 3px grid, 7 rows, denser and closer to the state colour towards the head
257 const buckets = [0, 1, 2, 3, 4].map(k => {
258 const m = k / 4
259 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(m, 1.5)
260 return { color: rgb(done ? light : mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
261 })
262 let px = ''
263 for (let col = 0; col * 3 < fx; col++) {
264 const x = col * 3
265 const u = Math.min(1, (x + 1.5) / fx)
266 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
267 const bucket = done ? 4 : Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
268 for (let r = 0; r < PX_ROWS; r++) {
269 if (hash(col, r, 1) > dense + 0.1) continue
270 px += `<rect x="${x}" y="${(H - (PX_ROWS * 3 - 1)) / 2 + r * 3}" class="b${bucket} t${Math.floor(hash(col, r, 2) * 4)}"/>`
271 }
272 }
273
274 // a short tick per tool call, bright once passed; a wider clear strip over each carries its tooltip
275 const marks = b.calls
276 .map(c => {
277 const x = c.frac * W
278 const passed = x < fx - 1
279 const fill = c.isError ? STATE_COLOR.error : passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'
280 return `<rect x="${(x - 0.75).toFixed(1)}" y="${(H - 7) / 2}" width="1.5" height="7" rx=".75" fill="${fill}" opacity="${c.isError ? 0.9 : passed ? 0.6 : 0.45}"/>`
281 })
282 .join('')
283 // tooltips only once the bar is settled: only then is it drawn interactive, and a running call's time
284 // in them would change the picture every second
285 const isTipped = isSettled(b)
286 const hits = isTipped
287 ? b.calls
288 .map(c => `<rect x="${(c.frac * W - 4).toFixed(1)}" y="0" width="8" height="${H}" fill="transparent"><title>${esc(callText(c, now))}</title></rect>`)
289 .join('')
290 : ''
291 const pillTip = isTipped ? `<title>${esc([`${b.calls.length} ${LABEL.calls}`, tokensText(b)].filter(Boolean).join(' · '))}</title>` : ''
292
293 // knob: a pill with the activity and counts, or a round dot with the part number when narrow
294 const color = STATE_COLOR[b.state]
295 const icon = b.state === 'running' ? PHASE_ICON[activityPhase(b)] : ICON_PATH[b.state]
296 let knob = ''
297 let kw = H
298 if (W < NARROW) {
299 const number = Math.max(1, ORDER.indexOf(b.phase))
300 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
301 done
302 ? `<path d="${ICON_PATH.done}" transform="translate(-6 ${(H - 12) / 2}) scale(.5)" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
303 : `<text x="0" y="${H / 2 + 4}" text-anchor="middle" class="kt">${number}</text>`
304 }`
305 } else {
306 const name = pillName(b)
307 const count = pillCount(b)
308 const iconW = icon ? 16 : 0
309 const countW = count ? 6 + textWidth(count, 6.5) : 0
310 const maxW = Math.max(80, W * 0.55)
311 let shown = name
312 while (shown.length > 3 && 20 + iconW + textWidth(shown) + countW > maxW) shown = shown.slice(0, -1)
313 if (shown !== name) shown = shown.trimEnd() + '…'
314 kw = Math.round(20 + iconW + textWidth(shown) + countW)
315 const left = -kw / 2 + 10
316 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
317 if (icon)
318 knob += `<path d="${icon}" transform="translate(${left} ${(H - 12) / 2}) scale(.5)" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
319 knob += `<text x="${left + iconW}" y="${H / 2 + 4}" class="kt">${esc(shown)}${count ? `<tspan class="kc" dx="6">${esc(count)}</tspan>` : ''}</text>`
320 }
321 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
322 const kx = clampX(fx)
323 const kFrom = clampX(from)
324
325 // once settled the track is drawn interactive, in a frame of its own; a frame whose color-scheme differs from
326 // the app's gets an opaque light backdrop in dark mode, so the picture declares both schemes
327 const style = `<style>
328:root{color-scheme:light dark}
329.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
330.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
331.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
332rect[class]{width:2px;height:2px}
333.t0,.t1,.t2,.t3{animation:tw ${done ? 3.2 : 2.2}s ease-in-out infinite}
334.t1{animation-duration:${done ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${done ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${done ? 3.5 : 3.3}s;animation-delay:-.4s}
335@keyframes tw{0%,100%{opacity:1}50%{opacity:${done ? 0.8 : 0.45}}}
336.kt{font:500 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#fff}
337.kc{font-weight:400;fill-opacity:.75}
338@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
339</style>`
340 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
341 const glideKnob = glide
342 ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>`
343 : ''
344
345 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
346<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
347<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity="${done ? 0.3 : 0.05}"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
348<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
349<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}${hits}</g>
350<g transform="translate(${kx.toFixed(1)} 0)">${pillTip}${glideKnob}${knob}</g></svg>`
351}
352
353const AGENT_COLOR: Record<AgentRun['state'], string> = {
354 running: STATE_COLOR.running,
355 waiting: STATE_COLOR.needs_input,
356 done: STATE_COLOR.done,
357 error: STATE_COLOR.error,
358}
359
360// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
361function visibleAgents(b: TurnBar, now: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
362 const list = b.agents ?? []
363 if (list.length === 0) return null
364 const hasError = list.some(a => a.state === 'error')
365 if (b.agentsDoneAt && now - b.agentsDoneAt > FOLD_MS && !hasError) return null
366 if (list.length <= MAX_STRIPS) return { shown: list, hidden: [] }
367 const keep = new Set(
368 list
369 .filter(a => a.state !== 'done')
370 .slice(0, MAX_STRIPS - 1)
371 .map(a => a.id),
372 )
373 for (const a of [...list].reverse()) {
374 if (keep.size >= MAX_STRIPS - 1) break
375 keep.add(a.id)
376 }
377 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
378}
379
380// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
381const lastStrip = new Map<string, { tool: string; color: string }>()
382const MORPH = '.2s'
383
384const stripsHeight = (n: number) => n * STRIP_H + (n - 1) * STRIP_GAP
385
386// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
387function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): string {
388 const isNarrow = W < NARROW
389 const rows: string[] = []
390 v.shown.forEach((a, i) => {
391 const c = AGENT_COLOR[a.state]
392 const y = i * (STRIP_H + STRIP_GAP)
393 const indent = a.depth > 0 ? 12 : 0
394 let px = ''
395 if (a.state === 'running') {
396 for (let col = 0; col * 3 < W; col++) {
397 for (let r = 0; r < 4; r++) {
398 if (hash(col + i * 41, r, 5) > 0.2) continue
399 px += `<rect x="${col * 3}" y="${y + 3 + r * 3.6}" class="t${Math.floor(hash(col, r, 6) * 4)}" fill="${c}" fill-opacity=".32"/>`
400 }
401 }
402 }
403 const nameRoom = isNarrow ? W - 30 - indent : W * 0.5
404 const full = (a.depth > 0 ? '↳ ' : '') + a.title
405 let name = full
406 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
407 if (name !== full) name = name.trimEnd() + '…'
408 const nameX = 19 + indent
409 const toolX = nameX + textWidth(name, 6.2) + 8
410 const time = clockText((a.endedAt ?? now) - a.startedAt)
411 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
412 const was = lastStrip.get(a.id)
413 lastStrip.set(a.id, { tool: a.tool, color: c })
414 const isToolChanged = was !== undefined && was.tool !== a.tool
415 const flow = (attr: string) =>
416 was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : ''
417 const tool = isNarrow
418 ? ''
419 : (isToolChanged ? `<text x="${toolX}" y="${y + 12.5}" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
420 `<text x="${toolX}" y="${y + 12.5}" class="sn${isToolChanged ? ' mi' : ''}" style="fill:${c}">${esc(a.tool)}</text>` +
421 `<text x="${W - 9}" y="${y + 12.5}" text-anchor="end" class="sn st">${time}</text>`
422 rows.push(
423 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
424 `<circle cx="${10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
425 `<text x="${nameX}" y="${y + 12.5}" class="sn">${esc(name)}</text>` +
426 tool,
427 )
428 })
429 if (v.hidden.length > 0) {
430 const y = v.shown.length * (STRIP_H + STRIP_GAP)
431 const doneCount = v.hidden.filter(a => a.state === 'done').length
432 rows.push(
433 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
434 `<text x="10" y="${y + 12.5}" class="sn st">+${moreAgents(v.hidden.length, doneCount)}</text>`,
435 )
436 }
437 return `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#26252B}.st{fill-opacity:.6}
438@media (prefers-color-scheme:dark){.sn{fill:#F0EEFC}.st{fill-opacity:.65}}
439.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
440.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
441.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
442@media (prefers-reduced-motion:reduce){.sd,.mi,.mo{animation:none}.mo{opacity:0}}</style>${rows.join('')}`
443}
444
445// ---------- engine glue ----------
446
447const isLive = (b: TurnBar) => b.state === 'running' || b.state === 'needs_input'
448// an interactive Svg (tooltips) is a frame that reloads on every redraw, so a bar that still redraws every
449// second would flicker; it turns interactive only once nothing on it moves any more
450const isSettled = (b: TurnBar) => !isLive(b) && !(b.agents ?? []).some(a => a.state === 'running' || a.state === 'waiting')
451
452// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
453// computed inside update() from the latest list, so concurrent writers do not drop each other
454function placeBar(list: readonly TurnBar[], next: TurnBar): TurnBar[] {
455 const prev = list.find(b => b.id === next.id)
456 const rest = prev ? list.map(b => (b.id === next.id ? next : b)) : [...list, next]
457 while (rest.length > MAX_BARS) {
458 const doneAt = rest.findIndex(b => !isLive(b))
459 rest.splice(doneAt >= 0 ? doneAt : 0, 1)
460 }
461 return rest
462}
463
464// Module state: a reload forgets the live turn and running agents; their bar then stays until the next turn.
465let live: Live | null = null
466let isTicking = false
467const pendingMain = new Set<string>() // main-loop tool_use_ids in flight, to find a permission ask
468const waitingMain = new Set<string>() // of those, the ones held on the person
469const agentHome = new Map<string, string>() // agentId -> bar id
470const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
471const waiting = new Set<string>()
472let foldUntil = 0 // keep ticking until finished strips have folded
473
474// writes the live counters into the bar; the fill only grows
475async function flush($: EngineInterface) {
476 const l = live
477 if (!l) return
478 const frac = fracOf(l)
479 await update($, bars, list =>
480 list.map(b =>
481 b.id !== l.turnId || !isLive(b)
482 ? b
483 : {
484 ...b,
485 phase: l.phase,
486 activity: l.activity,
487 frac: Math.max(b.frac, frac),
488 calls: l.calls.map(c => ({ ...c })),
489 },
490 ),
491 )
492}
493
494// needs_input on and off for the live turn
495async function setWaiting($: EngineInterface, isWaiting: boolean, note: string | null) {
496 const l = live
497 const id = l?.turnId
498 if (!l || !id) return
499 await update($, bars, list =>
500 list.map(b => {
501 if (b.id !== id || b.state !== (isWaiting ? 'running' : 'needs_input')) return b
502 return { ...b, state: isWaiting ? ('needs_input' as const) : ('running' as const), note: isWaiting ? note : null }
503 }),
504 )
505}
506
507function syncAgents(b: TurnBar, now: number): TurnBar {
508 const agents = b.agents ?? []
509 const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
510 return { ...b, agentsDoneAt: isOver ? (b.agentsDoneAt ?? now) : null }
511}
512
513function addRun(b: TurnBar, run: AgentRun, parentId: string | undefined, now: number): TurnBar {
514 // a batch that has finished makes room for the next one
515 const list = b.agentsDoneAt ? [] : [...(b.agents ?? [])]
516 let at = list.length
517 const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
518 if (parentAt >= 0) {
519 at = parentAt + 1
520 while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
521 }
522 list.splice(at, 0, run)
523 return syncAgents({ ...b, agents: list, agentsDoneAt: null }, now)
524}
525
526// changes one agent's strip inside the latest list
527async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
528 const home = agentHome.get(agentId)
529 if (!home) return
530 const now = await $.clock.now()
531 let isFolding = false
532 await update($, bars, list =>
533 list.map(b => {
534 if (b.id !== home || !b.agents?.some(a => a.id === agentId)) return b
535 const next = syncAgents({ ...b, agents: b.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
536 isFolding = !b.agentsDoneAt && next.agentsDoneAt !== null
537 return next
538 }),
539 )
540 if (isFolding) foldUntil = now + FOLD_MS + 1500
541}
542
543export const register: Register = on => {
544 on('session.start', async ($, e, next) => {
545 $.clock.every(1000, async () => {
546 if (isTicking || agentHome.size > 0 || (await $.clock.now()) < foldUntil) await update($, tick, n => n + 1)
547 })
548 await $.command.register({ name: 'turnbar', description: '턴 진행 바 보이기/숨기기' })
549 await $.command.register({ name: 'turnbar-clear', description: '턴 진행 바 지우기' })
550
551 return next(e)
552 })
553
554 // a main-loop turn opens a bar; subagents raise no turn.start
555 on('turn.start', async ($, e, next) => {
556 const now = await $.clock.now()
557 const line = firstLine(e.text)
558 const isShort = !e.text.trim().includes('\n') && [...line].length <= SHORT_TITLE
559 // an empty title draws as a placeholder until the name arrives
560 const title = !line ? LABEL.untitled : isShort ? line : ''
561 live = {
562 turnId: e.turnId,
563 phase: 'request',
564 activity: LABEL.request,
565 thinkChars: 0,
566 stepText: 0,
567 isStepTool: false,
568 answerChars: 0,
569 tools: 0,
570 calls: [],
571 }
572 const bar: TurnBar = {
573 id: e.turnId,
574 title,
575 phase: 'request',
576 activity: LABEL.request,
577 state: 'running',
578 frac: 0,
579 calls: [],
580 note: null,
581 startedAt: now,
582 endedAt: null,
583 tokens: null,
584 agents: [],
585 agentsDoneAt: null,
586 }
587 let kept: TurnBar[] = []
588 await update($, bars, list => {
589 // the agents of a batch that has not folded yet move to the new bar, finished ones too: a background
590 // agent's end starts a turn of its own at once (its notification), and the strip would vanish before
591 // it ever showed it was done. A folded batch stays behind; its running agents still move.
592 const isShown = (x: TurnBar) => !x.agentsDoneAt || now - x.agentsDoneAt <= FOLD_MS
593 const carried = list.flatMap(x => (x.agents ?? []).filter(a => isShown(x) || a.state === 'running' || a.state === 'waiting'))
594 const doneAt = list.find(x => x.agentsDoneAt && isShown(x))?.agentsDoneAt ?? null
595 for (const a of carried) if (a.state === 'running' || a.state === 'waiting') agentHome.set(a.id, e.turnId)
596 kept = placeBar(list, syncAgents({ ...bar, agents: carried, agentsDoneAt: doneAt }, now))
597 return kept
598 })
599 for (const id of [...lastHead.keys()]) if (!kept.some(b => b.id === id)) lastHead.delete(id)
600 isTicking = true
601 if (!title) void nameTurn($, e.turnId, e.text).catch(() => undefined)
602
603 return next(e)
604 })
605
606 // the main loop's stream moves the bar: thinking, tool calls, and a long enough text as the answer
607 on('turn.step', async function* ($, e, next) {
608 const l = live
609 if (e.agentId || !l || l.turnId !== e.turnId) return yield* next(e)
610 l.stepText = 0
611 l.isStepTool = false
612 let since = 0
613 let flushedAt = 0
614 for await (const c of next(e)) {
615 yield c
616 let isNow = false
617 if (c.kind === 'thinking') {
618 l.thinkChars += c.text.length
619 isNow = l.activity !== LABEL.thinking
620 advance(l, 'thinking')
621 l.activity = LABEL.thinking
622 } else if (c.kind === 'text') {
623 l.stepText += c.text.length
624 if (!l.isStepTool && l.stepText >= ANSWER_MIN_CHARS) {
625 isNow = l.activity !== LABEL.answering
626 advance(l, 'answering')
627 if (l.phase === 'answering') l.answerChars = l.stepText
628 l.activity = LABEL.answering
629 }
630 } else if (c.kind === 'tool') {
631 l.isStepTool = true
632 l.tools += 1
633 advance(l, 'working')
634 l.activity = LABEL.working
635 l.calls = [...l.calls, { id: c.id, name: c.name, target: '', frac: fracOf(l), startedAt: null, endedAt: null, isError: false }].slice(-MAX_CALLS)
636 isNow = true
637 } else if (c.kind === 'stop') {
638 isNow = true
639 }
640 since += 1
641 if (!isNow && since < CLOCK_EVERY) continue
642 since = 0
643 const at = await $.clock.now()
644 if (isNow || at - flushedAt >= FLUSH_MS) {
645 flushedAt = at
646 await flush($)
647 }
648 }
649 })
650
651 on('tool.call', async ($, e, next) => {
652 // a subagent's call only names its current tool on its strip
653 if (e.agentId) {
654 const agentId = e.agentId
655 if (!agentHome.has(agentId)) return next(e)
656 await editAgent($, agentId, a => ({ ...a, state: 'running', tool: e.tool }))
657 if (e.tool_use_id) toolUses.set(e.tool_use_id, agentId)
658 const ran = await next(e)
659 if (e.tool_use_id) toolUses.delete(e.tool_use_id)
660 if (waiting.delete(agentId)) await editAgent($, agentId, a => (a.state === 'waiting' ? { ...a, state: 'running' } : a))
661 return ran
662 }
663 const l = live
664 if (!l) return next(e)
665 // a question or a plan to approve waits on the person until the call returns
666 if (e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode') {
667 await setWaiting($, true, e.tool === 'ExitPlanMode' ? LABEL.plan : LABEL.question)
668 const ran = await next(e)
669 await setWaiting($, false, null)
670 return ran
671 }
672 const useId = e.tool_use_id
673 if (useId) pendingMain.add(useId)
674 // the call the stream announced now runs: its target, and when it began
675 const target = targetOf(e as unknown as Record<string, unknown>)
676 const startedAt = await $.clock.now()
677 let call = l.calls.find(c => c.id === useId)
678 if (!call) {
679 call = { id: useId ?? `call-${l.calls.length}`, name: e.tool, target, frac: fracOf(l), startedAt, endedAt: null, isError: false }
680 l.calls = [...l.calls, call].slice(-MAX_CALLS)
681 }
682 call.target = target
683 call.startedAt = startedAt
684 l.activity = LABEL.working
685 await flush($)
686 const ran = await next(e)
687 call.endedAt = await $.clock.now()
688 call.isError = ran.isError === true || ran.deny !== undefined
689 if (useId) {
690 pendingMain.delete(useId)
691 if (waitingMain.delete(useId)) await setWaiting($, false, null)
692 }
693 if (live === l) await flush($)
694 return ran
695 })
696
697 // a call held on a permission prompt turns the bar (or an agent's strip) amber until it goes on
698 on('tool.check', async ($, e, next) => {
699 const verdict = await next(e)
700 const useId = e.tool_use_id
701 if (!useId || verdict.decision !== 'ask') return verdict
702 const agentId = toolUses.get(useId)
703 // the mode often settles an ask by itself in a blink; only a call still held after a moment waits on the person
704 $.clock.after(ASK_DELAY_MS, async () => {
705 if (agentId) {
706 if (toolUses.get(useId) !== agentId) return
707 waiting.add(agentId)
708 await editAgent($, agentId, a => ({ ...a, state: 'waiting', tool: LABEL.approval }))
709 } else if (pendingMain.has(useId)) {
710 waitingMain.add(useId)
711 await setWaiting($, true, LABEL.approval)
712 }
713 })
714
715 return verdict
716 })
717
718 on('agent.spawn', async ($, e, next) => {
719 const started = await next(e)
720 if (!('agentId' in started) || !started.agentId) return started
721 const parentHome = e.parentAgentId ? agentHome.get(e.parentAgentId) : undefined
722 const home = parentHome ?? live?.turnId
723 if (!home) return started
724 const id = started.agentId
725 const now = await $.clock.now()
726 agentHome.set(id, home)
727 const run: AgentRun = {
728 id,
729 title: (e.description || e.subagentType).slice(0, 60),
730 state: 'running',
731 tool: LABEL.agentStarting,
732 startedAt: now,
733 endedAt: null,
734 depth: parentHome ? 1 : 0,
735 }
736 await update($, bars, list => list.map(b => (b.id === home ? addRun(b, run, e.parentAgentId, now) : b)))
737
738 return started
739 })
740
741 on('turn.complete', async ($, e, next) => {
742 const agentId = e.agentId
743 if (agentId) {
744 if (agentHome.has(agentId)) {
745 const now = await $.clock.now()
746 const isFailed = e.reason !== 'answer'
747 const tool = e.reason === 'aborted' ? LABEL.agentStopped : isFailed ? LABEL.agentFailed : LABEL.agentDone
748 await editAgent($, agentId, a => ({ ...a, state: isFailed ? 'error' : 'done', tool, endedAt: now }))
749 agentHome.delete(agentId)
750 waiting.delete(agentId)
751 }
752 return next(e)
753 }
754 const l = live
755 if (!l || l.turnId !== e.turnId) return next(e)
756 live = null
757 isTicking = false
758 pendingMain.clear()
759 waitingMain.clear()
760 const now = await $.clock.now()
761 for (const c of l.calls) if (c.startedAt !== null && c.endedAt === null) c.endedAt = now
762 const u = e.usage
763 const tokens = u
764 ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens }
765 : null
766 const state: TurnState = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'error'
767 const note = e.reason === 'refusal' ? (e.refusal.explanation ?? LABEL.refused) : e.reason === 'error' ? LABEL.apiError : null
768 await update($, bars, list =>
769 list.map(b =>
770 b.id !== e.turnId
771 ? b
772 : {
773 ...b,
774 state,
775 note,
776 activity: LABEL[state],
777 frac: state === 'done' ? 1 : b.frac,
778 phase: state === 'done' ? 'answering' : b.phase,
779 endedAt: now,
780 tokens,
781 calls: l.calls.map(c => ({ ...c })),
782 },
783 ),
784 )
785
786 return next(e)
787 })
788
789 on('command.run', { command: 'turnbar' }, async $ => {
790 if ((await read($, bars)).length === 0) return { text: '아직 바가 없습니다. 다음 요청부터 나타납니다.' }
791 const open = await read($, isOpen)
792 await update($, isOpen, () => !open)
793
794 return { text: open ? '진행 바를 숨겼습니다.' : '진행 바를 다시 표시합니다.' }
795 })
796
797 on('command.run', { command: 'turnbar-clear' }, async $ => {
798 lastHead.clear()
799 lastStrip.clear()
800 await update($, bars, () => [])
801
802 return { text: '진행 바를 지웠습니다.' }
803 })
804
805 // always drawn, so the person sees the mod is loaded: plain text, since a Button's chip is taller than the footer
806 // row and gets cut. While the bar shows it takes the colour of the turn's state, else it is dim.
807 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
808 const last = (await read($, bars)).at(-1)
809 const open = await read($, isOpen)
810 const { Box, Text } = $.ui.resolve(e)
811 // other mods add their labels to modes beneath us; keep them
812 const below = await next(e)
813 const isActive = last !== undefined && open
814
815 return (
816 <Box flexDirection="row" alignItems="center" gap={1}>
817 <Text key="turnbar-label" bold={isActive} color={isActive ? STATE_COLOR[last.state] : undefined} dimColor={!isActive}>
818 {LABEL.button}
819 </Text>
820 {below}
821 </Box>
822 )
823 })
824
825 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
826 const list = await read($, bars)
827 if (list.length === 0 || e.props.hasSurvey || !(await read($, isOpen))) return next(e)
828 const t = $.ui.resolve(e)
829 const { Box, Button, Text } = t
830 // the terminal's table names an Svg that draws nothing, so the surface decides, not the table
831 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : null
832 const total = Math.max(320, (e.props.bodyColumns || 100) * 8)
833 // a fixed title column, so the track does not move when a title arrives; every bar is pinned to the right edge
834 // (fixed-width clock, close button) and the rows line up.
835 // Desktop reports ~8 CSS px per column; glyph, gaps, clock and the close button take ~144 px.
836 const titleWidth = Math.round(Math.max(120, Math.min(220, total * 0.22)))
837 const trackW = Math.max(120, Math.min(1400, total - titleWidth - 144))
838 await read($, tick)
839 const now = await $.clock.now()
840 // the terminal: every part has a width counted in cells, so nothing is squeezed out of a narrow row.
841 // The engine keeps a few cells on the right for its own collapse mark.
842 const cols = Math.max(30, (e.props.bodyColumns || 80) - 4)
843 const labelCells = Math.max(...list.map(b => cells(`${pillName(b)} ${pillCount(b)}`)), 9)
844 // glyph, percent, label, clock, the close button and the gaps between the seven parts
845 const fixedCells = 1 + 4 + labelCells + 4 + 1 + 6
846 const titleCells = cols - fixedCells >= 30 ? Math.min(20, Math.max(...list.map(b => cells(b.title || '…')))) : 0
847 const barCells = Math.max(8, Math.min(40, cols - fixedCells - titleCells - (titleCells > 0 ? 1 : 0)))
848 // a hairline between bars, so each bar and its agent strips read as one group
849 const divider = `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="1"><rect width="${total}" height="1" fill="#808080" fill-opacity=".22"/></svg>`
850
851 return (
852 <Box flexDirection="column" gap={1}>
853 {list.flatMap((b, i) => {
854 const v = visibleAgents(b, now)
855 const stripsH = v ? 5 + stripsHeight(v.shown.length + (v.hidden.length > 0 ? 1 : 0)) : 0
856 const source = v
857 ? `<svg xmlns="http://www.w3.org/2000/svg" width="${trackW}" height="${TRACK_H + stripsH}">${trackSvg(b, trackW, now)}<g transform="translate(0 ${TRACK_H + 5})">${stripsSvg(v, trackW, now)}</g></svg>`
858 : trackSvg(b, trackW, now)
859 const line = i > 0 && Svg ? [<Svg key={`div-${b.id}`} source={divider} alt="" width={total} height={1} />] : []
860 const color = STATE_COLOR[b.state]
861 const time = clockText((b.endedAt ?? now) - b.startedAt)
862 const count = pillCount(b)
863 const agentsAlt = v ? `; ${LABEL.agentsAlt}: ${(b.agents ?? []).map(a => `${a.title} ${a.tool}`).join(', ')}` : ''
864 const alt = `${b.title}: ${pillName(b)}${count ? `, ${count}` : ''}, ${time}${agentsAlt}`
865 const frac = b.state === 'done' ? 1 : Math.min(1, Math.max(0, b.frac))
866 const pct = Math.round(frac * 100)
867 const filled = Math.round(frac * barCells)
868
869 if (!Svg) {
870 // subagents as a tree under the bar: name, what it does now, how long; finished ones dim
871 const TOOL_CELLS = 12
872 const tree = v
873 ? [
874 ...v.shown.map(a => {
875 const indent = a.depth > 0 ? ' ' : ' '
876 const nameCells = Math.max(8, cols - cells(indent) - 2 - 1 - TOOL_CELLS - 1 - 5)
877 const isOver = a.state === 'done'
878 return (
879 <Text key={`agent-${a.id}`}>
880 <Text dimColor>{`${indent}└ `}</Text>
881 <Text dimColor={isOver}>{fitCells(a.title, nameCells)}</Text>
882 <Text color={AGENT_COLOR[a.state]}>{` ${fitCells(a.tool, TOOL_CELLS)}`}</Text>
883 <Text dimColor>{` ${clockText((a.endedAt ?? now) - a.startedAt).padStart(5, ' ')}`}</Text>
884 </Text>
885 )
886 }),
887 ...(v.hidden.length > 0
888 ? [
889 <Text key={`agents-more-${b.id}`} dimColor>
890 {` └ +${moreAgents(v.hidden.length, v.hidden.filter(a => a.state === 'done').length)}`}
891 </Text>,
892 ]
893 : []),
894 ]
895 : []
896 // a dithered block bar: dark cells for what is done, light ones for the rest, the share beside it
897 return [
898 <Box key={`group-${b.id}`} flexDirection="column">
899 <Box key={`bar-${b.id}`} flexDirection="row" gap={1}>
900 <Text color={color}>{STATE_GLYPH[b.state]}</Text>
901 {titleCells > 0 ? <Text dimColor={!b.title}>{fitCells(b.title || '…', titleCells)}</Text> : null}
902 <Text>
903 <Text color={color}>{'▓'.repeat(filled)}</Text>
904 <Text dimColor>{'░'.repeat(barCells - filled)}</Text>
905 </Text>
906 <Text>{`${String(pct).padStart(2, '0')}%`.padStart(4, ' ')}</Text>
907 <Text color={color}>{fitCells(`${pillName(b)} ${pillCount(b)}`, labelCells)}</Text>
908 <Text dimColor>{time.padStart(4, ' ')}</Text>
909 <Button key={`close-${b.id}`} plain dimColor label="✕" onPress={() => update($, bars, all => all.filter(x => x.id !== b.id))} />
910 </Box>
911 {tree}
912 </Box>,
913 ]
914 }
915
916 return [
917 ...line,
918 <Box key={`bar-${b.id}`} flexDirection="row" alignItems={v ? 'flex-start' : 'center'} gap={1}>
919 <Text color={color}>{STATE_GLYPH[b.state]}</Text>
920 {b.title ? <Text wrap="truncate">{b.title}</Text> : <Text dimColor>…</Text>}
921 <Box flexGrow={1} />
922 <Svg source={source} alt={alt} width={trackW} height={TRACK_H + stripsH} isInteractive={isSettled(b) || undefined} />
923 <Svg source={clockSvg(time)} alt={time} width={CLOCK_W} height={TRACK_H} />
924 <Button key={`close-${b.id}`} plain dimColor label="✕" onPress={() => update($, bars, all => all.filter(x => x.id !== b.id))} />
925 </Box>,
926 ]
927 })}
928 </Box>
929 )
930 })
931}
932types/index.d.ts 53 lines1export type TurnState = 'running' | 'needs_input' | 'error' | 'stopped' | 'done'
2// the furthest part of the turn reached; drives the fill, never goes back within a turn
3export type Phase = 'request' | 'thinking' | 'working' | 'answering'
4// one subagent shown as a state strip under the bar; depth 1 sits under its parent agent
5export type AgentRun = {
6 id: string
7 title: string
8 state: 'running' | 'waiting' | 'done' | 'error'
9 tool: string
10 startedAt: number
11 endedAt: number | null
12 depth: number
13}
14// one tool call of the main loop: a tick on the track with its tooltip
15export type ToolRun = {
16 id: string // tool_use_id
17 name: string
18 target: string // the file, command or query it works on; '' until the call runs
19 frac: number // where on the track its tick sits
20 startedAt: number | null // when it began to run; null while the model still writes it
21 endedAt: number | null
22 isError: boolean
23}
24// what the turn cost, as turn.complete reports it
25export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
26export type TurnBar = {
27 id: string // the turn's turnId
28 title: string // a short name for the request; '' while it is being named
29 phase: Phase
30 activity: string // what happens right now: the pill's label
31 state: TurnState
32 frac: number // 0..1, the fill
33 calls: ToolRun[]
34 note: string | null
35 startedAt: number
36 endedAt: number | null
37 tokens: TurnTokens | null
38 agents?: AgentRun[]
39 // when the current batch of agents all finished; their strips fold a few seconds later
40 agentsDoneAt?: number | null
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'turn-progress': {
46 bars: TurnBar[]
47 isOpen: boolean
48 // bumped every second while a turn or an agent runs, so elapsed times redraw
49 tick: number
50 }
51 }
52}
53