SLOPSHOPPER

progress-pulse

A live, colorful progress bar for whatever this session is working on: a band above the prompt, a full /progress pane, and a celebration every time a task lands

newpanebandguardcommandtoast
v0.2.2no licenseupdated 2026-10-09jgilb17/claude-mods/progress-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-pulse
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ ⏺ Read(src/auth.ts) │ ┃ No task list yet. Progress appears as soon ⎿ Read 6 lines │ ┃ as the session breaks the work into tasks. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /progress │ ⎿ progress-pulse: app: no task list yet. Progress appears once the │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Progress
app No task list yet. Progress appears as soon as the session breaks the work into tasks.
README

Joshua's Claude Code mods

Mods that load in every Claude Code session: the terminal, the desktop app's Code tab, and cloud sessions.

ModWhat it shows
repo-sentryBusiness, repo, branch, behind or ahead of origin, dirty count. Warns on work sitting on main, a stale repo, or a staged secret.
progress-pulseA live gradient progress band for the session's task list, a cheer on every finished task, /progress for the full view, /progress demo to see it run.
usage-meterThe 5-hour and weekly plan limits with reset times, context fill and session cost, right above the message box. /usage-meter hides or shows it.

How they load

  • Mac: setup-mac.js (run once) installs every mod as a Claude Code plugin read straight from this folder, and adds a session-start hook that runs sync-mods.js --quiet. A mod added to the marketplace installs itself at the next session start; an edit to a mod takes effect at the next session start or /reload-plugins.
  • Cloud: cloud-setup.sh goes into each cloud environment's setup script. Every new container clones this repo and runs sync-mods.js.

Adding a mod

Put it in its own folder with .claude-plugin/plugin.json, add it to .claude-plugin/marketplace.json, check it with claude plugin validate <folder> and claude plugin test <folder>, commit and push.

Other tools here

mcp-diagnose.js, mcp-where.js, move-mcp-to-repo.js, mcp-set-secret.js, mcp-env-from-dotenv.js: MCP server helpers that never print secret values. servicetrade-mcp/: the read-only ServiceTrade MCP server.

