teckProgress: a segmented progress bar above the prompt plus a live crew panel (/crew) for subagents: model, steps, context, estimated cost and time.

Part of laynem/claudemods, by Layne. Installed it appears as teckprogress; the panel opens with /crew.
A Claude Code mod with two parts:
crew N button opens the panel and ✕ dismisses the bar./crew (/crew open only opens it). It lists running, finished and planned subagents with model, effort, self-reported steps, context fill, estimated cost and elapsed time. A compact mode shrinks it to a row of robots.It is a clean-room rewrite inspired by savvy-progress from johnnyvizz/claude-kit (MIT). It is compatible with the savvy-flow skill out of the box, since the worker prefix defaults to savvy-.
Needs Claude Code 2.1.287 or later. It installs from the claudemods marketplace:
claude plugin marketplace add laynem/claudemods
claude plugin install teckprogress@claudemods
To try a local checkout for one session instead: claude --plugin-dir ./plugins/teckprogress.
This mod is built on Claude Code's early-access function hooks (hooks/register.tsx), and that API can change between releases.
mcp__teckprogress__progress: the orchestrator reports a plan (title, tasks), the phase, the accepted count (done), and finished: true at the end. Each task title should match the Agent description it is launched with, so the panel can pair runs with tasks. Calls from subagents are ignored.mcp__teckprogress__step: a worker reports its own done / total / note. Tracked workers get a one-line request appended to their brief. A worker that never reports shows its context fill as a grey bar instead.If you are not using savvy-flow, tell your orchestrating prompt (or a CLAUDE.md) to call progress after planning and after each accepted task.
Set these in /config under teckprogress, or in ~/.claude/settings.json:
{ "pluginConfigs": { "teckprogress": { "options": { "autoOpen": "any", "workerPrefix": "savvy-" } } } }
autoOpen: workers (the default) opens the panel when a tracked worker starts or a plan is reported. any also opens it once for any other subagent. never leaves it to /crew.workerPrefix: agent types starting with this prefix are tracked workers. The rest of the type name picks the tier, so savvy-careful becomes the careful tier.Everything you are likely to change sits in the block at the top of hooks/register.tsx:
PLUGIN must match name in .claude-plugin/plugin.json, because tool names are derived from it. If you rename the plugin, change both, the literal plugin name in the four atom(...) calls below it, and the state key in types/index.d.ts.COMMAND is the slash command name.ACCENT, DONE and FAIL are the band colors.TIERS holds each tier's label, color, and the model shown for planned tasks. Add or remove tiers freely; the progress tool's tier enum follows this table.S holds every string the UI draws, so you can translate or reword it in place.PRICES is the per-model estimate table, in USD per million tokens. Costs are estimates, not a bill, and cache writes are priced at the 1-hour rate.BOT is the 16×16 mascot sprite. Each letter is a color: a (antenna) and c (chest light) take the tier color and animate, and l is the legs.MIT, © 2026 Layne.
hooks/register.tsx 928 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Flow, PanelState, Phase, PlannedTask, Run } from '../types'
5
6// =============================================================================
7// CUSTOMIZE HERE
8// =============================================================================
9
10// Must equal "name" in .claude-plugin/plugin.json: the engine names this plugin's
11// tools mcp__<plugin>__<tool>. The state atoms below repeat it as a literal,
12// because the validator only accepts string literals there.
13const PLUGIN = 'teckprogress'
14
15// The slash command that toggles the panel, and the panel's id and title.
16const COMMAND = 'crew'
17const PANE_ID = 'crew-panel'
18
19// Colors used by the band. Tier colors are in TIERS below.
20const ACCENT = '#3E8ED0'
21const DONE = '#3FA66B'
22const FAIL = '#D0453F'
23
24// Worker tiers. The key is what follows the worker prefix in the agent type
25// (savvy-careful -> careful). `model` is only shown for planned tasks that have no
26// run yet; a real run shows the model the engine reports.
27type Tier = { label: string; color: string; model: string }
28const TIERS: Record<string, Tier> = {
29 fable: { label: 'fable', color: '#8B6FE0', model: 'Fable · high' },
30 heavy: { label: 'heavy', color: '#D9534F', model: 'Opus · xhigh' },
31 careful: { label: 'careful', color: '#D39A16', model: 'Opus · high' },
32 medium: { label: 'medium', color: '#3B82D6', model: 'Opus · medium' },
33 light: { label: 'light', color: '#2FA36B', model: 'Opus · low' },
34}
35const OTHER: Tier = { label: 'other', color: '#8A8F98', model: '' }
36
37// Every word the band and panel draw. Translate in place.
38const S = {
39 pane: 'Crew',
40 cost: 'Cost',
41 tokens: 'Tokens',
42 time: 'Time',
43 compact: 'Compact',
44 expand: 'Expand',
45 running: 'Running',
46 finished: 'Finished',
47 planned: 'Planned',
48 empty: 'No subagents yet. They show up here as soon as one starts.',
49 round: 'round',
50 failed: 'failed',
51 after: 'after',
52 ctx: 'ctx',
53 opened: 'Crew panel opened.',
54 closed: 'Crew panel closed.',
55 phase: { plan: 'Planning', design: 'Designing', delegate: 'Tasks', review: 'Review', close: 'Done' } as Record<Phase, string>,
56 busy: 'working',
57}
58
59// Estimated USD per million tokens: input, output, cache read, cache write.
60// First match wins, so specific releases go above their family. Cache writes use
61// the 1-hour rate (2x input). This is an estimate, not a bill: update as prices move.
62type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
63const PRICES: [RegExp, Price][] = [
64 [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
65 [/fable|mythos/, [10, 50, 1, 20]],
66 [/opus-5-5/, [4, 20, 0.2, 8]],
67 [/opus/, [5, 25, 0.5, 10]],
68 [/sonnet-5/, [2, 10, 0.1, 4]],
69 [/sonnet/, [3, 15, 0.3, 6]],
70 [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
71 [/haiku/, [1, 5, 0.1, 2]],
72]
73const DEFAULT_PRICE: Price = [4, 20, 0.2, 8]
74
75// =============================================================================
76
77const flowAtom = atom({ plugin: 'teckprogress', key: 'flow' } as const, null)
78const runsAtom = atom({ plugin: 'teckprogress', key: 'runs' } as const, [])
79const panelAtom = atom({ plugin: 'teckprogress', key: 'panel' } as const, { compact: false, hideFinished: false, autoOpenedFor: '' })
80const nowAtom = atom({ plugin: 'teckprogress', key: 'now' } as const, 0)
81
82const PROGRESS_TOOL = `mcp__${PLUGIN}__progress`
83const STEP_TOOL = `mcp__${PLUGIN}__step`
84const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
85
86// Tool schemas are only advice to the model; these bound what a call can make us draw.
87const MAX_TASKS = 200
88const MAX_DEPS = 20
89const MAX_TITLE = 120
90const MAX_RUNS = 200
91const MAX_STEPS = 1000
92const MAX_NOTE = 60
93
94// Appended to a worker's brief when it doesn't already mention the step tool.
95// A line in the brief gets followed far more reliably than one in a system prompt.
96const STEP_BRIEF = `
97
98Progress reporting: before any other work, call \`${STEP_TOOL}\` with \`total\` (your plan as 3-8 steps) and \`done: 0\`. Call it again with the new \`done\` and a few-word \`note\` each time a step finishes. It only draws a progress bar; it does not change your task.`
99
100let workerPrefix = 'savvy-'
101
102// --- small helpers -------------------------------------------------------------
103
104const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
105const int = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : fallback)
106const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, max - 1) + '…' : s)
107const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
108const esc = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
109
110// Strips a plugin namespace: "savvy-flow:savvy-careful" -> "savvy-careful".
111const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
112const isWorker = (type: string): boolean => !!workerPrefix && bareType(type).startsWith(workerPrefix)
113const tierKey = (type: string): string => {
114 if (!isWorker(type)) return 'other'
115 const k = bareType(type).slice(workerPrefix.length).toLowerCase()
116 return Object.hasOwn(TIERS, k) ? k : 'other'
117}
118const tierOf = (key: string): Tier => TIERS[key] ?? OTHER
119// A planned task names its tier directly ("careful") or as an agent type ("savvy-careful").
120const plannedTier = (v: string): string => {
121 const k = v.trim().toLowerCase().replace(/^[^:]*:/, '').replace(workerPrefix.toLowerCase(), '')
122 return Object.hasOwn(TIERS, k) ? k : 'other'
123}
124
125const fmtTokens = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
126const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
127const fmtTime = (ms: number): string => {
128 const s = Math.max(0, Math.round(ms / 1000))
129 const h = Math.floor(s / 3600)
130 const m = Math.floor((s % 3600) / 60)
131 const ss = String(s % 60).padStart(2, '0')
132 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
133}
134
135const modelName = (id: string): string => {
136 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id ?? '')
137 if (!m) return (id ?? '').replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
138 const [, fam = '', major = '', minor] = m
139 return `${fam[0]?.toUpperCase()}${fam.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
140}
141const runModel = (r: Run): string => (r.effort ? `${modelName(r.model)} · ${r.effort}` : modelName(r.model))
142
143// Haiku 4.5 and Claude 3 have 200K windows; everything newer, 1M.
144const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
145
146type Usage = {
147 input_tokens?: number
148 output_tokens?: number
149 cache_read_input_tokens?: number
150 cache_creation_input_tokens?: number
151 model?: string
152}
153const u = (x?: number): number => x || 0
154const promptOf = (us: Usage): number => u(us.input_tokens) + u(us.cache_read_input_tokens) + u(us.cache_creation_input_tokens)
155const totalOf = (us: Usage): number => promptOf(us) + u(us.output_tokens)
156
157const priceOf = (model: string, prompt: number): Price => {
158 const id = (model ?? '').toLowerCase()
159 // Haiku 5.5 charges more for a single request whose prompt is over 100K tokens.
160 if (prompt > 100_000 && /haiku-5-5/.test(id)) return [0.5, 2.5, 0.05, 1]
161 return PRICES.find(([re]) => re.test(id))?.[1] ?? DEFAULT_PRICE
162}
163const costOf = (model: string, us: Usage, perRequest: boolean): number => {
164 const [i, o, r, w] = priceOf(model, perRequest ? promptOf(us) : 0)
165 return (u(us.input_tokens) * i + u(us.output_tokens) * o + u(us.cache_read_input_tokens) * r + u(us.cache_creation_input_tokens) * w) / 1e6
166}
167
168// Rough advance of UI sans text in em, to size slots without measuring.
169const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
170const charEm = (ch: string): number => (WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
171// `bold` widens the estimate for semibold text, which runs about 10% wider.
172const textW = (s: string, size: number, bold = false): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0) * (bold ? 1.1 : 1)
173const fit = (s: string, size: number, max: number, bold = false): string => {
174 if (textW(s, size, bold) <= max) return s
175 let out = ''
176 for (const ch of s) {
177 if (textW(out + ch + '…', size, bold) > max) break
178 out += ch
179 }
180 return out + '…'
181}
182
183// --- flow state ----------------------------------------------------------------
184
185type ProgressInput = {
186 title?: string
187 total?: number
188 done?: number
189 phase?: Phase
190 finished?: boolean
191 tasks?: { title?: string; tier?: string; after?: number[] }[]
192}
193
194const emptyFlow = (): Flow => ({ title: 'Working', total: 0, done: 0, running: 0, phase: 'plan', finished: false, tasks: [] })
195
196const startsNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
197 !prev || prev.finished || (input.title !== undefined && clip(input.title.trim(), MAX_TITLE) !== prev.title)
198
199const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
200 Array.isArray(tasks)
201 ? tasks
202 .filter(t => typeof t?.title === 'string' && t.title.trim())
203 .slice(0, MAX_TASKS)
204 .map(t => ({
205 title: clip((t.title ?? '').trim(), MAX_TITLE),
206 tier: plannedTier(String(t.tier ?? '')),
207 after: (Array.isArray(t.after) ? t.after : []).filter(n => Number.isInteger(n) && n > 0 && n <= MAX_TASKS).slice(0, MAX_DEPS),
208 }))
209 : undefined
210
211// Fields left out keep their value; a new title starts a fresh flow with no carry-over.
212const mergeFlow = (prev: Flow | null, input: ProgressInput): Flow => {
213 const base = startsNewFlow(prev, input) || !prev ? emptyFlow() : prev
214 const tasks = cleanTasks(input.tasks) ?? base.tasks
215 const total = clamp(int(input.total, input.tasks ? tasks.length : base.total), 0, MAX_TASKS)
216 const done = clamp(int(input.done, base.done), 0, total || MAX_TASKS)
217 const finished = input.finished === true
218 const phase = finished ? 'close' : input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
219 return { ...base, title: clip(input.title?.trim() || base.title, MAX_TITLE), total, done, phase, finished, tasks }
220}
221
222const flowLabel = (f: Flow): string => {
223 if (f.finished) return S.phase.close
224 if (f.phase === 'plan' || f.phase === 'design') return S.phase[f.phase]
225 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${S.busy}`
226 return `${S.phase[f.phase]} ${count}`
227}
228const flowRatio = (f: Flow): number => (f.finished ? 1 : f.total ? f.done / f.total : 0)
229
230type Planned = PlannedTask & { n: number }
231// Planned tasks that no run has picked up yet, matched by description.
232const pendingTasks = (f: Flow | null, runs: Run[]): Planned[] => {
233 if (!f || f.finished) return []
234 const started = new Set(runs.map(r => norm(r.description)))
235 return f.tasks.map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
236}
237
238const totals = (runs: Run[], at: number) => {
239 if (!runs.length) return { cost: 0, tokens: 0, time: 0 }
240 const start = Math.min(...runs.map(r => r.startedAt))
241 const end = Math.max(...runs.map(r => r.endedAt ?? Math.max(at, r.startedAt)))
242 return { cost: runs.reduce((s, r) => s + r.cost, 0), tokens: runs.reduce((s, r) => s + r.tokens, 0), time: end - start }
243}
244const elapsed = (r: Run, at: number): number => (r.endedAt ?? Math.max(at, r.startedAt)) - r.startedAt
245const ctxPct = (r: Run): number => (r.window ? clamp(Math.round((r.context / r.window) * 100), 0, 100) : 0)
246// Reported steps when there are any; a finished run is full; otherwise unknown.
247const stepRatio = (r: Run): number | null =>
248 r.status === 'done' ? 1 : r.stepTotal ? clamp((r.stepDone ?? 0) / r.stepTotal, 0, 1) : null
249
250// --- the mascot: a 16x16 pixel robot, tinted per tier --------------------------
251//
252// Letters map to colors; `a` (antenna tip) and `c` (chest light) are animated.
253// Legs are split left/right so they can step in turn.
254
255const BOT = [
256 '.......aa.......',
257 '.......ss.......',
258 '...hhhhhhhhhh...',
259 '..hhhhhhhhhhhh..',
260 '..hvvvvvvvvvvh..',
261 '..hveevvvveevh..',
262 '..hvvvvvvvvvvh..',
263 '..hhhhhhhhhhhh..',
264 '.....hhhhhh.....',
265 '..bbbbbbbbbbbb..',
266 '.mbbbbbbbbbbbbm.',
267 '.mbbbbccccbbbbm.',
268 '.mbbbbccccbbbbm.',
269 '..bbbbbbbbbbbb..',
270 '...ll......ll...',
271 '..lll......lll..',
272]
273const BOT_PX = 16
274
275const BOT_CSS = `<style>
276.bot .h{fill:#C9CDD6}.bot .b{fill:#AEB4C0}.bot .m,.bot .s{fill:#8E95A3}.bot .l{fill:#6F7685}.bot .v{fill:#20242D}
277@media (prefers-color-scheme: dark){.bot .h{fill:#9DA3AE}.bot .b{fill:#868D99}.bot .m,.bot .s{fill:#6C7380}.bot .l{fill:#585E69}.bot .v{fill:#14171C}}
278.go .la{animation:step .5s steps(1) infinite}.go .lb{animation:step .5s steps(1) infinite -.25s}
279.go .bd{animation:bob .5s steps(1) infinite -.125s}
280.go .ant{animation:blink 1s steps(1) infinite}.go .led{animation:glow 1s ease-in-out infinite}
281@keyframes step{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
282@keyframes blink{50%{opacity:.2}}@keyframes glow{50%{opacity:.45}}
283@media (prefers-reduced-motion: reduce){.go *{animation:none!important}}
284</style>`
285
286const bot = (x: number, y: number, tint: string, opts: { dim?: boolean; walking?: boolean; scale?: number } = {}): string => {
287 const scale = opts.scale ?? 1.75
288 const body: string[] = []
289 const ant: string[] = []
290 const led: string[] = []
291 const la: string[] = []
292 const lb: string[] = []
293 BOT.forEach((row, py) =>
294 [...row].forEach((ch, px) => {
295 if (ch === '.') return
296 const tinted = ch === 'a' || ch === 'e' || ch === 'c'
297 const rect = `<rect x="${px}" y="${py}" width="1" height="1"${tinted ? ` fill="${tint}"` : ` class="${ch}"`}/>`
298 if (ch === 'a') ant.push(rect)
299 else if (ch === 'c') led.push(rect)
300 else if (ch === 'l') (px < 8 ? la : lb).push(rect)
301 else body.push(rect)
302 }),
303 )
304 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${opts.dim ? 0.45 : 1}" shape-rendering="crispEdges">
305<g class="bot${opts.walking ? ' go' : ''}"><g class="bd">${body.join('')}<g class="ant">${ant.join('')}</g><g class="led">${led.join('')}</g></g><g class="la">${la.join('')}</g><g class="lb">${lb.join('')}</g></g></g>`
306}
307
308// --- the band above the prompt -------------------------------------------------
309
310const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif"
311const BAND_H = 24
312
313const BAND_CSS = `<style>
314.tx{fill:#1F2328}.mu{fill:#7A808A}.tr{fill:#E3E5E8}
315@media (prefers-color-scheme: dark){.tx{fill:#E8EAED}.mu{fill:#959BA5}.tr{fill:#30343A}}
316.wip{animation:wip 1.4s ease-in-out infinite}@keyframes wip{50%{opacity:.25}}
317@media (prefers-reduced-motion: reduce){.wip{animation:none}}
318</style>`
319
320// One SVG per row: the desktop wraps sibling elements, so everything but the
321// buttons lives in a single drawing. Up to 40 tasks get one cell each; beyond
322// that, or with no total, the bar is a single track.
323const bandSvg = (f: Flow, W: number, working: boolean): string => {
324 const color = f.finished ? DONE : ACCENT
325 const title = fit(f.title, 13, Math.max(60, W * 0.35), true)
326 const label = flowLabel(f)
327 const pct = `${Math.round(flowRatio(f) * 100)}%`
328 const botW = Math.round(BOT_PX * 1.25)
329 const pctW = 38
330 // Right side, from the bar out: gap, label, gap, percent, bot.
331 const right = Math.round(12 + textW(label, 12, true) + 10 + pctW + botW + 4)
332 const barX = Math.round(16 + textW(title, 13, true) + 14)
333 const barW = Math.max(60, W - barX - right)
334 const barH = 10
335 const by = (BAND_H - barH) / 2
336 const cells: string[] = []
337
338 if (f.total > 0 && f.total <= 40) {
339 const gap = f.total > 20 ? 2 : 3
340 const cw = (barW - gap * (f.total - 1)) / f.total
341 for (let i = 0; i < f.total; i++) {
342 const x = (i * (cw + gap)).toFixed(2)
343 const w = cw.toFixed(2)
344 if (f.finished || i < f.done) cells.push(`<rect x="${x}" width="${w}" height="${barH}" rx="2.5" fill="${color}"/>`)
345 else if (i < f.done + f.running) cells.push(`<rect class="tr" x="${x}" width="${w}" height="${barH}" rx="2.5"/><rect class="wip" x="${x}" width="${w}" height="${barH}" rx="2.5" fill="${color}" opacity=".55"/>`)
346 else cells.push(`<rect class="tr" x="${x}" width="${w}" height="${barH}" rx="2.5"/>`)
347 }
348 } else {
349 const fill = Math.round(barW * flowRatio(f))
350 cells.push(`<rect class="tr" width="${barW}" height="${barH}" rx="5"/>`)
351 if (fill > 0) cells.push(`<rect width="${fill}" height="${barH}" rx="5" fill="${color}"/>`)
352 if (!f.finished && f.running > 0 && f.total === 0) cells.push(`<rect class="wip" width="${barW}" height="${barH}" rx="5" fill="${color}" opacity=".25"/>`)
353 }
354
355 const labelX = barX + barW + 12
356 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAND_H}" viewBox="0 0 ${W} ${BAND_H}">${BAND_CSS}${BOT_CSS}
357<circle cx="5" cy="${BAND_H / 2}" r="4" fill="${color}"/>
358<text class="tx" x="16" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="600">${esc(title)}</text>
359<g transform="translate(${barX},${by})">${cells.join('')}</g>
360<text x="${labelX}" y="${BAND_H / 2 + 4}" font-family="${FONT}" font-size="12" font-weight="600" fill="${color}">${esc(label)}</text>
361<text class="mu" x="${W - botW - 4}" y="${BAND_H / 2 + 4}" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${pct}</text>
362${bot(W - botW, 2, color, { walking: working, scale: 1.25 })}
363</svg>`
364}
365
366const textBar = (ratio: number, width: number): string => {
367 const n = Math.round(width * clamp(ratio, 0, 1))
368 return '█'.repeat(n) + '░'.repeat(Math.max(0, width - n))
369}
370
371// --- the crew panel (desktop) --------------------------------------------------
372
373const PANE_CSS = `<style>
374.tx{fill:#1F2328}.mu{fill:#6E747E}.fa{fill:#9AA0A9}.tr{fill:#E6E8EB}.rule{stroke:#E6E8EB}.card{fill:#F3F4F6}
375@media (prefers-color-scheme: dark){.tx{fill:#E8EAED}.mu{fill:#A0A6AF}.fa{fill:#767C85}.tr{fill:#2C3036}.rule{stroke:#2C3036}.card{fill:#24272C}}
376.live{animation:live 1.6s ease-in-out infinite}@keyframes live{50%{opacity:.3}}
377@media (prefers-reduced-motion: reduce){.live{animation:none}}
378</style>`
379
380const paneSvg = (W: number, H: number, body: string): string =>
381 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${BOT_CSS}${body}</svg>`
382
383const mark = (x: number, y: number, status: Run['status'] | 'planned', color: string): string => {
384 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
385 if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="${DONE}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
386 if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${FAIL}" stroke-width="1.8" stroke-linecap="round"/>`
387 return `<circle cx="${x}" cy="${y}" r="4.5" fill="none" stroke="#9AA0A9" stroke-width="1.4" stroke-dasharray="2 2"/>`
388}
389
390const HEADER_H = (title: string): number => (title ? 74 : 46)
391const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
392 const gap = 8
393 const cw = (W - gap * 2) / 3
394 const top = title ? 28 : 0
395 const card = (i: number, k: string, v: string) => {
396 const x = i * (cw + gap)
397 return `<rect class="card" x="${x}" y="${top}" width="${cw}" height="42" rx="6"/>
398<text class="mu" x="${x + 10}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
399<text class="tx" x="${x + 10}" y="${top + 34}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
400 }
401 return paneSvg(
402 W,
403 HEADER_H(title),
404 `${title ? `<text class="tx" x="0" y="16" font-family="${FONT}" font-size="14" font-weight="600">${esc(fit(title, 14, W, true))}</text>` : ''}
405${card(0, S.cost, '≈' + fmtCost(t.cost))}${card(1, S.tokens, fmtTokens(t.tokens))}${card(2, S.time, fmtTime(t.time))}`,
406 )
407}
408
409const RUN_H = 64
410const runSvg = (W: number, r: Run, at: number): string => {
411 const key = tierKey(r.type)
412 const tier = tierOf(key)
413 const tx = 40
414 const tw = W - tx - 20
415 const ctx = ctxPct(r)
416 const prog = stepRatio(r)
417 const meta = [runModel(r)]
418 if (r.round > 1) meta.push(`${S.round} ${r.round}`)
419 if (r.status === 'failed') meta.push(S.failed)
420 const stats = `${S.ctx} ${ctx}% ≈${fmtCost(r.cost)} ${fmtTime(elapsed(r, at))}`
421 const steps = r.stepTotal ? `${r.stepDone ?? 0}/${r.stepTotal}${r.stepNote ? ' · ' + r.stepNote : ''}` : ''
422 const stepsW = tw - textW(stats, 11) - 12
423 // With no reported steps, the bar shows context fill in grey instead.
424 const fillW = Math.round(tw * (prog ?? ctx / 100))
425 return paneSvg(
426 W,
427 RUN_H,
428 `${bot(2, 12, tier.color, { walking: r.status === 'running' })}
429<text class="tx" x="${tx}" y="17" font-family="${FONT}" font-size="13" font-weight="600">${esc(fit(r.description || r.type, 13, tw, true))}</text>
430<text x="${tx}" y="33" font-family="${FONT}" font-size="11"><tspan fill="${tier.color}">${esc(key === 'other' ? bareType(r.type) : tier.label)}</tspan><tspan class="mu"> ${esc(meta.join(' · '))}</tspan></text>
431${steps && stepsW > 30 ? `<text class="tx" x="${tx}" y="48" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${esc(fit(steps, 11, stepsW))}</text>` : ''}
432<text class="mu" x="${tx + tw}" y="48" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
433<rect class="tr" x="${tx}" y="54" width="${tw}" height="4" rx="2"/>
434<rect${prog === null ? ' class="fa"' : ` fill="${r.status === 'failed' ? FAIL : tier.color}"`} x="${tx}" y="54" width="${fillW}" height="4" rx="2"/>
435${mark(W - 8, 14, r.status, tier.color)}
436<line class="rule" x1="0" y1="${RUN_H - 0.5}" x2="${W}" y2="${RUN_H - 0.5}"/>`,
437 )
438}
439
440const PLAN_H = 44
441const plannedSvg = (W: number, p: Planned): string => {
442 const tier = tierOf(p.tier)
443 const tx = 40
444 const meta = [tier.model, p.after.length ? `${S.after} ${p.after.join(', ')}` : ''].filter(Boolean).join(' · ')
445 return paneSvg(
446 W,
447 PLAN_H,
448 `${bot(2, 4, tier.color, { dim: true })}
449<text class="mu" x="${tx}" y="17" font-family="${FONT}" font-size="13" font-weight="600">${esc(fit(`${p.n}. ${p.title}`, 13, W - tx - 20, true))}</text>
450<text x="${tx}" y="33" font-family="${FONT}" font-size="11"><tspan fill="${tier.color}">${esc(tier.label)}</tspan><tspan class="fa"> ${esc(meta)}</tspan></text>
451${mark(W - 8, 14, 'planned', tier.color)}
452<line class="rule" x1="0" y1="${PLAN_H - 0.5}" x2="${W}" y2="${PLAN_H - 0.5}"/>`,
453 )
454}
455
456const compactSvg = (W: number, runs: Run[], planned: Planned[], t: ReturnType<typeof totals>): string => {
457 const step = 32
458 const icons = [
459 ...runs.map(r => ({ color: tierOf(tierKey(r.type)).color, walking: r.status === 'running', dim: false })),
460 ...planned.map(p => ({ color: tierOf(p.tier).color, walking: false, dim: true })),
461 ]
462 const room = Math.max(1, Math.floor((W - 160) / step))
463 const shown = icons.slice(0, room)
464 const more = icons.length - shown.length
465 return paneSvg(
466 W,
467 30,
468 `${shown.map((ic, i) => bot(i * step, 1, ic.color, { walking: ic.walking, dim: ic.dim })).join('')}
469${more > 0 ? `<text class="mu" x="${shown.length * step + 4}" y="20" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
470<text class="mu" x="${W}" y="20" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} ${fmtTokens(t.tokens)} ${fmtTime(t.time)}</text>`,
471 )
472}
473
474// --- panel open/close ----------------------------------------------------------
475
476async function openPane($: EngineInterface): Promise<void> {
477 const at = await $.clock.now()
478 await update($, nowAtom, () => at)
479 await $.ui.open({ id: PANE_ID, title: S.pane })
480}
481
482async function togglePane($: EngineInterface): Promise<boolean> {
483 if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
484 await $.ui.close({ id: PANE_ID })
485 return false
486 }
487 await openPane($)
488 return true
489}
490
491// Opens the panel once per key (a flow title, or "any"), so closing it sticks.
492async function autoOpen($: EngineInterface, key: string): Promise<void> {
493 const p = await read($, panelAtom)
494 if (p.autoOpenedFor === key) return
495 await update($, panelAtom, prev => ({ ...prev, autoOpenedFor: key }))
496 void openPane($)
497}
498
499// --- registration --------------------------------------------------------------
500
501export const register: Register = (on, options) => {
502 const autoMode = options.autoOpen === 'any' || options.autoOpen === 'never' ? options.autoOpen : 'workers'
503
504 on('session.start', async ($, e, next) => {
505 const result = await next(e)
506 workerPrefix = typeof options.workerPrefix === 'string' ? options.workerPrefix.trim() : 'savvy-'
507
508 await $.tool.register({
509 name: 'progress',
510 description:
511 'Report orchestration progress to the bar above the prompt and the crew panel. ' +
512 'Call it after presenting a plan (title, tasks, phase "delegate"), each time a task is accepted (done), ' +
513 'when changing phase or re-planning (tasks), and once at the end with finished: true. Omitted fields keep their value. ' +
514 'Main session only: calls from subagents are ignored (they use `step`).',
515 inputSchema: {
516 type: 'object',
517 properties: {
518 title: { type: 'string', description: 'Short name of the overall task, a few words. A new title starts a new flow.' },
519 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks. Defaults to the length of tasks.' },
520 done: { type: 'integer', minimum: 0, description: 'Tasks accepted after review.' },
521 phase: { type: 'string', enum: [...PHASES] },
522 finished: { type: 'boolean', description: 'True once everything is closed out.' },
523 tasks: {
524 type: 'array',
525 description:
526 'Planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` it will be delegated with, so runs can be matched to tasks.',
527 items: {
528 type: 'object',
529 properties: {
530 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
531 tier: { type: 'string', enum: Object.keys(TIERS) },
532 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of tasks this one waits for.' },
533 },
534 required: ['title', 'tier'],
535 },
536 },
537 },
538 },
539 })
540
541 await $.tool.register({
542 name: 'step',
543 description:
544 'For worker subagents: report progress on your own task to the crew panel. ' +
545 'Right after reading your brief, call it with `total` (your plan as 3-8 steps) and `done: 0`, then again as each step finishes. ' +
546 'Cheap and silent: it only draws a bar.',
547 inputSchema: {
548 type: 'object',
549 properties: {
550 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
551 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if your plan changes.' },
552 note: { type: 'string', description: 'What you are on now, a few words.' },
553 },
554 required: ['done'],
555 },
556 // Listed up front so workers see it without searching.
557 isDeferred: false,
558 })
559
560 await $.command.register({
561 name: COMMAND,
562 description: 'Show or hide the crew panel (subagents: model, steps, context, cost, time). "/crew open" only opens it.',
563 })
564
565 // Tick elapsed times once a second, only while something is running.
566 $.clock.every(1000, () => {
567 void (async () => {
568 const runs = await read($, runsAtom)
569 if (!runs.some(r => r.status === 'running')) return
570 const at = await $.clock.now()
571 await update($, nowAtom, () => at)
572 })()
573 })
574 return result
575 })
576
577 on('command.run', { command: COMMAND }, async ($, e) => {
578 if ((e.args ?? '').trim() === 'open') {
579 await openPane($)
580 return { text: S.opened }
581 }
582 return { text: (await togglePane($)) ? S.opened : S.closed }
583 })
584
585 // The orchestrator's report. A subagent's call carries an agentId and is ignored,
586 // so a worker can't rename, finish or reset the flow.
587 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
588 if (e.agentId) return { result: 'ignored: only the main session reports the flow' }
589 const input = e as unknown as ProgressInput
590 const prev = await read($, flowAtom)
591 if (startsNewFlow(prev, input) && input.title !== undefined) {
592 // A new flow clears finished runs; anything still running stays visible.
593 await update($, runsAtom, runs => runs.filter(r => r.status === 'running'))
594 }
595 const next = await update($, flowAtom, p => mergeFlow(p, input))
596 if (next && input.tasks?.length && autoMode !== 'never') await autoOpen($, next.title)
597 return { result: `ok: ${flowLabel(next ?? emptyFlow())}` }
598 })
599
600 // A worker's own steps: the call runs in the worker's loop, so agentId names it.
601 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
602 const agentId = e.agentId
603 if (!agentId) return { result: 'ignored: only subagents report steps' }
604 const input = e as unknown as { done?: number; total?: number; note?: string }
605 let found = false
606 await update($, runsAtom, runs =>
607 runs.map(r => {
608 if (r.agentId !== agentId) return r
609 found = true
610 const total = clamp(int(input.total, r.stepTotal ?? 0), 0, MAX_STEPS)
611 const done = clamp(int(input.done, r.stepDone ?? 0), 0, MAX_STEPS)
612 const note = typeof input.note === 'string' && input.note.trim() ? clip(input.note.trim(), MAX_NOTE) : undefined
613 return { ...r, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: note }
614 }),
615 )
616 return { result: found ? 'ok' : `ignored: no panel row for agent ${agentId}` }
617 })
618
619 // Safety net: worker launches mark tasks as in flight even if nobody reports.
620 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
621 if (!isWorker(String(e.subagent_type ?? ''))) return next(e)
622 await update($, flowAtom, prev => {
623 const base = prev && !prev.finished ? prev : emptyFlow()
624 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
625 })
626 try {
627 return await next(e)
628 } finally {
629 await update($, flowAtom, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
630 }
631 })
632
633 // A subagent starts: brief workers on `step`, add a panel row.
634 on('agent.spawn', async ($, e, next) => {
635 const worker = isWorker(e.subagentType)
636 const needsBrief = worker && !e.prompt.includes(STEP_TOOL)
637 const started = await next(needsBrief ? { ...e, prompt: e.prompt + STEP_BRIEF } : e)
638 if (started.deny !== undefined) return started
639
640 const at = await $.clock.now()
641 await update($, runsAtom, runs => {
642 const round = 1 + (e.description ? runs.filter(r => norm(r.description) === norm(e.description)).length : 0)
643 const run: Run = {
644 id: started.agentId ?? e.tool_use_id,
645 agentId: started.agentId,
646 // Stored clipped: the panel only ever shows a line of each.
647 type: clip(String(e.subagentType ?? ''), MAX_TITLE),
648 description: clip(String(e.description ?? ''), MAX_TITLE),
649 model: clip(String(started.model ?? ''), MAX_TITLE),
650 status: 'running',
651 startedAt: at,
652 context: 0,
653 window: windowOf(started.model),
654 tokens: 0,
655 cost: 0,
656 requests: 0,
657 round,
658 }
659 return [...runs.filter(r => r.id !== run.id), run].slice(-MAX_RUNS)
660 })
661 await update($, nowAtom, () => at)
662
663 if (worker && autoMode !== 'never') {
664 const f = await read($, flowAtom)
665 await autoOpen($, f && !f.finished ? f.title : 'workers')
666 } else if (autoMode === 'any') {
667 await autoOpen($, 'any')
668 }
669 return started
670 })
671
672 // Every model request a subagent makes: live context, tokens and cost.
673 on('turn.step', async function* ($, e, next) {
674 const result = yield* next(e)
675 const usage = result.usage as Usage | undefined
676 const agentId = e.agentId
677 if (!agentId || !usage) return result
678 const model = clip(String(usage.model || e.model || ''), MAX_TITLE)
679 await update($, runsAtom, runs =>
680 runs.map((r): Run =>
681 r.agentId !== agentId
682 ? r
683 : {
684 ...r,
685 model,
686 effort: typeof e.effort === 'string' ? clip(e.effort, 20) : r.effort,
687 status: 'running',
688 endedAt: undefined,
689 context: totalOf(usage),
690 window: windowOf(model),
691 tokens: r.tokens + totalOf(usage),
692 cost: r.cost + costOf(model, usage, true),
693 requests: r.requests + 1,
694 },
695 ),
696 )
697 return result
698 })
699
700 // A subagent's turn ends: mark it done or failed.
701 on('turn.complete', async ($, e, next) => {
702 const agentId = e.agentId
703 if (agentId) {
704 const at = await $.clock.now()
705 const usage = e.usage as Usage | undefined
706 await update($, runsAtom, runs =>
707 runs.map((r): Run => {
708 if (r.agentId !== agentId) return r
709 const ended: Run = { ...r, status: e.reason === 'answer' ? 'done' : 'failed', endedAt: at }
710 // If no per-request usage was seen, fall back to the turn's total.
711 if (r.requests === 0 && usage) {
712 const model = usage.model || r.model
713 return { ...ended, model, tokens: totalOf(usage), cost: costOf(model, usage, false) }
714 }
715 return ended
716 }),
717 )
718 await update($, nowAtom, () => at)
719 }
720 return next(e)
721 })
722
723 // ---- the crew panel --------------------------------------------------------
724
725 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
726 const ui = $.ui.resolve(e)
727 const { Box, Text, Button } = ui
728 const runs = await read($, runsAtom)
729 const f = await read($, flowAtom)
730 const p: PanelState = await read($, panelAtom)
731 const at = Math.max(await read($, nowAtom), ...runs.map(r => r.startedAt), 0)
732
733 const running = runs.filter(r => r.status === 'running').reverse()
734 const finished = runs.filter(r => r.status !== 'running').reverse()
735 const planned = pendingTasks(f, runs)
736 const t = totals(runs, at)
737 const title = f && !f.finished ? f.title : ''
738 const empty = runs.length === 0 && planned.length === 0
739 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} tokens, ${fmtTime(t.time)}`
740
741 const compactBtn = (
742 <Button key="compact" label={p.compact ? S.expand : S.compact} plain onPress={() => update($, panelAtom, s => ({ ...s, compact: !s.compact }))} />
743 )
744 const finishedBtn = (
745 <Button
746 key="finished"
747 label={`${p.hideFinished ? '▸' : '▾'} ${S.finished} (${finished.length})`}
748 plain
749 onPress={() => update($, panelAtom, s => ({ ...s, hideFinished: !s.hideFinished }))}
750 />
751 )
752
753 if (e.surface === 'desktop' && 'Svg' in ui) {
754 const { Svg } = ui
755 const W = clamp((e.props.bodyColumns || 40) * 8 - 8, 240, 900)
756 if (p.compact) {
757 return (
758 <Box flexDirection="column" gap={1}>
759 <Svg source={compactSvg(W, [...running, ...finished], planned, t)} alt={`${runs.length} agents, ${summary}`} width={W} height={30} />
760 {compactBtn}
761 </Box>
762 )
763 }
764 return (
765 <Box flexDirection="column">
766 <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={HEADER_H(title)} />
767 {compactBtn}
768 {empty && <Text dimColor>{S.empty}</Text>}
769 {running.length > 0 && <Text dimColor>{`${S.running} (${running.length})`}</Text>}
770 {running.map(r => (
771 <Svg key={r.id} source={runSvg(W, r, at)} alt={`${r.description}: ${modelName(r.model)}, running`} width={W} height={RUN_H} />
772 ))}
773 {finished.length > 0 && finishedBtn}
774 {!p.hideFinished &&
775 finished.map(r => (
776 <Svg key={r.id} source={runSvg(W, r, at)} alt={`${r.description}: ${modelName(r.model)}, ${r.status}`} width={W} height={RUN_H} />
777 ))}
778 {planned.length > 0 && <Text dimColor>{`${S.planned} (${planned.length})`}</Text>}
779 {planned.map(pl => (
780 <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: planned`} width={W} height={PLAN_H} />
781 ))}
782 </Box>
783 )
784 }
785
786 // Terminal: same content as text rows.
787 const cols = Math.max(24, e.props.bodyColumns || 40)
788 const barW = clamp(cols - 34, 6, 20)
789 const glyph = { running: '●', done: '✓', failed: '✗' } as const
790 const row = (r: Run) => {
791 const key = tierKey(r.type)
792 const tier = tierOf(key)
793 const prog = stepRatio(r)
794 const ctx = ctxPct(r)
795 const steps = r.stepTotal ? `${r.stepDone ?? 0}/${r.stepTotal}${r.stepNote ? ' ' + r.stepNote : ''} · ` : ''
796 return (
797 <Box key={r.id} flexDirection="column" marginBottom={1}>
798 <Box flexDirection="row" gap={1}>
799 <Text color={tier.color}>▣</Text>
800 <Text bold wrap="truncate-end">{r.description || r.type}</Text>
801 <Text color={r.status === 'failed' ? 'red' : r.status === 'done' ? 'green' : tier.color}>{glyph[r.status]}</Text>
802 </Box>
803 <Text dimColor wrap="truncate-end">
804 {' '}
805 {key === 'other' ? bareType(r.type) : tier.label} · {runModel(r)}
806 {r.round > 1 ? ` · ${S.round} ${r.round}` : ''}
807 </Text>
808 <Text wrap="truncate-end">
809 {' '}
810 {prog === null ? <Text dimColor>{textBar(ctx / 100, barW)}</Text> : <Text color={tier.color}>{textBar(prog, barW)}</Text>}
811 <Text dimColor>
812 {' '}
813 {steps}
814 {S.ctx} {ctx}% ≈{fmtCost(r.cost)} {fmtTime(elapsed(r, at))}
815 </Text>
816 </Text>
817 </Box>
818 )
819 }
820
821 return (
822 <Box flexDirection="column">
823 <Box flexDirection="row" justifyContent="space-between">
824 <Text bold wrap="truncate-end">{title}</Text>
825 {compactBtn}
826 </Box>
827 <Text dimColor>{summary}</Text>
828 {p.compact ? (
829 <Text wrap="truncate-end">
830 {[...running, ...finished].map(r => (
831 <Text key={r.id} color={tierOf(tierKey(r.type)).color}>
832 {glyph[r.status]}{' '}
833 </Text>
834 ))}
835 {planned.map(pl => (
836 <Text key={`plan-${pl.n}`} dimColor>
837 ○{' '}
838 </Text>
839 ))}
840 </Text>
841 ) : (
842 <Box flexDirection="column" marginTop={1}>
843 {empty && <Text dimColor>{S.empty}</Text>}
844 {running.length > 0 && <Text dimColor>{`${S.running} (${running.length})`}</Text>}
845 {running.map(row)}
846 {finished.length > 0 && finishedBtn}
847 {!p.hideFinished && finished.map(row)}
848 {planned.length > 0 && <Text dimColor>{`${S.planned} (${planned.length})`}</Text>}
849 {planned.map(pl => {
850 const tier = tierOf(pl.tier)
851 return (
852 <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
853 <Text dimColor wrap="truncate-end">
854 <Text color={tier.color}>▢</Text> {pl.n}. {pl.title} ○
855 </Text>
856 <Text dimColor wrap="truncate-end">
857 {' '}
858 {tier.label}
859 {tier.model ? ` · ${tier.model}` : ''}
860 {pl.after.length ? ` · ${S.after} ${pl.after.join(', ')}` : ''}
861 </Text>
862 </Box>
863 )
864 })}
865 </Box>
866 )}
867 </Box>
868 )
869 })
870
871 // ---- the band above the prompt --------------------------------------------
872
873 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
874 const f = await read($, flowAtom)
875 if (f === null || e.props.hasSurvey) return next(e)
876 // Keep other mods' bands, drawn beneath ours. Claude Code's own placeholder
877 // ({ type: 'engine' }) must not be nested: the desktop drops a band that does.
878 const drawn = await Promise.resolve(next(e)).catch(() => null)
879 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
880
881 const ui = $.ui.resolve(e)
882 const { Box, Text, Button } = ui
883 const runs = await read($, runsAtom)
884 const crew = runs.length + pendingTasks(f, runs).length
885 const working = runs.some(r => r.status === 'running')
886 const pct = `${Math.round(flowRatio(f) * 100)}%`
887 const crewBtn = <Button key="crew" label={`crew ${crew}`} plain onPress={() => void togglePane($)} />
888 const dismiss = <Button key="dismiss" label="✕" plain role="dismiss" onPress={() => update($, flowAtom, () => null)} />
889
890 // The terminal's element table has an Svg that draws nothing, so check the surface too.
891 if (e.surface !== 'terminal' && 'Svg' in ui) {
892 const { Svg } = ui
893 const width = clamp((e.props.bodyColumns || 100) * 8 - 110, 180, 1600)
894 return (
895 <Box flexDirection="column">
896 <Box flexDirection="row" alignItems="center" gap={1}>
897 <Svg source={bandSvg(f, width, working)} alt={`${f.title}: ${flowLabel(f)}, ${pct}`} width={width} height={BAND_H} />
898 {crewBtn}
899 {dismiss}
900 </Box>
901 {below}
902 </Box>
903 )
904 }
905
906 const cols = e.props.bodyColumns
907 const titleW = clamp(Math.min(f.title.length + 2, Math.floor(cols / 3)), 8, 30)
908 const barW = clamp(cols - titleW - 34, 6, 40)
909 const color = f.finished ? DONE : ACCENT
910 return (
911 <Box flexDirection="column">
912 <Box flexDirection="row" gap={2}>
913 <Box width={titleW} flexShrink={0}>
914 <Text color={color}>● </Text>
915 <Text wrap="truncate-end">{f.title}</Text>
916 </Box>
917 <Text color={color}>{textBar(flowRatio(f), barW)}</Text>
918 <Text bold>{flowLabel(f)}</Text>
919 <Text dimColor>{pct}</Text>
920 {crewBtn}
921 {dismiss}
922 </Box>
923 {below}
924 </Box>
925 )
926 })
927}
928types/index.d.ts 61 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7}
8
9export type Flow = {
10 title: string
11 total: number
12 done: number
13 running: number
14 phase: Phase
15 finished: boolean
16 tasks: PlannedTask[]
17}
18
19export type RunStatus = 'running' | 'done' | 'failed'
20
21export type Run = {
22 id: string
23 agentId?: string
24 type: string
25 description: string
26 model: string
27 effort?: string
28 status: RunStatus
29 startedAt: number
30 endedAt?: number
31 /** Size of the last request's prompt + output: how full the window is. */
32 context: number
33 window: number
34 /** Running sum over every request of the run. */
35 tokens: number
36 cost: number
37 requests: number
38 round: number
39 /** Self-reported through the `step` tool. */
40 stepDone?: number
41 stepTotal?: number
42 stepNote?: string
43}
44
45export type PanelState = {
46 compact: boolean
47 hideFinished: boolean
48 autoOpenedFor: string
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'teckprogress': {
54 flow: Flow | null
55 runs: Run[]
56 panel: PanelState
57 now: number
58 }
59 }
60}
61