SLOPSHOPPER

pulse

Live band above the prompt: tool activity, running subagents and todo progress of this session.

panebandguardcommandpromptagents
v0.1.0no licenseupdated 2026-10-16kyojinkim/claude-code-pulse-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulse
│ ┃ pulse ✕ › fix the failing auth test and add an audit log call │ ┃ ◇ Idle │ ┃ 1 turn 9 calls 1 failed 0 agents run ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Tools By calls, this session ⏺ Update(src/auth.ts) │ ┃ Tool Share Calls Failed Avg ⎿ Added 2 lines, removed 1 line │ ┃ Bash ███████████ 4 1 20ms ⏺ Bash(bun test) │ ┃ Write ██████░░░░░ 2 – 20ms ⎿ 3 pass, 1 fail │ ┃ Read ███░░░░░░░░ 1 – 20ms │ ┃ Grep ███░░░░░░░░ 1 – 20ms ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Edit ███░░░░░░░░ 1 – 20ms │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Recent Last 9 of 9, newest first │ ┃ ✓ Bash Show environment file 20ms 0s › /pulse │ ┃ ✓ Bash Clean build output and push 20ms ⎿ pulse: pulse: band hidden. /pulse shows it again. │ ┃ ✓ Bash Show working tree status 20ms │ ┃ ✗ Bash Run the test suite 20ms 0s ag │ ┃ ✓ Write src/cache.ts 20ms 0s ago │ ┃ ✓ Write src/audit.ts 20ms 0s ago │ ┃ ✓ Edit src/auth.ts 20ms 0s ago │ ┃ ✓ Grep app/src 20ms 0s ago │ ┃ ✓ Read src/auth.ts 20ms 0s ago │ ┃ │ ┃ Agents None this session │ ┃ │ ┃ Todos No list yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pulse
◇ Idle 1 turn 9 calls 1 failed 0 agents running Tools By calls, this session Tool Share Calls Failed Avg Last Bash ███████████ 4 1 20ms 0s ago Write ██████░░░░░ 2 – 20ms 0s ago Read ███░░░░░░░░ 1 – 20ms 0s ago Grep ███░░░░░░░░ 1 – 20ms 0s ago Edit ███░░░░░░░░ 1 – 20ms 0s ago Recent Last 9 of 9, newest first ✓ Bash Show environment file 20ms 0s ago ✓ Bash Clean build output and push 20ms 0s ago ✓ Bash Show working tree status 20ms 0s ago ✗ Bash Run the test suite 20ms 0s ago ✓ Write src/cache.ts 20ms 0s ago ✓ Write src/audit.ts 20ms 0s ago ✓ Edit src/auth.ts 20ms 0s ago ✓ Grep app/src 20ms 0s ago ✓ Read src/auth.ts 20ms 0s ago Agents None this session Todos No list yet
README

Pulse

Live session activity at a glance: a band above the prompt that shows your tool calls, running subagents, and todo progress in real time.

Know what Claude is doing right now—which tools it's calling, how many have failed, what agents are running, and how far your todos have come. The band stays updated with every tool call and every turn, all session-local. Nothing leaves your machine.

Type /pulse to toggle the band. /pulse details opens the full breakdown.

EXPANDED

The band shows at the top of every turn.

  • Top tools: the busiest tools this session (by call count), with failures highlighted in red.
  • Agents: running subagents listed by type and elapsed time. Shows "none this session" if empty.
  • Collapse button: ▼ to collapse, ▲ when collapsed. Details button opens the pane for the full view. X hides the band.

COLLAPSED

One-line summary:

  • Tool calls: total count, failures (red), and which tool ran last.
  • Agents: count if any are running.
  • Todos: a progress bar and count (e.g., 1/3).

DETAILS PANE

Open with the Details button on the band, or run /pulse details at the prompt.

Pane shows:

  • Working/Idle: whether a turn is running and how long it's taken.
  • Session stats: total turns, calls, failures, running agents, and todo progress.
  • Tools table: each tool's call count, failures, average execution time, and last used.
  • Recent calls: the last ~12 calls, with what they targeted and how long they took.
  • Agents: all agents this session, running first, with type, description, and duration.
  • Todos: progress bar and full list with status (✓ done, ▶ in progress, · pending).