Source 4 files
hooks/register.tsx 466 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Celebration, Task } from '../types'
5import { bar, counts, duration, eta, fromTodos, gradient, milestonesCrossed, runs, textReport, today, upsert } from './logic'
6import { agentClock, agentsText, agentTasks, BAR_H, botSize, botSvg, pillWords, TRACK_H, compactTokens, currentBatch, finished, fraction, miniBarSvg, modelName, spawned, stepped, stepText, tier, trackSvg } from './agents'
7
8const tasksA = atom({ plugin: 'progress-pulse', key: 'tasks' } as const, [] as Task[])
9const startA = atom({ plugin: 'progress-pulse', key: 'sessionStart' } as const, 0)
10const cheerA = atom({ plugin: 'progress-pulse', key: 'celebration' } as const, null as Celebration | null)
11const projectA = atom({ plugin: 'progress-pulse', key: 'project' } as const, '')
12const hiddenA = atom({ plugin: 'progress-pulse', key: 'bandHidden' } as const, false)
13const agentsA = atom({ plugin: 'progress-pulse', key: 'agents' } as const, [] as Agent[])
14const paneOpenedA = atom({ plugin: 'progress-pulse', key: 'paneOpened' } as const, false)
15
16const usageTokens = (u: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number } | undefined) =>
17  // Cache reads are left out: every request of a subagent re-reads its whole cached prompt, so
18  // counting them made three 9-second Haiku agents read as 1.3M tokens in the 0.2.0 pane.
19  u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : 0
20
21const PANE = 'progress-pulse'
22const CHEER_MS = 8000
23const QUIET_AFTER_DONE_MS = 15 * 60_000
24
25async function projectName($: EngineInterface): Promise<string> {
26  try {
27    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 3000 })
28    if (r.exitCode === 0) return r.stdout.trim().split('/').filter(Boolean).pop() ?? ''
29  } catch { /* not a repo, or git missing */ }
30  const cwd = await $.session.cwd()
31  return cwd.split('/').filter(Boolean).pop() ?? 'this session'
32}
33
34// Everything that happens when the task list changes: store it, and cheer what just finished.
35async function apply($: EngineInterface, change: (prev: Task[], now: number) => Task[]): Promise<void> {
36  const now = await $.clock.now()
37  const stored = await read($, tasksA)
38  const isDemo = (t: Task) => t.id.startsWith('demo-')
39  const before = stored
40  let after = change(before, now)
41  if (after.some(t => !isDemo(t)) && after.some(isDemo)) after = after.filter(t => !isDemo(t))
42  await update($, tasksA, () => after)
43
44  const was = new Set(before.filter(t => t.status === 'completed').map(t => t.id))
45  const landed = after.filter(t => t.status === 'completed' && !was.has(t.id))
46  if (!landed.length) return
47
48  const b = counts(before), a = counts(after)
49  const all = a.total > 0 && a.done === a.total
50  const last = landed[landed.length - 1]!
51  const startedAt = await read($, startA)
52  const text = all
53    ? `ALL ${a.total} DONE in ${duration(now - (startedAt || now))}. Shipped.`
54    : `Done: ${last.subject}   ${a.done} of ${a.total}`
55  await update($, cheerA, () => ({ text, big: all, until: now + CHEER_MS }))
56  $.clock.after(CHEER_MS + 50, () => {
57    void (async () => {
58      const t = await $.clock.now()
59      await update($, cheerA, c => (c && c.until <= t ? null : c))
60    })().catch(() => {})
61  })
62
63  for (const m of milestonesCrossed(b.total ? b.pct : 0, a.pct)) {
64    if (m < 100) $.ui.toast(`${m}% of the way there: ${a.done} of ${a.total} done`)
65  }
66  if (all) $.ui.toast(`Every task done: ${a.total} of ${a.total}. Nice work.`, { timeoutMs: 8000 })
67
68  // A running tally per project per day, kept across sessions, for the pane's Today section.
69  if (landed.every(isDemo)) return
70  try {
71    const project = (await read($, projectA)) || 'this session'
72    const key = `day:${today(now)}`
73    const tally = ((await $.store.get(key)) ?? {}) as Record<string, number>
74    tally[project] = (tally[project] ?? 0) + landed.filter(t => !isDemo(t)).length
75    await $.store.set(key, tally)
76  } catch { /* the tally is a nicety; never let it break a tool call */ }
77}
78
79// The Agents section: what the crew cost and how far each one has got.
80// eslint-disable-next-line @typescript-eslint/no-explicit-any
81type El = any
82async function crewSection($: EngineInterface, els: { Box: El; Text: El; Svg: El | null }, crew: Agent[], tasks: Task[], now: number) {
83  const { Box, Text, Svg } = els
84  let usd: number | null = null
85  try { const u = await $.session.usage(); usd = u.cost ? u.cost.usd : null } catch { /* no ledger on this host */ }
86  const tokens = crew.reduce((x, a) => x + a.tokens, 0)
87  const first = Math.min(...crew.map(a => a.startedAt))
88  const last = crew.some(a => a.status === 'running') ? now : Math.max(...crew.map(a => a.endedAt ?? now))
89  const running = crew.filter(a => a.status === 'running')
90  const ended = crew.filter(a => a.status !== 'running')
91  const planned = tasks.filter(t => t.status === 'pending')
92
93  const tile = (label: string, value: string, note: string) => (
94    <Box key={label} flexDirection="column" borderStyle="round" paddingX={1} flexGrow={1}>
95      <Text dimColor>{label}</Text>
96      <Text bold>{value}</Text>
97      <Text dimColor>{note}</Text>
98    </Box>
99  )
100  const row = (a: Agent) => {
101    const t = tier(a.model, a.type)
102    const live = a.status === 'running'
103    const step = a.tasksTotal ? `${a.tasksDone}/${a.tasksTotal} · ${a.stepLabel}` : a.stepLabel
104    const mark = a.status === 'done' ? <Text color="#2ec27e">{'✓'}</Text> : a.status === 'failed' ? <Text color="#e5484d">{'✕'}</Text> : <Text color={t.color}>{'●'}</Text>
105    return (
106      <Box key={a.id} flexDirection="row" columnGap={1} alignItems="center" marginBottom={1}>
107        {Svg ? <Svg source={botSvg(t.color, { cell: 3, bounce: live })} alt={`${t.label} agent`} {...botSize(3)} /> : null}
108        <Box flexDirection="column" flexGrow={1}>
109          <Text>
110            <Text bold>{a.name.slice(0, 40)}</Text>
111            <Text color={t.color}>{'  ' + t.label}</Text>
112            <Text dimColor>{`  ${modelName(a.model)} · ${a.type}`}</Text>
113          </Text>
114          <Text dimColor={!live}>{live ? step : a.status === 'done' ? 'Finished' : 'Stopped before finishing'}</Text>
115          {Svg
116            ? <Svg source={miniBarSvg(a.status === 'running' ? fraction(a) : 1, a.status === 'failed' ? '#e5484d' : t.color, 260)} alt="progress" width={260} height={BAR_H} />
117            : null}
118          <Text dimColor>{`${a.tokens ? compactTokens(a.tokens) + ' tokens · ' : ''}${agentClock((a.endedAt ?? now) - a.startedAt)}`}</Text>
119        </Box>
120        {mark}
121      </Box>
122    )
123  }
124  return (
125    <Box flexDirection="column">
126      <Text bold>{`Agents  ${ended.length} of ${crew.length} finished`}</Text>
127      <Box flexDirection="row" columnGap={1}>
128        {tile('Cost', usd !== null ? `$${usd.toFixed(2)}` : 'n/a', 'whole session, API rates')}
129        {tile('Tokens', compactTokens(tokens), 'agents, excl. cache reads')}
130        {tile('Time', agentClock(last - first), 'this crew')}
131      </Box>
132      <Text> </Text>
133      {running.length ? <Text bold color="#8b7cf6">{`Running · ${running.length}`}</Text> : null}
134      {running.map(row)}
135      {ended.length ? <Text bold color="#2ec27e">{`Finished · ${ended.length}`}</Text> : null}
136      {ended.map(row)}
137      {planned.length ? <Text bold dimColor>{`Planned · ${planned.length}`}</Text> : null}
138      {planned.slice(0, 6).map(t => <Text key={t.id} dimColor>{'◷ ' + t.subject}</Text>)}
139      <Text> </Text>
140    </Box>
141  )
142}
143
144export const register: Register = on => {
145  on('session.start', async ($, e, next) => {
146    const r = await next(e)
147    if (!(await read($, startA))) {
148      const now = await $.clock.now()
149      await update($, startA, () => now)
150    }
151    const name = await projectName($)
152    await update($, projectA, () => name)
153    await $.command.register({ name: 'progress', description: 'Show live progress on what this session is working on' })
154    return r
155  })
156
157  on('command.run', { command: 'progress' }, async ($, e) => {
158    await update($, hiddenA, () => false)
159    if (e.args.trim() === 'demo') {
160      // A 12-second show of what a session looks like as it works through a list. Demo tasks
161      // carry a demo- id, are never tallied, and vanish the moment a real task arrives.
162      const steps = ['Read the brief', 'Pull the live numbers', 'Build the change', 'Run the test suite', 'Check it on real data', 'Ship it']
163      await apply($, (_prev, now) => steps.reduce((ts, s, i) => upsert(ts, `demo-${i}`, { subject: s }, now), [] as Task[]))
164      let i = 0
165      const tick = $.clock.every(1000, () => {
166        const k = Math.floor(i / 2), starting = i % 2 === 0
167        i++
168        if (k >= steps.length) { tick.cancel(); return }
169        void apply($, (prev, now) => upsert(prev, `demo-${k}`, starting
170          ? { status: 'in_progress', activeForm: steps[k] + '...' }
171          : { status: 'completed' }, now)).catch(() => {})
172      })
173      return { text: 'Running a 12 second demo in the band above the prompt.' }
174    }
175    try { await $.ui.open({ id: PANE, title: 'Progress' }) } catch { /* a screen with no panes: the text below still answers */ }
176    // Also answer in text: sessions whose screen does not draw mod bands or panes (a cloud
177    // session viewed from the app) still get the picture as a transcript line.
178    const tasks = await read($, tasksA)
179    const now = await $.clock.now()
180    const started = await read($, startA)
181    const crewText = agentsText(currentBatch(await read($, agentsA)), now)
182    const report = textReport(tasks, (await read($, projectA)) || 'this session', now - (started || now))
183    return { text: tasks.length || !crewText ? report + (crewText ? '\n' + crewText : '') : crewText }
184  })
185
186  // The session's own task list is the source of truth: every TaskCreate, TaskUpdate and
187  // TodoWrite the model makes passes through here, and only a call that succeeded is counted.
188  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
189    const r = await next(e)
190    const id = r.deny === undefined && !r.isError ? r.result?.task?.id : undefined
191    if (id && e.agentId) await update($, agentsA, l => agentTasks(l, String(e.agentId), { created: 1 }))
192    else if (id) await apply($, (prev, now) => upsert(prev, String(id), { subject: e.subject, activeForm: e.activeForm ?? null }, now))
193    return r
194  }).catch(($, e, next) => next(e))
195
196  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
197    const r = await next(e)
198    if (r.deny !== undefined || r.isError || r.result?.success === false) return r
199    if (e.agentId) {
200      if (e.status === 'completed') await update($, agentsA, l => agentTasks(l, String(e.agentId), { completed: 1 }))
201      return r
202    }
203    const id = String(e.taskId)
204    if (e.status === 'deleted') {
205      await apply($, prev => prev.filter(t => t.id !== id))
206    } else {
207      await apply($, (prev, now) => upsert(prev, id, {
208        ...(e.subject ? { subject: e.subject } : {}),
209        ...(e.activeForm ? { activeForm: e.activeForm } : {}),
210        ...(e.status && e.status !== 'deleted' ? { status: e.status } : {}),
211      }, now))
212    }
213    return r
214  }).catch(($, e, next) => next(e))
215
216  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
217    const r = await next(e)
218    if (r.deny === undefined && !r.isError && e.agentId) {
219      await update($, agentsA, l => agentTasks(l, String(e.agentId), { total: e.todos.length, done: e.todos.filter(t => t.status === 'completed').length }))
220    } else if (r.deny === undefined && !r.isError) await apply($, (prev, now) => fromTodos(prev, e.todos, now))
221    return r
222  }).catch(($, e, next) => next(e))
223
224  // The crew: every subagent the session starts (Agent tool, workflows, forks) gets a card.
225  on('agent.spawn', async ($, e, next) => {
226    const r = await next(e)
227    if (r.deny === undefined && r.agentId) {
228      const now = await $.clock.now()
229      await update($, agentsA, l => spawned(l, { id: r.agentId!, name: e.description || e.subagentType, type: e.subagentType, model: r.model || e.model || e.parentModel, background: e.background }, now))
230      if (!(await read($, paneOpenedA))) {
231        await update($, paneOpenedA, () => true)
232        try { await $.ui.open({ id: PANE, title: 'Progress' }) } catch { /* a screen with no panes */ }
233      }
234    }
235    return r
236  }).catch(($, e, next) => next(e))
237
238  // Each tool call an agent makes moves its card: a step counted and a few words on what it is doing.
239  on('tool.call', async ($, e, next) => {
240    const r = await next(e)
241    if (e.agentId) {
242      const label = stepText(e.tool, e as unknown as Record<string, unknown>)
243      await update($, agentsA, l => stepped(l, String(e.agentId), label))
244    }
245    return r
246  }).catch(($, e, next) => next(e))
247
248  // An agent's turn ending is the agent finishing: its tokens are what the API reported for it.
249  on('turn.complete', async ($, e, next) => {
250    const r = await next(e)
251    if (e.agentId) {
252      const now = await $.clock.now()
253      const ok = e.reason === 'answer'
254      await update($, agentsA, l => finished(l, String(e.agentId), ok, usageTokens(e.usage), now))
255      const batch = currentBatch(await read($, agentsA))
256      if (batch.length > 1 && batch.every(a => a.status !== 'running')) {
257        $.ui.toast(`Crew finished: ${batch.filter(a => a.status === 'done').length} of ${batch.length} agents done.`, { timeoutMs: 6000 })
258      }
259    }
260    return r
261  }).catch(($, e, next) => next(e))
262
263  // The band above the prompt: one glance, always there while there is work on the list.
264  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
265    const below = await next(e)
266    const tasks = await read($, tasksA)
267    const crew = currentBatch(await read($, agentsA))
268    if (e.props.hasSurvey || (!tasks.length && !crew.length) || (await read($, hiddenA))) return below
269    const now = await $.clock.now()
270    const c = counts(tasks)
271    const crewDone = crew.filter(a => a.status !== 'running').length
272    const lastDone = Math.max(0, ...tasks.map(t => t.doneAt ?? 0), ...crew.map(a => a.endedAt ?? 0))
273    const cheer = await read($, cheerA)
274    const cheering = cheer && cheer.until > now
275    const allDone = (tasks.length ? c.done === c.total : true) && crewDone === crew.length
276    if (allDone && now - lastDone > QUIET_AFTER_DONE_MS && !cheering) return below
277
278    const { Box, Text, Button } = $.ui.resolve(e)
279    const project = await read($, projectA)
280    const started = await read($, startA)
281
282    // Screens that draw vectors (the desktop app, VS Code, mobile): the animated pixel track,
283    // a pill riding its leading edge, and the crew count beside a bobbing bot.
284    if (e.surface !== 'terminal') {
285      const { Svg } = $.ui.resolve(e) as unknown as { Svg: El }
286      // Tasks and agents count together, so a crew still running never shows as 100%.
287      const crewRunning = crew.some(a => a.status === 'running')
288      const units = tasks.length + crew.length, unitsDone = c.done + crewDone
289      const pct = Math.round((unitsDone / Math.max(1, units)) * 100)
290      const done = pct >= 100 && allDone
291      const active = tasks.find(t => t.status === 'in_progress')
292      const word = active ? pillWords(active.activeForm || active.subject) : crewRunning ? 'Agents' : 'Next up'
293      const count = active || !crewRunning ? `${c.done}/${c.total}` : `${crewDone}/${crew.length}`
294      const label = done ? 'Done' : `${word} ${count}`
295      const title = (project || 'This session').slice(0, 28)
296      const dot = done ? '#2ec27e' : '#8b7cf6'
297      const running = crewRunning
298      const crewHat = (crew.find(a => a.status === 'running') ?? crew[crew.length - 1])
299      return (
300        <Box flexDirection="column">
301          <Box flexDirection="row" alignItems="center" columnGap={1}>
302            <Text color={dot}>{'●'}</Text>
303            <Text bold>{title}</Text>
304            <Svg source={trackSvg(pct, label, done ? 'done' : 'running', 300)} alt={`${title}: ${pct}% (${label})`} width={300} height={TRACK_H} />
305            <Text bold>{`${pct}%`}</Text>
306            {crew.length
307              ? <Box flexDirection="row" alignItems="center" columnGap={1}>
308                  <Svg source={botSvg(done || !crewHat ? '#2ec27e' : tier(crewHat.model, crewHat.type).color, { cell: 2.4, bounce: running })} alt="crew" {...botSize(2.4)} />
309                  <Text>{`×${crew.length}`}</Text>
310                </Box>
311              : null}
312            <Button key="hide" plain label="✕" onPress={() => update($, hiddenA, () => true)} />
313          </Box>
314          {cheering
315            ? <Text bold color={cheer!.big ? '#2ec27e' : 'success'}>{(cheer!.big ? '★ ' : '✦ ') + cheer!.text}</Text>
316            : active
317              ? <Text wrap="truncate-end">
318                  <Text color={dot}>{'▶ '}</Text>
319                  <Text>{active.activeForm || active.subject}</Text>
320                  <Text dimColor>{(tasks.find(t => t.status === 'pending') ? '   next: ' + tasks.find(t => t.status === 'pending')!.subject : '') + (tasks.length ? `   ${c.done} of ${c.total}` : '')}</Text>
321                </Text>
322              : null}
323          {below}
324        </Box>
325      )
326    }
327
328    if (!tasks.length) {
329      return (
330        <Box flexDirection="column">
331          <Text>
332            <Text bold color="#8b7cf6">{'◆ '}</Text>
333            <Text bold>{(project || 'session') + ' '}</Text>
334            <Text>{`agents ${crewDone}/${crew.length} done`}</Text>
335            <Text dimColor>{'  ·  ' + crew.filter(a => a.status === 'running').map(a => a.name).slice(0, 2).join(', ')}</Text>
336          </Text>
337          {below}
338        </Box>
339      )
340    }
341    // Size the bar to what is left of the line after the fixed text, so the band never wraps,
342    // including when the /progress pane is docked beside the transcript and narrows it.
343    const name = (project || 'session').slice(0, 24)
344    const tail = ` ${c.pct}%  ${c.done} of ${c.total} · ${duration(now - (started || now))}`
345    const width = Math.max(6, Math.min(40, e.props.bodyColumns - (2 + name.length + 1) - tail.length - 7))
346    const active = tasks.find(t => t.status === 'in_progress')
347    const upNext = tasks.find(t => t.status === 'pending')
348
349    return (
350      <Box flexDirection="column">
351        <Box flexDirection="row" flexWrap="nowrap">
352          <Text wrap="truncate-end">
353            <Text bold color={gradient(c.pct / 100)}>{'◆ '}</Text>
354            <Text bold>{name + ' '}</Text>
355            {runs(bar(c, width)).map(r => (r.color ? <Text color={r.color}>{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
356            <Text bold color={gradient(c.pct / 100)}>{` ${c.pct}%`}</Text>
357            <Text dimColor>{`  ${c.done} of ${c.total} · ${duration(now - (started || now))}`}</Text>
358          </Text>
359          <Box marginLeft={2}>
360            <Button key="hide" plain label="hide" onPress={() => update($, hiddenA, () => true)} />
361          </Box>
362        </Box>
363        {cheering
364          ? <Text bold color={cheer!.big ? '#2ed573' : 'success'}>{(cheer!.big ? '★ ★ ★  ' : '✦  ') + cheer!.text + (cheer!.big ? '  ★ ★ ★' : '  ✦')}</Text>
365          : active
366            ? <Text>
367                <Text color={gradient(c.pct / 100)}>{'▶ '}</Text>
368                <Text>{active.activeForm || active.subject}</Text>
369                {upNext ? <Text dimColor>{'   next: ' + upNext.subject}</Text> : null}
370              </Text>
371            : upNext ? <Text dimColor>{'○ up next: ' + upNext.subject}</Text> : null}
372        {below}
373      </Box>
374    )
375  })
376
377  // The full view: /progress opens it.
378  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379    const { Box, Text } = $.ui.resolve(e)
380    const tasks = await read($, tasksA)
381    const project = await read($, projectA)
382    const started = await read($, startA)
383    const now = await $.clock.now()
384    const c = counts(tasks)
385    const cols = e.props.bodyColumns
386    const width = Math.max(10, Math.min(60, cols - 4))
387    const big = runs(bar(c, width))
388    const left = eta(tasks)
389    const doneTimes = tasks.filter(t => t.status === 'completed' && t.doneAt).map(t => (t.doneAt as number) - (t.startedAt ?? t.createdAt))
390    const avg = doneTimes.length ? doneTimes.reduce((x, y) => x + y, 0) / doneTimes.length : null
391
392    let tally: Record<string, number> = {}
393    try { tally = ((await $.store.get(`day:${today(now)}`)) ?? {}) as Record<string, number> } catch { /* empty */ }
394    const most = Math.max(1, ...Object.values(tally))
395    const barRow = (key: string) => (
396      <Box key={key} flexDirection="row">
397        {big.map(r => (r.color ? <Text color={r.color}>{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
398      </Box>
399    )
400
401    const crew = currentBatch(await read($, agentsA))
402    const PaneSvg = e.surface !== 'terminal' ? ($.ui.resolve(e) as unknown as { Svg: El }).Svg : null
403    const crewView = crew.length ? await crewSection($, { Box, Text, Svg: PaneSvg }, crew, tasks, now) : null
404    // The task list's own bar: the band's dotted track where vectors draw. The block-character bar
405    // is for the terminal; on the desktop it wrapped into four green stripes nobody could read.
406    const taskBar = PaneSvg
407      ? <PaneSvg source={trackSvg(c.pct, c.done === c.total ? 'Done' : `${c.done}/${c.total}`, c.done === c.total ? 'done' : 'running', 300)} alt={`Tasks ${c.pct}%`} width={300} height={TRACK_H} />
408      : <Box flexDirection="column">{barRow('b1')}{barRow('b2')}</Box>
409
410    return (
411      <Box flexDirection="column">
412        <Text bold>{`${project || 'This session'}`}</Text>
413        <Text> </Text>
414        {crewView}
415        {crewView ? <Text bold>Session tasks</Text> : null}
416        {tasks.length === 0 && crew.length ? <Text dimColor>No task list in the main session yet.</Text> : tasks.length === 0
417          ? <Text dimColor>No task list yet. Progress appears as soon as the session breaks the work into tasks.</Text>
418          : <Box flexDirection="column">
419              {taskBar}
420              <Text> </Text>
421              <Text>
422                <Text bold color={gradient(c.pct / 100)}>{`${c.pct}%`}</Text>
423                <Text>{`  ${c.done} of ${c.total} done`}</Text>
424                <Text dimColor>{c.active ? `  ·  ${c.active} in progress` : ''}</Text>
425              </Text>
426              <Text> </Text>
427              {tasks.map(t => {
428                const took = t.status === 'completed' && t.doneAt ? duration(t.doneAt - (t.startedAt ?? t.createdAt))
429                  : t.status === 'in_progress' && t.startedAt ? `${duration(now - t.startedAt)} so far` : ''
430                return t.status === 'completed'
431                  ? <Text><Text color="#2ed573">{'✓ '}</Text><Text dimColor>{t.subject}</Text><Text dimColor>{took ? `  ${took}` : ''}</Text></Text>
432                  : t.status === 'in_progress'
433                    ? <Text><Text bold color={gradient(c.pct / 100)}>{'▶ '}</Text><Text bold>{t.activeForm || t.subject}</Text><Text dimColor>{took ? `  ${took}` : ''}</Text></Text>
434                    : <Text dimColor>{'○ ' + t.subject}</Text>
435              })}
436              <Text> </Text>
437              <Text dimColor>
438                {`Elapsed ${duration(now - (started || now))}`}
439                {avg !== null ? `  ·  ${duration(avg)} per task (measured)` : ''}
440                {left !== null && left > 0 ? `  ·  about ${duration(left)} left (estimate from that average)` : ''}
441              </Text>
442            </Box>}
443        {Object.keys(tally).length
444          ? <Box flexDirection="column">
445              <Text> </Text>
446              <Text bold>Today across projects</Text>
447              {Object.entries(tally).sort((x, y) => y[1] - x[1]).map(([name, n], i) => (
448                PaneSvg
449                  ? <Box key={name} flexDirection="row" columnGap={1} alignItems="center">
450                      <Text>{name.slice(0, 22)}</Text>
451                      <PaneSvg source={miniBarSvg(n / most, gradient(n / most), 160)} alt={`${name}: ${n} done`} width={160} height={BAR_H} />
452                      <Text bold>{`${n}`}</Text>
453                    </Box>
454                  : <Text>
455                      <Text>{name.padEnd(22).slice(0, 22)}</Text>
456                      <Text color={gradient(n / most)}>{'█'.repeat(Math.max(1, Math.round((n / most) * 24)))}</Text>
457                      <Text bold>{`  ${n}`}</Text>
458                    </Text>
459              ))}
460            </Box>
461          : null}
462      </Box>
463    )
464  })
465}
466
hooks/logic.ts 122 lines
1import type { Task, TaskStatus } from '../types'
2
3// Orange to gold to green as the bar fills. At 100% the whole bar turns green.
4const STOPS: [number, number, number][] = [[0xff, 0x6b, 0x35], [0xff, 0xc1, 0x4d], [0x2e, 0xd5, 0x73]]
5const hex = (r: number, g: number, b: number) =>
6  '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
7
8export function gradient(t: number): string {
9  const x = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
10  const i = Math.min(STOPS.length - 2, Math.floor(x))
11  const f = x - i
12  const a = STOPS[i]!, b = STOPS[i + 1]!
13  return hex(a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f)
14}
15
16export type Counts = { total: number; done: number; active: number; pending: number; pct: number }
17
18export function counts(tasks: Task[]): Counts {
19  const total = tasks.length
20  const done = tasks.filter(t => t.status === 'completed').length
21  const active = tasks.filter(t => t.status === 'in_progress').length
22  return { total, done, active, pending: total - done - active, pct: total ? Math.round((done / total) * 100) : 0 }
23}
24
25// One cell per entry: filled cells carry their gradient color; an in-progress task shows as a
26// half-lit cell after the filled run so the bar visibly moves as work starts, not only as it ends.
27export type Cell = { ch: string; color: string | null }
28
29export function bar(c: Counts, width: number): Cell[] {
30  const w = Math.max(4, width)
31  const filled = c.total ? Math.round((c.done / c.total) * w) : 0
32  const activeCells = c.total && c.active ? Math.max(1, Math.round((c.active / c.total) * w)) : 0
33  const cells: Cell[] = []
34  for (let i = 0; i < w; i++) {
35    if (i < filled) cells.push({ ch: '█', color: c.done === c.total ? '#2ed573' : gradient(i / Math.max(1, w - 1)) })
36    else if (i < filled + activeCells) cells.push({ ch: '▓', color: gradient(i / Math.max(1, w - 1)) })
37    else cells.push({ ch: '░', color: null })
38  }
39  return cells
40}
41
42// Collapse runs of the same color so a 40-cell bar is a handful of Text elements, not 40.
43export function runs(cells: Cell[]): { text: string; color: string | null }[] {
44  const out: { text: string; color: string | null }[] = []
45  for (const cell of cells) {
46    const last = out[out.length - 1]
47    if (last && last.color === cell.color) last.text += cell.ch
48    else out.push({ text: cell.ch, color: cell.color })
49  }
50  return out
51}
52
53export function duration(ms: number): string {
54  const s = Math.max(0, Math.round(ms / 1000))
55  if (s < 60) return `${s}s`
56  const m = Math.round(s / 60)
57  if (m < 60) return `${m}m`
58  return `${Math.floor(m / 60)}h ${m % 60}m`
59}
60
61// Estimate of time left: the average of the measured durations of tasks finished this session,
62// times what is left. Labelled an estimate wherever it is shown; null until two tasks have
63// finished with a measured duration, because one sample is not an average.
64export function eta(tasks: Task[]): number | null {
65  const measured = tasks
66    .filter(t => t.status === 'completed' && t.doneAt !== null && (t.startedAt ?? t.createdAt) !== null)
67    .map(t => (t.doneAt as number) - (t.startedAt ?? t.createdAt))
68    .filter(ms => ms > 0)
69  if (measured.length < 2) return null
70  const avg = measured.reduce((a, b) => a + b, 0) / measured.length
71  const left = tasks.filter(t => t.status !== 'completed').length
72  return left ? avg * left : 0
73}
74
75// The quarter marks a run of completions crossed, so each gets one cheer and only one.
76export function milestonesCrossed(before: number, after: number): number[] {
77  return [25, 50, 75, 100].filter(m => before < m && after >= m)
78}
79
80export function upsert(tasks: Task[], id: string, patch: Partial<Task> & { subject?: string }, now: number): Task[] {
81  const i = tasks.findIndex(t => t.id === id)
82  if (i === -1) {
83    return [...tasks, {
84      id, subject: patch.subject ?? `Task ${id}`, activeForm: patch.activeForm ?? null,
85      status: (patch.status as TaskStatus) ?? 'pending', createdAt: now,
86      startedAt: patch.status === 'in_progress' ? now : null, doneAt: patch.status === 'completed' ? now : null,
87    }]
88  }
89  const prev = tasks[i]!
90  const next: Task = { ...prev, ...patch }
91  if (patch.status === 'in_progress' && prev.status !== 'in_progress') next.startedAt = now
92  if (patch.status === 'completed' && prev.status !== 'completed') next.doneAt = now
93  if (patch.status && patch.status !== 'completed') next.doneAt = null
94  return tasks.map((t, j) => (j === i ? next : t))
95}
96
97// TodoWrite hands over the whole list every time. Match by content so a task keeps its timings
98// across rewrites; anything not in the new list is gone.
99export function fromTodos(prev: Task[], todos: { content: string; status: TaskStatus; activeForm?: string }[], now: number): Task[] {
100  return todos.map((todo, i) => {
101    const old = prev.find(t => t.subject === todo.content)
102    const base: Task = old ?? { id: `todo-${i}-${todo.content.slice(0, 24)}`, subject: todo.content, activeForm: todo.activeForm ?? null, status: 'pending', createdAt: now, startedAt: null, doneAt: null }
103    return upsert([base], base.id, { status: todo.status, activeForm: todo.activeForm ?? base.activeForm }, now)[0]!
104  })
105}
106
107export const today = (now: number) => new Date(now).toISOString().slice(0, 10)
108
109// The whole picture as plain text, for a screen that draws no bands or panes.
110export function textReport(tasks: Task[], project: string, elapsedMs: number): string {
111  if (!tasks.length) return `${project}: no task list yet. Progress appears once the session breaks the work into tasks.`
112  const c = counts(tasks)
113  const cells = bar(c, 20).map(x => x.ch).join('')
114  const lines = [`${project}  ${cells}  ${c.pct}%  ${c.done} of ${c.total} done  ·  ${duration(elapsedMs)}`]
115  for (const t of tasks) {
116    lines.push(t.status === 'completed' ? `  ✓ ${t.subject}` : t.status === 'in_progress' ? `  ▶ ${t.activeForm || t.subject}` : `  ○ ${t.subject}`)
117  }
118  const left = eta(tasks)
119  if (left) lines.push(`  about ${duration(left)} left (estimate from the measured average)`)
120  return lines.join('\n')
121}
122
hooks/agents.ts 180 lines
1import type { Agent } from '../types'
2
3// What a model id reads as: claude-opus-5-5 -> Opus 5.5. Anything else is shown as given.
4export function modelName(id: string): string {
5  const m = /(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/i.exec(id || '')
6  if (!m) return id || 'inherit'
7  return `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
8}
9
10// The crew tier, read from the model the agent runs on: the bigger the model, the heavier the hat.
11export type Tier = { label: string; color: string }
12export function tier(model: string, type: string): Tier {
13  const m = (model || '').toLowerCase()
14  if (/explore|plan|review/i.test(type)) return { label: 'careful', color: '#e0a526' }
15  if (m.includes('opus')) return { label: 'heavy', color: '#ff6b35' }
16  if (m.includes('haiku')) return { label: 'light', color: '#2ec27e' }
17  return { label: 'medium', color: '#5b8def' }
18}
19
20export function spawned(list: Agent[], a: { id: string; name: string; type: string; model: string; background: boolean }, now: number): Agent[] {
21  if (list.some(x => x.id === a.id)) return list
22  return [...list, { ...a, status: 'running' as const, startedAt: now, endedAt: null, steps: 0, stepLabel: 'Starting', tokens: 0, tasksDone: 0, tasksTotal: 0 }].slice(-40)
23}
24
25export function stepped(list: Agent[], id: string, label: string): Agent[] {
26  return list.map(a => (a.id === id && a.status === 'running' ? { ...a, steps: a.steps + 1, stepLabel: label } : a))
27}
28
29export function agentTasks(list: Agent[], id: string, change: { created?: number; completed?: number; total?: number; done?: number }): Agent[] {
30  return list.map(a => {
31    if (a.id !== id) return a
32    const tasksTotal = change.total ?? a.tasksTotal + (change.created ?? 0)
33    const tasksDone = Math.min(tasksTotal, change.done ?? a.tasksDone + (change.completed ?? 0))
34    return { ...a, tasksTotal, tasksDone }
35  })
36}
37
38export function finished(list: Agent[], id: string, ok: boolean, tokens: number, now: number): Agent[] {
39  return list.map(a => (a.id === id
40    ? { ...a, status: (ok ? 'done' : 'failed') as Agent['status'], endedAt: now, tokens: a.tokens + tokens, tasksDone: ok ? a.tasksTotal : a.tasksDone, stepLabel: ok ? 'Done' : 'Stopped' }
41    : a))
42}
43
44// A tool call as a few words on the agent's card.
45export function stepText(tool: string, input: Record<string, unknown>): string {
46  if (tool === 'TaskUpdate' && typeof input.activeForm === 'string') return input.activeForm
47  if (tool === 'Bash' && typeof input.description === 'string') return input.description
48  if ((tool === 'Read' || tool === 'Edit' || tool === 'Write') && typeof input.file_path === 'string') {
49    return `${tool} ${String(input.file_path).split('/').pop()}`
50  }
51  if (tool === 'Grep' || tool === 'Glob') return 'Searching'
52  if (tool.startsWith('mcp__')) return tool.split('__').slice(1).join(' ').replace(/_/g, ' ')
53  return tool
54}
55
56// The batch: agents started within ten minutes of the newest, so yesterday's crew does not crowd today's.
57export function currentBatch(list: Agent[]): Agent[] {
58  if (!list.length) return []
59  const newest = Math.max(...list.map(a => a.startedAt))
60  return list.filter(a => newest - a.startedAt < 10 * 60_000 || a.status === 'running')
61}
62
63export function fraction(a: Agent): number | null {
64  if (a.status !== 'running') return 1
65  if (a.tasksTotal > 0) return a.tasksDone / a.tasksTotal
66  return null // running with no task list: drawn as an indeterminate shimmer
67}
68
69export const compactTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
70
71// ---------- Pixel art (original sprite: a small worker bot whose hat color says its tier) ----------
72
73const SPRITE = [
74  '...hhhh...',
75  '..hhhhhh..',
76  '.hhhhhhhh.',
77  '.bbbbbbbb.',
78  '.bebbbbeb.',
79  '.bbbbbbbb.',
80  '.bbmmmmbb.',
81  '..bbbbbb..',
82  '..b.bb.b..',
83]
84
85// Drawn sizes, so the Svg element gets an exact box: without width and height the desktop app
86// sizes the frame itself, which is how the 0.2.0 band ended up with large white boxes.
87export const botSize = (cell = 4) => ({ width: 10 * cell, height: 10 * cell })
88export const TRACK_H = 22
89export const BAR_H = 4
90
91// The pill's words: the start of what is happening, cut at a word so it reads as a phrase.
92export function pillWords(text: string, max = 18): string {
93  const t = text.replace(/\.\.\.$|…$/, '').trim()
94  if (t.length <= max) return t
95  const cut = t.slice(0, max)
96  const sp = cut.lastIndexOf(' ')
97  return (sp > 6 ? cut.slice(0, sp) : cut).replace(/[\s,;:]+$/, '') + '…'
98}
99
100export function botSvg(hat: string, opts: { cell?: number; bounce?: boolean } = {}): string {
101  const c = opts.cell ?? 4
102  const w = SPRITE[0]!.length * c, hgt = SPRITE.length * c
103  const fill: Record<string, string> = { h: hat, b: '#9fb3c8', e: '#1b2430', m: '#5c6f82' }
104  let rects = ''
105  SPRITE.forEach((row, y) => [...row].forEach((ch, x) => {
106    if (ch !== '.') rects += `<rect x="${x * c}" y="${y * c}" width="${c}" height="${c}" fill="${fill[ch]}"/>`
107  }))
108  const bob = opts.bounce
109    ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -${c / 2};0 0" dur="0.9s" repeatCount="indefinite"/>`
110    : ''
111  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${hgt + c}" viewBox="0 -${c} ${w} ${hgt + c}" shape-rendering="crispEdges"><g>${rects}${bob}</g></svg>`
112}
113
114const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
115
116// The band's track: a field of pixel dots that fills left to right, a shimmer running through the
117// filled part while work is in flight, and a pill riding the leading edge with what is happening.
118export function trackSvg(pct: number, label: string, state: 'running' | 'done', width = 300): string {
119  const h = 22, dot = 3, gap = 2, rows = 3
120  const cols = Math.floor((width - 8) / (dot + gap))
121  const filledCols = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cols)
122  const color = state === 'done' ? '#2ec27e' : '#8b7cf6'
123  let dots = ''
124  for (let x = 0; x < cols; x++) {
125    for (let y = 0; y < rows; y++) {
126      const on = x < filledCols
127      const op = on ? 0.95 : ((x * 7 + y * 3) % 5 === 0 ? 0.35 : 0.15)
128      dots += `<rect x="${4 + x * (dot + gap)}" y="${5 + y * (dot + gap)}" width="${dot}" height="${dot}" fill="${on ? color : '#8a8f98'}" opacity="${op}"/>`
129    }
130  }
131  const pillText = esc(label).slice(0, 24)
132  const pillW = Math.max(46, pillText.length * 6.6 + 18)
133  const edge = 4 + filledCols * (dot + gap)
134  const pillX = state === 'done' ? width - pillW - 2 : Math.max(2, Math.min(width - pillW - 2, edge - pillW / 2))
135  const shimmer = state === 'running' && filledCols > 2
136    ? `<rect x="0" y="0" width="28" height="${h}" fill="url(#sh)"><animate attributeName="x" from="-28" to="${edge}" dur="1.6s" repeatCount="indefinite"/></rect>`
137    : ''
138  const pulse = state === 'running'
139    ? `<animate attributeName="opacity" values="1;0.75;1" dur="1.4s" repeatCount="indefinite"/>`
140    : ''
141  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
142    `<defs><linearGradient id="sh" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
143    `<clipPath id="fill"><rect x="0" y="0" width="${edge}" height="${h}"/></clipPath></defs>` +
144    `<rect x="0" y="0" width="${width}" height="${h}" rx="${h / 2}" fill="#8a8f98" opacity="0.12"/>` +
145    dots + `<g clip-path="url(#fill)">${shimmer}</g>` +
146    `<g><rect x="${pillX}" y="1" width="${pillW}" height="${h - 2}" rx="${(h - 2) / 2}" fill="${color}"/>` +
147    `<text x="${pillX + pillW / 2}" y="${h / 2 + 4}" text-anchor="middle" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="11.5" font-weight="600" fill="#fff">${pillText}</text>${pulse}</g></svg>`
148}
149
150// A thin per-agent bar: solid when it has a known fraction, a sliding stripe when it does not.
151export function miniBarSvg(frac: number | null, color: string, width = 260): string {
152  const h = 4
153  const base = `<rect x="0" y="0" width="${width}" height="${h}" rx="2" fill="#8a8f98" opacity="0.2"/>`
154  if (frac === null) {
155    return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${base}<rect x="0" y="0" width="${width * 0.3}" height="${h}" rx="2" fill="${color}"><animate attributeName="x" from="-${width * 0.3}" to="${width}" dur="1.3s" repeatCount="indefinite"/></rect></svg>`
156  }
157  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${base}<rect x="0" y="0" width="${Math.round(width * Math.max(0, Math.min(1, frac)))}" height="${h}" rx="2" fill="${color}"/></svg>`
158}
159
160const clock = (ms: number) => {
161  const s = Math.max(0, Math.round(ms / 1000))
162  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
163}
164export const agentClock = clock
165
166// One line per agent of the current crew, for sessions whose screen draws no panes.
167export function agentsText(crew: Agent[], now: number): string {
168  if (!crew.length) return ''
169  const done = crew.filter(a => a.status !== 'running').length
170  const lines = [`Agents  ${done} of ${crew.length} finished`]
171  for (const a of crew) {
172    const mark = a.status === 'running' ? '●' : a.status === 'done' ? '✓' : '✕'
173    const t = tier(a.model, a.type)
174    const step = a.tasksTotal ? `${a.tasksDone}/${a.tasksTotal} · ${a.stepLabel}` : a.stepLabel
175    const took = clock((a.endedAt ?? now) - a.startedAt)
176    lines.push(`  ${mark} ${a.name}  (${t.label}, ${modelName(a.model)})  ${a.status === 'running' ? step : a.status === 'done' ? 'finished' : 'failed'}  ·  ${a.tokens ? compactTokens(a.tokens) + ' tokens · ' : ''}${took}`)
177  }
178  return lines.join('\n')
179}
180
types/index.d.ts 46 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4  id: string
5  subject: string
6  activeForm: string | null
7  status: TaskStatus
8  createdAt: number
9  startedAt: number | null
10  doneAt: number | null
11}
12
13export type Celebration = { text: string; big: boolean; until: number }
14
15export type AgentStatus = 'running' | 'done' | 'failed'
16
17export type Agent = {
18  id: string
19  name: string
20  type: string
21  model: string
22  background: boolean
23  status: AgentStatus
24  startedAt: number
25  endedAt: number | null
26  steps: number
27  stepLabel: string
28  tokens: number
29  tasksDone: number
30  tasksTotal: number
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'progress-pulse': {
36      tasks: Task[]
37      sessionStart: number
38      celebration: Celebration | null
39      project: string
40      bandHidden: boolean
41      agents: Agent[]
42      paneOpened: boolean
43    }
44  }
45}
46