SLOPSHOPPER

turn-progress

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

newbandspinnerguardcommandmodel
v0.1.8MITupdated 2026-10-06Dan-Millie-Front/turn-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-progress
› 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 › /turnbar ⎿ turn-progress: 아직 바가 없습니다. 다음 요청부터 나타납니다. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts Progress ⟨Claude Code's own drawing⟩
README

turn-progress

프롬프트 위에 지금 턴의 진행 상태를 바 한 줄로 보여주는 Claude Code 모드입니다. 턴은 요청 → 생각 → 작업 → 답변 순서로 진행됩니다.

  • 구간 세 개(생각 · 작업 · 답변)로 나뉜 픽셀 바, 도구 호출마다 틱 하나
  • 왼쪽에 작업 제목을 표시합니다. 긴 요청은 Haiku가 2~5단어로 요약합니다(턴당 약 100토큰).
  • pill에 지금 단계와 위치(작업 중 2/3)를 표시하고, 오른쪽에 경과 시간을 표시
  • 서브에이전트는 바 아래 상태 스트립으로 표시
  • 턴이 끝난 뒤 틱에 마우스를 올리면 Edit · register.tsx · 0.8s 같은 툴팁이 나옵니다.
  • 질문·승인 대기는 amber, 오류는 red, 중단은 grey, 완료는 green
  • 모델 도구나 프롬프트 규칙을 쓰지 않아, 토큰이 드는 곳은 제목 요약뿐

설치 (마켓플레이스)

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)에서 가져와 고쳤습니다.

Source 2 files
hooks/register.tsx 932 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
932
types/index.d.ts 53 lines
1export 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