WHAT PULSE TRACKS

Tool calls — every tool Claude runs this session: name, count, failures, and execution times.

Subagents — each spawned agent: type, description, when it started and ended, and whether it errored.

Todos — from TodoWrite calls: content, active form, and status.

Recent history — the last ~40 calls and their targets (file paths, command snippets, queries), useful for checking what Claude just tried without scrolling the transcript.

Nothing is saved after the session ends. Each session starts fresh.


INSTALL

Needs Claude Code 2.1.287 or later.

Desktop app: in the Code tab, send this as a message:

Install the pulse plugin from the GitHub marketplace kyojinkim/claude-code-pulse-mod

Then allow claude plugin commands if asked, and quit and reopen the app.

Terminal: run these one at a time:

claude plugin marketplace add kyojinkim/claude-code-pulse-mod
claude plugin install pulse@kyojinkim/claude-code-pulse-mod
/reload-plugins

The band appears once the plugins reload.

Either way installs it for both the desktop app and the terminal.


WHERE IT WORKS

  • Claude desktop app, Code tab
  • Claude Code in a terminal (with ▲/▼ to collapse/expand, and ▼ to collapse, [-] to hide)

DEVELOP

Clone and run from the folder:

git clone https://github.com/kyojinkim/claude-code-pulse-mod
cd claude-code-pulse-mod
claude --plugin-dir .

Then run /pulse to verify. Check the details pane with /pulse details.

Run the test suite:

claude plugin test .

PRIVACY

Everything runs on your machine with the same access Claude Code has. Nothing is sent anywhere. Read the code before installing—it's open source.


LICENSE

MIT © 2026 Kory Kim

