SLOPSHOPPER

tool-radar

Mission control for what Claude is doing: a live pane of every tool call with status, arguments, duration and failures, plus /radar summary.

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-radar
│ ┃ Tool radar ✕ › fix the failing auth test and add an audit log call │ ┃ 9 calls · 1 failed · avg 20ms · Bash 4 Writ… │ ┃ [ Clear ] [ Errors only ] [ Close ] ⏺ Read(src/auth.ts) │ ┃ ✓ Bash cat .env ⎿ Read 6 lines │ ┃ ✓ Bash rm -rf build && git push --fo ⏺ Update(src/auth.ts) │ ┃ ✓ Bash git status --porcelain ⎿ Added 2 lines, removed 1 line │ ┃ ✗ Bash bun test — src/auth.test.ts: ⏺ Bash(bun test) │ ┃ ✓ Write src/cache.ts ⎿ 3 pass, 1 fail │ ┃ ✓ Write src/audit.ts │ ┃ ✓ Edit src/auth.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Grep refresh\( in app/src │ ┃ ✓ Read src/auth.ts ✻ Worked for 42s · done 4:20 PM │ │ › /radar │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tool radar
9 calls · 1 failed · avg 20ms · Bash 4 Write 2 Edit 1 [ Clear ] [ Errors only ] [ Close ] ✓ Bash cat .env 20ms ✓ Bash rm -rf build && git push --force … 20ms ✓ Bash git status --porcelain 20ms ✗ Bash bun test — src/auth.test.ts: ✓ re… 20ms ✓ Write src/cache.ts 20ms ✓ Write src/audit.ts 20ms ✓ Edit src/auth.ts 20ms ✓ Grep refresh\( in app/src 20ms ✓ Read src/auth.ts 20ms
README

📡 tool-radar

Mission control for what Claude is doing: every tool call, live, with its status, arguments, duration and why it failed.

<img src="../../../assets/screens/tool-radar.svg" alt="tool-radar in a real Claude Code session" width="100%">

╭─ Tool radar ──────────────────────────────────────────────╮
│ 42 calls · 3 failed · 1 running · avg 1.2s · Bash 20 Edit 12 Read 8
│ [ Clear ] [ Errors only ] [ Close ]                        │
│ ◐ Bash         npm run test -- --watch=false            …  │
│ ✓ Edit         hooks/register.ts                     38ms  │
│ ✗ Bash         pytest -q — exit code 1: 2 failed     4.1s  │
│ ✓ ↳ Read       src/auth.ts                           12ms  │
│ ⊘ Bash         git push --force — blocked by policy   0ms  │
│ ✓ mcp:github   search_issues is:open label:bug       1.9s  │
╰────────────────────────────────────────────────────────────╯

When Claude is a few dozen tool calls into a task, the transcript scrolls by too fast to follow. tool-radar keeps one compact, newest-first list of every call: what it ran, how long it took, and which calls failed or were denied. It covers subagents and MCP tools too.

Features

  • Live pane (/radar): one row per call with a status glyph (◐ running, ✓ done, ✗ failed, ⊘ denied), the tool, a short look at its arguments (the command, the file, the pattern, the URL, the subagent's task, the MCP tool), and the duration.
  • Header summary: total calls, failures, denials, how many are running, the average duration, and your busiest tools.
  • Errors only toggle (e) shows just the failed and denied calls, with the reason inline. Clear (c) starts over and Close (x) closes the pane.
  • Subagents marked: a call a subagent made shows ↳ before the tool.
  • /radar summary prints a text report: counts per tool, the recent failures with their reasons, and the five slowest calls.
  • Fits the pane: rows are sized to the pane's width, and a docked pane shows as many rows as it has room for.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install tool-radar@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Usage

CommandWhat it does
/radarOpen the pane. Works mid-turn.
/radar summaryPrint counts, failures and the slowest calls
/radar clearForget every call so far
/radar closeClose the pane

In the pane, press ctrl+x tab to give it the keyboard, then c, e or x.

Configuration

OptionDefaultWhat it does
autoOpenfalseOpen the pane when a session starts. Claude Code only seats a pane it wasn't asked for in a terminal at least 144 columns wide (110 once you've opened it yourself).

How it works

Event / APIWhy
tool.call (every tool)Records the call before next(e) and its outcome after: deny, isError, or done
$.clock.nowTimes each call
$.state (calls, totals, errorsOnly)Holds the list across hot reloads, redraws the pane on every change, and resets on /clear
ui.render on PaneDraws the list
command.run/radar and its subcommands

The pane keeps the latest 300 calls. The totals count every call.

Test it

claude plugin test mods/awareness/tool-radar   # 20 tests

Limitations

  • A call's duration includes any time it waited on your permission prompt.
  • A running call shows …, not a ticking timer. The pane redraws when a call starts or ends.
  • $.state resets on /clear, /resume and /branch, so the radar starts empty there too.
Source 3 files
hooks/register.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RadarCall, RadarStatus } from '../types'
5import { countCall, emptyTotals, headerLine, preview, rowLine, summaryText } from './format'
6
7const PANE = 'tool-radar'
8const TITLE = 'Tool radar'
9// How many calls the pane remembers; the totals count every call.
10const CAP = 300
11
12const calls = atom({ plugin: 'tool-radar', key: 'calls' } as const, [])
13const totals = atom({ plugin: 'tool-radar', key: 'totals' } as const, emptyTotals())
14const errorsOnly = atom({ plugin: 'tool-radar', key: 'errorsOnly' } as const, false)
15
16// Ids for calls that carry no tool_use_id (a plugin's own $.tool.call).
17let seq = 0
18
19async function settle($: EngineInterface, id: string, tool: string, status: RadarStatus, startedAt: number, error?: string) {
20  const ms = Math.max(0, (await $.clock.now()) - startedAt)
21  const cut = error === undefined ? undefined : error.slice(0, 300)
22  await update($, calls, list =>
23    list.map(call => (call.id === id ? { ...call, status, ms, ...(cut === undefined ? {} : { error: cut }) } : call)),
24  )
25  await update($, totals, t => countCall(t, tool, status, ms))
26}
27
28async function clearAll($: EngineInterface) {
29  await update($, calls, () => [])
30  await update($, totals, () => emptyTotals())
31}
32
33export const register: Register = (on, options) => {
34  on('session.start', async ($, e, next) => {
35    await $.command.register({
36      name: 'radar',
37      description: 'Tool radar: open the live pane, or /radar summary | clear | close',
38      argumentHint: '[summary|clear|close]',
39      immediate: true,
40    })
41    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
42    return next(e)
43  })
44
45  on('command.run', { command: 'radar' }, async ($, e) => {
46    const arg = e.args.trim().toLowerCase()
47    if (arg === 'summary' || arg === 'stats') {
48      return { text: summaryText(await read($, totals), await read($, calls)) }
49    }
50    if (arg === 'clear' || arg === 'reset') {
51      await clearAll($)
52      return { text: 'Cleared the radar.' }
53    }
54    if (arg === 'close') {
55      await $.ui.close({ id: PANE })
56      return {}
57    }
58    const opened = await $.ui.open({ id: PANE, title: TITLE })
59    return opened.isPlaced ? {} : { text: `The radar pane is waiting for room: ${opened.reason}.` }
60  })
61
62  on('tool.call', async ($, e, next) => {
63    const tool = String(e.tool)
64    seq += 1
65    const id = e.tool_use_id ?? `radar-${seq}`
66    const startedAt = await $.clock.now()
67    const call: RadarCall = {
68      id,
69      tool,
70      preview: preview(tool, e as unknown as Record<string, unknown>),
71      status: 'running',
72      startedAt,
73      ...(e.agentId === undefined ? {} : { agent: e.agentId }),
74    }
75    await update($, calls, list => [...list, call].slice(-CAP))
76
77    let ran
78    try {
79      ran = await next(e)
80    } catch (error) {
81      await settle($, id, tool, 'failed', startedAt, 'interrupted')
82      throw error
83    }
84    if (ran.deny !== undefined) await settle($, id, tool, 'denied', startedAt, ran.deny)
85    else if (ran.isError === true) await settle($, id, tool, 'failed', startedAt, ran.text ?? 'the tool reported an error')
86    else await settle($, id, tool, 'done', startedAt)
87
88    return ran
89  })
90
91  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92    const { Box, Button, Text } = $.ui.resolve(e)
93    const list = await read($, calls)
94    const sums = await read($, totals)
95    const onlyErrors = await read($, errorsOnly)
96
97    // Two columns of slack: a docked pane draws a little narrower than bodyColumns says.
98    const width = Math.max(24, e.props.bodyColumns - 2)
99    const running = list.filter(call => call.status === 'running').length
100    // Docked beside the transcript the pane has a fixed height; inline it grows.
101    const room = e.props.placement === 'dock' ? Math.max(5, e.props.scroll.bodyRows - 3) : 12
102    const shown = list
103      .filter(call => !onlyErrors || call.status === 'failed' || call.status === 'denied')
104      .slice(-room)
105      .reverse()
106
107    return (
108      <Box flexDirection="column">
109        <Text bold wrap="truncate-end">
110          {headerLine(sums, running, width)}
111        </Text>
112        <Box flexDirection="row" columnGap={1}>
113          <Button key="clear" label="Clear" hotkey="c" onPress={() => clearAll($)} />
114          <Button
115            key="errors"
116            label={onlyErrors ? 'All calls' : 'Errors only'}
117            hotkey="e"
118            onPress={() => update($, errorsOnly, value => !value)}
119          />
120          <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
121        </Box>
122        {shown.length === 0 && (
123          <Text key="empty" dimColor>
124            {onlyErrors ? 'No failed or denied calls.' : 'No tool calls yet. They show here as Claude works.'}
125          </Text>
126        )}
127        {shown.map(call => {
128          const row = rowLine(call, width)
129          return (
130            <Box key={`row-${call.id}`} flexDirection="row">
131              <Text color={row.color}>{row.glyph} </Text>
132              <Text color={row.color} dimColor={call.status === 'done'} wrap="truncate-end">
133                {row.text}
134              </Text>
135            </Box>
136          )
137        })}
138      </Box>
139    )
140  })
141}
142
hooks/format.ts 192 lines
1// Pure helpers: no `$`, so the tests and other mods can import them.
2
3import type { RadarCall, RadarStatus, RadarTotals } from '../types'
4
5export const GLYPH: Record<RadarStatus, string> = {
6  running: '◐',
7  done: '✓',
8  failed: '✗',
9  denied: '⊘',
10}
11
12export const COLOR: Record<RadarStatus, string | undefined> = {
13  running: 'yellow',
14  done: undefined,
15  failed: 'red',
16  denied: 'magenta',
17}
18
19export function emptyTotals(): RadarTotals {
20  return { calls: 0, failed: 0, denied: 0, totalMs: 0, byTool: {} }
21}
22
23/** `mcp__github__search_issues` → `{ server: 'github', name: 'search_issues' }`. */
24export function mcpParts(tool: string): { server: string; name: string } | undefined {
25  if (!tool.startsWith('mcp__')) return undefined
26  const [, server = '', ...rest] = tool.split('__')
27  return { server, name: rest.join('__') }
28}
29
30/** The short name a call is counted and listed under: `Bash`, `mcp:github`. */
31export function toolLabel(tool: string): string {
32  const mcp = mcpParts(tool)
33  return mcp === undefined ? tool : `mcp:${mcp.server}`
34}
35
36function oneLine(text: string): string {
37  return text.replace(/\s+/g, ' ').trim()
38}
39
40function shortPath(path: string): string {
41  const parts = path.split(/[\\/]/).filter(p => p !== '')
42  return parts.slice(-2).join('/') || path
43}
44
45function str(value: unknown): string | undefined {
46  return typeof value === 'string' && value !== '' ? value : undefined
47}
48
49/** A one-line look at a call's arguments, at most 200 characters. */
50export function preview(tool: string, input: Readonly<Record<string, unknown>>): string {
51  const mcp = mcpParts(tool)
52  let text: string
53  switch (tool) {
54    case 'Bash':
55      text = str(input.command) ?? ''
56      break
57    case 'Read':
58    case 'Edit':
59    case 'MultiEdit':
60    case 'Write':
61      text = shortPath(str(input.file_path) ?? '')
62      break
63    case 'NotebookEdit':
64      text = shortPath(str(input.notebook_path) ?? '')
65      break
66    case 'Grep':
67    case 'Glob':
68      text = [str(input.pattern), str(input.path) && `in ${shortPath(String(input.path))}`].filter(Boolean).join(' ')
69      break
70    case 'WebFetch':
71      text = (str(input.url) ?? '').replace(/^https?:\/\//, '')
72      break
73    case 'WebSearch':
74    case 'ToolSearch':
75      text = str(input.query) ?? ''
76      break
77    case 'Agent':
78    case 'Task':
79      text = [str(input.subagent_type) && `[${String(input.subagent_type)}]`, str(input.description)].filter(Boolean).join(' ')
80      break
81    case 'Skill':
82      text = str(input.skill) ?? str(input.name) ?? ''
83      break
84    case 'TodoWrite':
85      text = Array.isArray(input.todos) ? `${input.todos.length} todos` : ''
86      break
87    default: {
88      const first = Object.entries(input).find(([key, value]) => key !== 'tool' && key !== 'tool_use_id' && key !== 'agentId' && typeof value === 'string')
89      const arg = first === undefined ? '' : String(first[1])
90      text = mcp === undefined ? arg : `${mcp.name} ${arg}`.trim()
91    }
92  }
93  return oneLine(text).slice(0, 200)
94}
95
96export function formatDuration(ms: number): string {
97  if (ms < 1000) return `${Math.round(ms)}ms`
98  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
99  const minutes = Math.floor(ms / 60_000)
100  const seconds = Math.round((ms % 60_000) / 1000)
101  return `${minutes}m${String(seconds).padStart(2, '0')}s`
102}
103
104/** Adds one finished call to the running totals. */
105export function countCall(totals: RadarTotals, tool: string, status: RadarStatus, ms: number): RadarTotals {
106  const label = toolLabel(tool)
107  return {
108    calls: totals.calls + 1,
109    failed: totals.failed + (status === 'failed' ? 1 : 0),
110    denied: totals.denied + (status === 'denied' ? 1 : 0),
111    totalMs: totals.totalMs + ms,
112    byTool: { ...totals.byTool, [label]: (totals.byTool[label] ?? 0) + 1 },
113  }
114}
115
116/** Tools by count, most used first. */
117export function topTools(totals: RadarTotals): Array<[string, number]> {
118  return Object.entries(totals.byTool).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
119}
120
121function fit(text: string, width: number): string {
122  if (width <= 0) return ''
123  if (text.length <= width) return text
124  return width === 1 ? '…' : `${text.slice(0, width - 1)}…`
125}
126
127/** `42 calls · 3 failed · 1 running · avg 1.2s · Bash 20 Edit 12`, cut to `width`. */
128export function headerLine(totals: RadarTotals, running: number, width: number): string {
129  const parts = [`${totals.calls + running} call${totals.calls + running === 1 ? '' : 's'}`]
130  if (totals.failed > 0) parts.push(`${totals.failed} failed`)
131  if (totals.denied > 0) parts.push(`${totals.denied} denied`)
132  if (running > 0) parts.push(`${running} running`)
133  if (totals.calls > 0) parts.push(`avg ${formatDuration(totals.totalMs / totals.calls)}`)
134  let line = parts.join(' · ')
135  const tools = topTools(totals)
136  if (tools.length > 0) {
137    let tail = ''
138    for (const [label, count] of tools) {
139      const next = `${tail}${tail === '' ? '' : ' '}${label} ${count}`
140      if (line.length + 3 + next.length > width) break
141      tail = next
142    }
143    if (tail !== '') line = `${line} · ${tail}`
144  }
145  return fit(line, width)
146}
147
148/**
149 * One row of the list, as `glyph` and the `text` after it, `width` cells in
150 * all: `✓ Bash      npm test                     1.2s`.
151 */
152export function rowLine(call: RadarCall, width: number): { glyph: string; color: string | undefined; text: string } {
153  const room = Math.max(10, width - 2)
154  const nested = call.agent === undefined ? '' : '↳ '
155  const name = fit(`${nested}${toolLabel(call.tool)}`, 12).padEnd(12)
156  const time = call.status === 'running' ? '…' : formatDuration(call.ms ?? 0)
157  const rest = room - name.length - 1 - time.length - 1
158  const detail = call.status === 'failed' || call.status === 'denied'
159    ? [call.preview, call.error && `— ${oneLine(call.error)}`].filter(Boolean).join(' ')
160    : call.preview
161  const middle = rest > 0 ? fit(detail, rest).padEnd(rest) : ''
162  const text = rest > 0 ? `${name} ${middle} ${time}` : fit(`${name} ${time}`, room)
163
164  return { glyph: GLYPH[call.status], color: COLOR[call.status], text }
165}
166
167/** The text `/radar summary` prints. */
168export function summaryText(totals: RadarTotals, calls: readonly RadarCall[]): string {
169  const running = calls.filter(c => c.status === 'running').length
170  if (totals.calls + running === 0) return 'No tool calls yet this session.'
171
172  const lines = [headerLine({ ...totals, byTool: {} }, running, 200)]
173  const tools = topTools(totals)
174  if (tools.length > 0) lines.push(`By tool: ${tools.map(([label, count]) => `${label} ${count}`).join(' · ')}`)
175
176  const failures = calls.filter(c => c.status === 'failed' || c.status === 'denied').slice(-8)
177  if (failures.length > 0) {
178    lines.push('', 'Failures (newest last):')
179    for (const c of failures) {
180      const why = c.error === undefined ? '' : ` — ${fit(oneLine(c.error), 100)}`
181      lines.push(`  ${GLYPH[c.status]} ${toolLabel(c.tool)} ${fit(c.preview, 60)}${why}`)
182    }
183  }
184
185  const slowest = calls.filter(c => c.ms !== undefined).sort((a, b) => (b.ms ?? 0) - (a.ms ?? 0)).slice(0, 5)
186  if (slowest.length > 0) {
187    lines.push('', 'Slowest:')
188    for (const c of slowest) lines.push(`  ${formatDuration(c.ms ?? 0).padStart(7)}  ${toolLabel(c.tool)} ${fit(c.preview, 70)}`)
189  }
190  return lines.join('\n')
191}
192
types/index.d.ts 40 lines
1export type RadarStatus = 'running' | 'done' | 'failed' | 'denied'
2
3export type RadarCall = {
4  /** The tool_use id, or one the mod made up for a call that carried none. */
5  id: string
6  /** The tool's name as the model called it (`Bash`, `mcp__github__search`). */
7  tool: string
8  /** A short look at the arguments: the command, the file, the pattern. */
9  preview: string
10  /** The subagent's id when a subagent made the call; absent on the main loop. */
11  agent?: string
12  status: RadarStatus
13  startedAt: number
14  /** How long the call took, permission prompts included; absent while it runs. */
15  ms?: number
16  /** Why it failed or was denied, cut short. */
17  error?: string
18}
19
20export type RadarTotals = {
21  /** Calls that have finished, whatever the outcome. */
22  calls: number
23  failed: number
24  denied: number
25  /** Total milliseconds over the finished calls, for the average. */
26  totalMs: number
27  /** Finished calls per tool label (`Bash`, `mcp:github`). */
28  byTool: Record<string, number>
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'tool-radar': {
34      calls: RadarCall[]
35      totals: RadarTotals
36      errorsOnly: boolean
37    }
38  }
39}
40