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

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.
/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.e) shows just the failed and denied calls, with the reason inline. Clear (c) starts over and Close (x) closes the pane.↳ before the tool./radar summary prints a text report: counts per tool, the recent failures with their reasons, and the five slowest calls./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install tool-radar@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
| Command | What it does |
|---|---|
/radar | Open the pane. Works mid-turn. |
/radar summary | Print counts, failures and the slowest calls |
/radar clear | Forget every call so far |
/radar close | Close the pane |
In the pane, press ctrl+x tab to give it the keyboard, then c, e or x.
| Option | Default | What it does |
|---|---|---|
autoOpen | false | Open 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). |
| Event / API | Why |
|---|---|
tool.call (every tool) | Records the call before next(e) and its outcome after: deny, isError, or done |
$.clock.now | Times 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 Pane | Draws the list |
command.run | /radar and its subcommands |
The pane keeps the latest 300 calls. The totals count every call.
claude plugin test mods/awareness/tool-radar # 20 tests
…, 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.hooks/register.tsx 142 lines1import { 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}
142hooks/format.ts 192 lines1// 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}
192types/index.d.ts 40 lines1export 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