Source 2 files
hooks/register.tsx 449 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, RenderElement } from 'claude-code'
3import type { AgentRun, CallRow, Pulse, Todo, ToolStat } from '../types'
4
5// pulse: one band above the prompt with what this session is doing right
6// now (which tools ran and failed, which subagents run, how far the todo
7// list has come), and a pane with the detail. Nothing leaves the machine.
8
9const EMPTY: Pulse = { tools: [], lastTool: null, agents: [], todos: [], turnStartedAt: null, recent: [], turns: 0 }
10
11const pulse = atom({ plugin: 'pulse', key: 'pulse' } as const, EMPTY)
12const collapsed = atom({ plugin: 'pulse', key: 'collapsed' } as const, true)
13const hidden = atom({ plugin: 'pulse', key: 'hidden' } as const, false)
14
15const MAX_AGENTS_SHOWN = 4
16const MAX_TOOLS_SHOWN = 6
17const MAX_RECENT = 40
18const RECENT_SHOWN = 12
19const DONE_AGENT_TTL_MS = 60_000
20const PANE = 'pulse'
21
22type Host = Parameters<typeof update>[0]
23
24// State written by an older pulse lacks the newer fields; fill them in.
25const norm = (p: Partial<Pulse> | undefined): Pulse => ({
26  ...EMPTY,
27  ...(p ?? {}),
28  tools: (p?.tools ?? []).map(t => ({ ...t, ms: t.ms ?? 0, lastAt: t.lastAt ?? null })),
29  recent: p?.recent ?? [],
30  turns: p?.turns ?? 0,
31})
32const readPulse = async ($: Host) => norm(await read($, pulse))
33const edit = ($: Host, f: (p: Pulse) => Pulse) => update($, pulse, p => f(norm(p)))
34
35// The band is one site: whichever plugin sits on top answers for it. pulse draws
36// its own row and keeps whatever another plugin beneath it drew, stacked above it.
37const isBlank = (el: unknown): boolean => {
38  if (!el || typeof el !== 'object') return true
39  const t = (el as { type?: unknown }).type
40  if (t === 'engine') return true
41  const kids = (el as { children?: unknown }).children
42  return t === 'Box' && (!Array.isArray(kids) || kids.length === 0)
43}
44
45const short = (name: string) => name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name
46
47// What a call was about, in a few words: a file's last two segments, else
48// the call's own description, command, pattern, query or address.
49const detailOf = (input: Record<string, unknown>): string => {
50  const s = (k: string) => typeof input[k] === 'string' && (input[k] as string).length > 0 ? input[k] as string : undefined
51  const path = s('file_path') ?? s('notebook_path') ?? s('path')
52  if (path) return path.split('/').filter(Boolean).slice(-2).join('/')
53  const text = s('description') ?? s('command') ?? s('pattern') ?? s('query') ?? s('url') ?? s('skill') ?? s('prompt') ?? ''
54  return text.replace(/\s+/g, ' ').trim().slice(0, 160)
55}
56
57const since = (from: number, now: number) => {
58  const s = Math.max(0, Math.round((now - from) / 1000))
59  if (s < 60) return `${s}s`
60  const m = Math.floor(s / 60)
61  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
62}
63
64const ms = (n: number) => n < 1000 ? `${Math.round(n)}ms` : n < 60_000 ? `${(n / 1000).toFixed(1)}s` : since(0, n)
65
66const bar = (done: number, total: number, width: number) => {
67  if (total === 0) return '─'.repeat(width)
68  const filled = Math.round((done / total) * width)
69  return '█'.repeat(filled) + '░'.repeat(width - filled)
70}
71
72const agentGlyph = (a: AgentRun) => a.endedAt === null ? '●' : a.isError ? '✗' : '✓'
73const agentColor = (a: AgentRun) => a.endedAt === null ? 'warning' : a.isError ? 'error' : 'success'
74// Running agents first, then the newest.
75const byLive = (a: AgentRun, b: AgentRun) => (a.endedAt === null ? 0 : 1) - (b.endedAt === null ? 0 : 1) || b.startedAt - a.startedAt
76
77export const register: Register = on => {
78  on('session.start', ($, e, next) => {
79    $.command.register({ name: 'pulse', description: 'Show or hide the pulse band; "/pulse details" opens the pulse pane' })
80    return next(e)
81  })
82
83  on('command.run', { command: 'pulse' }, async ($, e) => {
84    if (/^\s*(details|pane|open)\s*$/i.test(e.args)) {
85      const opened = await $.ui.open({ id: PANE, title: 'Pulse' })
86      return { text: opened.isPlaced ? 'pulse: details pane opened.' : `pulse: pane waits (${opened.reason}).` }
87    }
88    const isHidden = await update($, hidden, v => !v)
89    return { text: isHidden ? 'pulse: band hidden. /pulse shows it again.' : 'pulse: band shown.' }
90  }).catch(() => ({ text: 'pulse: could not toggle' }))
91
92  on('prompt.submit', async ($, e, next) => {
93    const now = await $.clock.now()
94    await edit($, p => ({
95      ...p,
96      turnStartedAt: now,
97      turns: p.turns + 1,
98      tools: p.tools.map(t => ({ ...t, busy: 0 })),
99      agents: p.agents.filter(a => a.endedAt === null || now - a.endedAt < DONE_AGENT_TTL_MS),
100    }))
101    return next(e)
102  }).catch(($, e, next) => next(e))
103
104  on('agent.spawn', async ($, e, next) => {
105    const result = await next(e)
106    if (result.deny !== undefined || !result.agentId) return result
107    const now = await $.clock.now()
108    const run: AgentRun = { id: result.agentId, type: e.subagentType, description: e.description, startedAt: now, endedAt: null, isError: false }
109    await edit($, p => ({ ...p, agents: [...p.agents.filter(a => a.id !== run.id), run] }))
110    return result
111  }).catch(($, e, next) => next(e))
112
113  on('tool.call', async ($, e, next) => {
114    const name = short(e.tool)
115    const isMain = e.agentId === undefined
116    const startedAt = await $.clock.now()
117    const id = e.tool_use_id ?? `${name}@${startedAt}`
118
119    // The main loop's calls are the session's activity; a subagent's are its own.
120    if (isMain) {
121      const row: CallRow = { id, tool: name, detail: detailOf(e as unknown as Record<string, unknown>), startedAt, endedAt: null, isError: false }
122      await edit($, p => {
123        const tools = p.tools.some(t => t.name === name)
124          ? p.tools.map(t => t.name === name ? { ...t, calls: t.calls + 1, busy: t.busy + 1 } : t)
125          : [...p.tools, { name, calls: 1, errors: 0, busy: 1, ms: 0, lastAt: null } satisfies ToolStat]
126        return { ...p, tools, lastTool: name, recent: [...p.recent, row].slice(-MAX_RECENT) }
127      })
128    }
129
130    if (e.tool === 'TodoWrite') {
131      const todos: Todo[] = e.todos.map(t => ({ content: t.content, activeForm: t.activeForm, status: t.status }))
132      await edit($, p => ({ ...p, todos }))
133    }
134
135    const result = await next(e)
136    const isError = result.deny !== undefined || result.isError === true
137    const endedAt = await $.clock.now()
138
139    if (isMain) {
140      await edit($, p => ({
141        ...p,
142        tools: p.tools.map(t => t.name === name
143          ? { ...t, busy: Math.max(0, t.busy - 1), errors: t.errors + (isError ? 1 : 0), ms: t.ms + (endedAt - startedAt), lastAt: endedAt }
144          : t),
145        recent: p.recent.map(r => r.id === id ? { ...r, endedAt, isError } : r),
146      }))
147    }
148
149    if (e.tool === 'Agent' && result.deny === undefined) {
150      const live = await $.agent.list()
151      const alive = new Set(live.map(a => a.id))
152      await edit($, p => ({
153        ...p,
154        agents: p.agents.map(a => a.endedAt === null && !alive.has(a.id) ? { ...a, endedAt, isError } : a),
155      }))
156    }
157
158    return result
159  }).catch(($, e, next) => next(e))
160
161  on('turn.complete', async ($, e, next) => {
162    const now = await $.clock.now()
163    const live = await $.agent.list()
164    const alive = new Set(live.map(a => a.id))
165    await edit($, p => ({
166      ...p,
167      turnStartedAt: null,
168      tools: p.tools.map(t => ({ ...t, busy: 0 })),
169      // A call still open when the turn ends was cut off.
170      recent: p.recent.map(r => r.endedAt === null ? { ...r, endedAt: now } : r),
171      agents: p.agents.map(a => a.endedAt === null && !alive.has(a.id) ? { ...a, endedAt: now } : a),
172    }))
173    return next(e)
174  })
175
176  // The band: one row, a glance at the session. Open, it adds the busiest tools
177  // and this session's agents, each row named. Detail lives in the pane.
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    const p = await readPulse($)
180    const isCollapsed = await read($, collapsed)
181    const isHidden = await read($, hidden)
182    const isEmpty = p.tools.length === 0 && p.agents.length === 0 && p.todos.length === 0
183    if (e.props.hasSurvey || isHidden || isEmpty) return next(e)
184
185    const { Box, Text, Button } = $.ui.resolve(e)
186    const now = await $.clock.now()
187
188    const running = p.agents.filter(a => a.endedAt === null)
189    const done = p.todos.filter(t => t.status === 'completed').length
190    const current = p.todos.find(t => t.status === 'in_progress')
191    const calls = p.tools.reduce((n, t) => n + t.calls, 0)
192    const errors = p.tools.reduce((n, t) => n + t.errors, 0)
193    const busy = p.tools.filter(t => t.busy > 0)
194
195    const tools = (
196      <Box gap={1} flexShrink={1}>
197        <Text color={busy.length > 0 ? 'claude' : 'subtle'}>{busy.length > 0 ? '◆' : '◇'}</Text>
198        <Text bold tabular>{calls}</Text>
199        <Text dimColor>{calls === 1 ? 'tool call' : 'tool calls'}</Text>
200        {errors > 0 ? <Text color="error">{`· ${errors} failed`}</Text> : null}
201        {busy.length > 0
202          ? <Text color="claude" wrap="truncate-end">{`· running ${busy.map(t => t.name).join(', ')}`}</Text>
203          : p.lastTool ? <Text dimColor wrap="truncate-end">{`· last ${p.lastTool}`}</Text> : null}
204      </Box>
205    )
206    const agents = (
207      <Box gap={1} flexShrink={1}>
208        <Text color={running.length > 0 ? 'warning' : 'subtle'}>{running.length > 0 ? '●' : '○'}</Text>
209        {running.length > 0 ? <Text bold tabular>{running.length}</Text> : null}
210        <Text dimColor>{running.length === 0 ? 'No agents running' : running.length === 1 ? 'agent running' : 'agents running'}</Text>
211      </Box>
212    )
213    const todos = p.todos.length > 0 ? (
214      <Box gap={1} flexShrink={1}>
215        <Text color={done === p.todos.length ? 'success' : 'claude'} tabular>{bar(done, p.todos.length, 8)}</Text>
216        <Text bold tabular>{`${done}/${p.todos.length}`}</Text>
217        <Text dimColor wrap="truncate-end">{current ? current.activeForm || current.content : done === p.todos.length ? 'todos done' : 'todos'}</Text>
218      </Box>
219    ) : null
220
221    const controls = (
222      <Box gap={1} alignItems="center" flexShrink={0}>
223        <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'Pulse' })} />
224        <Button key="toggle" label={isCollapsed ? '▲' : '▼'} plain onPress={() => update($, collapsed, v => !v)} />
225        <Button key="hide" label="X" role="dismiss" plain dimColor onPress={() => update($, hidden, () => true)} />
226      </Box>
227    )
228
229    const summary = (
230      <Box gap={2} alignItems="center">
231        <Box gap={2} flexGrow={1} flexShrink={1} flexWrap="wrap">
232          {tools}
233          {agents}
234          {todos}
235        </Box>
236        {controls}
237      </Box>
238    )
239
240    const below = await next(e)
241    const rule = (
242      <Box width="100%" justifyContent="center">
243        <Text dimColor>· · · · ·</Text>
244      </Box>
245    )
246    const stacked = (mine: RenderElement) => isBlank(below)
247      ? mine
248      : <Box flexDirection="column" gap={1}>{below}{rule}{mine}</Box>
249    if (isCollapsed) return stacked(summary)
250
251    // Open: each row says what it lists, in a dim label column.
252    const label = (text: string) => (
253      <Box width={10} flexShrink={0}>
254        <Text dimColor>{text}</Text>
255      </Box>
256    )
257    const top = [...p.tools].sort((a, b) => b.calls - a.calls).slice(0, MAX_TOOLS_SHOWN)
258    const toolRow = (
259      <Box gap={1}>
260        {label('Top tools')}
261        <Box flexWrap="wrap" columnGap={3} flexShrink={1}>
262          {top.map(t => (
263            <Box key={t.name} gap={1}>
264              <Text color={t.busy > 0 ? 'claude' : 'text'}>{t.name}</Text>
265              <Text dimColor tabular>{`×${t.calls}`}</Text>
266              {t.errors > 0 ? <Text color="error" tabular>{`${t.errors} failed`}</Text> : null}
267            </Box>
268          ))}
269          {p.tools.length > top.length ? <Text dimColor>{`+${p.tools.length - top.length} more`}</Text> : null}
270        </Box>
271      </Box>
272    )
273    const shownAgents = [...p.agents].sort(byLive).slice(0, MAX_AGENTS_SHOWN)
274    const agentRow = (
275      <Box gap={1}>
276        {label('Agents')}
277        <Box flexWrap="wrap" columnGap={3} flexShrink={1}>
278          {shownAgents.length === 0 ? <Text dimColor>None this session</Text> : null}
279          {shownAgents.map(a => (
280            <Box key={a.id} gap={1}>
281              <Text color={agentColor(a)}>{agentGlyph(a)}</Text>
282              <Text>{a.type}</Text>
283              <Text dimColor tabular>{since(a.startedAt, a.endedAt ?? now)}</Text>
284            </Box>
285          ))}
286          {p.agents.length > shownAgents.length ? <Text dimColor>{`+${p.agents.length - shownAgents.length} more`}</Text> : null}
287        </Box>
288      </Box>
289    )
290
291    return stacked(
292      <Box flexDirection="column">
293        {summary}
294        <Box flexDirection="column" marginTop={1} marginLeft={2}>
295          {toolRow}
296          {agentRow}
297        </Box>
298      </Box>
299    )
300  })
301
302  // The pane: the session at a glance, then tools as a table, the latest
303  // calls, agents and todos.
304  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305    const { Box, Text } = $.ui.resolve(e)
306    const p = await readPulse($)
307    const now = await $.clock.now()
308    const width = Math.max(40, e.props.bodyColumns)
309
310    const calls = p.tools.reduce((n, t) => n + t.calls, 0)
311    const errors = p.tools.reduce((n, t) => n + t.errors, 0)
312    const done = p.todos.filter(t => t.status === 'completed').length
313    const running = p.agents.filter(a => a.endedAt === null).length
314    const sorted = [...p.tools].sort((a, b) => b.calls - a.calls)
315    const maxCalls = Math.max(1, ...sorted.map(t => t.calls))
316    const isWorking = p.turnStartedAt !== null
317
318    // Columns: name, share bar, calls, failed, avg, last; the bar takes what is left.
319    const nameW = Math.min(20, Math.max(6, ...sorted.map(t => t.name.length), ...p.recent.map(r => r.tool.length)))
320    const barW = Math.max(4, Math.min(24, width - (nameW + 6 + 7 + 7 + 9) - 10))
321    const cell = (w: number, child: RenderElement, right = false) => (
322      <Box width={w} flexShrink={0} justifyContent={right ? 'flex-end' : 'flex-start'}>{child}</Box>
323    )
324    const section = (title: string, note: string) => (
325      <Box gap={2}>
326        <Text bold>{title}</Text>
327        <Text dimColor>{note}</Text>
328      </Box>
329    )
330    const stat = (value: string | number, unit: string, color?: 'error' | 'warning' | 'claude') => (
331      <Box gap={1}>
332        <Text bold tabular color={color}>{String(value)}</Text>
333        <Text dimColor>{unit}</Text>
334      </Box>
335    )
336
337    const header = (
338      <Box flexDirection="column">
339        <Box gap={1}>
340          <Text color={isWorking ? 'claude' : 'subtle'}>{isWorking ? '◆' : '◇'}</Text>
341          <Text bold>{isWorking ? 'Working' : 'Idle'}</Text>
342          {isWorking ? <Text dimColor tabular>{`turn ${since(p.turnStartedAt as number, now)}`}</Text> : null}
343        </Box>
344        <Box gap={3} flexWrap="wrap" marginLeft={2}>
345          {stat(p.turns, p.turns === 1 ? 'turn' : 'turns')}
346          {stat(calls, calls === 1 ? 'call' : 'calls')}
347          {errors > 0 ? stat(errors, 'failed', 'error') : null}
348          {stat(running, 'agents running', running > 0 ? 'warning' : undefined)}
349          {p.todos.length > 0 ? stat(`${done}/${p.todos.length}`, 'todos') : null}
350        </Box>
351      </Box>
352    )
353
354    const toolTable = (
355      <Box flexDirection="column">
356        {section('Tools', calls === 0 ? 'None yet' : 'By calls, this session')}
357        {sorted.length > 0 ? (
358          <Box gap={1}>
359            {cell(nameW, <Text dimColor>Tool</Text>)}
360            {cell(barW, <Text dimColor>Share</Text>)}
361            {cell(6, <Text dimColor>Calls</Text>, true)}
362            {cell(7, <Text dimColor>Failed</Text>, true)}
363            {cell(7, <Text dimColor>Avg</Text>, true)}
364            {cell(9, <Text dimColor>Last</Text>, true)}
365          </Box>
366        ) : null}
367        {sorted.map(t => {
368          const finished = Math.max(0, t.calls - t.busy)
369          const filled = Math.max(1, Math.round((t.calls / maxCalls) * barW))
370          return (
371            <Box key={t.name} gap={1}>
372              {cell(nameW, <Text color={t.busy > 0 ? 'claude' : 'text'} wrap="truncate-end">{t.name}</Text>)}
373              {cell(barW, (
374                <Box>
375                  <Text color={t.errors > 0 ? 'warning' : 'claude'}>{'█'.repeat(filled)}</Text>
376                  <Text dimColor>{'░'.repeat(barW - filled)}</Text>
377                </Box>
378              ))}
379              {cell(6, <Text tabular>{String(t.calls)}</Text>, true)}
380              {cell(7, t.errors > 0 ? <Text color="error" tabular>{String(t.errors)}</Text> : <Text dimColor>–</Text>, true)}
381              {cell(7, <Text dimColor tabular>{finished > 0 ? ms(t.ms / finished) : '–'}</Text>, true)}
382              {cell(9, t.busy > 0 ? <Text color="claude">running</Text> : <Text dimColor tabular>{t.lastAt !== null ? `${since(t.lastAt, now)} ago` : '–'}</Text>, true)}
383            </Box>
384          )
385        })}
386      </Box>
387    )
388
389    const latest = [...p.recent].reverse().slice(0, RECENT_SHOWN)
390    const recentList = (
391      <Box flexDirection="column">
392        {section('Recent', latest.length === 0 ? 'No calls yet' : `Last ${latest.length} of ${calls}, newest first`)}
393        {latest.map(r => (
394          <Box key={r.id} gap={1}>
395            <Text color={r.endedAt === null ? 'claude' : r.isError ? 'error' : 'success'}>{r.endedAt === null ? '◆' : r.isError ? '✗' : '✓'}</Text>
396            {cell(nameW, <Text wrap="truncate-end">{r.tool}</Text>)}
397            <Box flexGrow={1} flexShrink={1}>
398              <Text dimColor wrap="truncate-end">{r.detail || '–'}</Text>
399            </Box>
400            {cell(7, r.endedAt === null ? <Text color="claude">running</Text> : <Text dimColor tabular>{ms(r.endedAt - r.startedAt)}</Text>, true)}
401            {cell(9, <Text dimColor tabular>{`${since(r.startedAt, now)} ago`}</Text>, true)}
402          </Box>
403        ))}
404      </Box>
405    )
406
407    const agentList = [...p.agents].sort(byLive)
408    const typeW = Math.min(20, Math.max(6, ...agentList.map(a => a.type.length)))
409    const agentSection = (
410      <Box flexDirection="column">
411        {section('Agents', agentList.length === 0 ? 'None this session' : `${running} running · ${agentList.length - running} done`)}
412        {agentList.map(a => (
413          <Box key={a.id} gap={1}>
414            <Text color={agentColor(a)}>{agentGlyph(a)}</Text>
415            {cell(typeW, <Text bold wrap="truncate-end">{a.type}</Text>)}
416            <Box flexGrow={1} flexShrink={1}>
417              <Text dimColor wrap="truncate-end">{a.description}</Text>
418            </Box>
419            {cell(9, a.endedAt === null ? <Text color="warning" tabular>{since(a.startedAt, now)}</Text> : <Text dimColor tabular>{since(a.startedAt, a.endedAt)}</Text>, true)}
420          </Box>
421        ))}
422      </Box>
423    )
424
425    const todoSection = (
426      <Box flexDirection="column">
427        {section('Todos', p.todos.length === 0 ? 'No list yet' : `${done} of ${p.todos.length} done`)}
428        {p.todos.length > 0 ? <Text color={done === p.todos.length ? 'success' : 'claude'} tabular>{bar(done, p.todos.length, Math.min(32, width - 4))}</Text> : null}
429        {p.todos.map((t, i) => (
430          <Box key={`${i}-${t.content}`} gap={1}>
431            <Text color={t.status === 'completed' ? 'success' : t.status === 'in_progress' ? 'claude' : 'subtle'}>{t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▶' : '·'}</Text>
432            <Text dimColor={t.status === 'completed'} strikethrough={t.status === 'completed'} wrap="wrap">{t.status === 'in_progress' ? t.activeForm || t.content : t.content}</Text>
433          </Box>
434        ))}
435      </Box>
436    )
437
438    return (
439      <Box flexDirection="column" gap={1}>
440        {header}
441        {toolTable}
442        {recentList}
443        {agentSection}
444        {todoSection}
445      </Box>
446    )
447  })
448}
449
types/index.d.ts 20 lines
1export type ToolStat = { name: string; calls: number; errors: number; busy: number; ms: number; lastAt: number | null }
2export type CallRow = { id: string; tool: string; detail: string; startedAt: number; endedAt: number | null; isError: boolean }
3export type AgentRun = { id: string; type: string; description: string; startedAt: number; endedAt: number | null; isError: boolean }
4export type Todo = { content: string; activeForm: string; status: 'pending' | 'in_progress' | 'completed' }
5export type Pulse = {
6  tools: ToolStat[]
7  lastTool: string | null
8  agents: AgentRun[]
9  todos: Todo[]
10  turnStartedAt: number | null
11  recent: CallRow[]
12  turns: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    pulse: { pulse: Pulse; collapsed: boolean; hidden: boolean }
18  }
19}
20