SLOPSHOPPER

teckprogress

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

newpanebandguardcommandtool
v0.1.0MITupdated 2026-10-22laynem/claudemods/plugins/teckprogress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · teckprogress
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ Compact │ ┃ ≈$0.00, 0 tokens, 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. They show up here as soon ⏺ Update(src/auth.ts) │ ┃ as one starts. ⎿ 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 │ │ › /crew │ ⎿ teckprogress: Crew panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
Compact ≈$0.00, 0 tokens, 0:00 No subagents yet. They show up here as soon as one starts.
README

teckProgress Mod

Part of laynem/claudemods, by Layne. Installed it appears as teckprogress; the panel opens with /crew.

A Claude Code mod with two parts:

  • A segmented progress bar above the prompt. It shows the flow's title, one cell per planned task (accepted cells fill in, in-flight ones pulse), the phase, a percent, and a little robot that walks while workers run. The crew N button opens the panel and ✕ dismisses the bar.
  • A crew panel, toggled with /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-.

Install

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.

Tools it adds

  • 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.

Settings

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.

Customizing

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.

License

MIT, © 2026 Layne.

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