Side pane with one little robot per agent (main and subagents); press one to see its conversation

A Claude Code mod that shows, in a side pane, one little robot for each agent in the session: the main agent and every subagent it launched. Press a robot and its conversation appears below, in the same pane.
✦
▗▄▄█▄▄▖ [ Main ]
▐ ● ● ▌ Main agent
▝▀▙▄▟▀▘ ▶ working
✦
▗▄▄█▄▄▖ [ Explore ]
▐ ● ● ▌ find endpoints
▝▀▙▄▟▀▘ working · 👁 on screen
╻
▗▄▄█▄▄▖ [ general-purpose ]
▝▘▐ ^ ^ ▌▝▘ migrate tests
▝▀▙▄▟▀▘ finished
Press a robot and the robots shrink to one line each, with its conversation below:
▐●●▌ [ Main ] working
▐●●▌ [ Explore ] ▶ working · 👁 on screen
▐^^▌ [ general-purpose ] finished
────────────────────────────────────────
▐●●▌ find endpoints [ Close ]
» Find the endpoints
› Grep MapGet
Found 14 endpoints…
Requires Claude Code with mod support (tested on the latest version).
From a Claude Code session in the terminal:
/plugin install agent-dolls --marketplace visiblesoft-es/claude-mods
Answer y to add the marketplace and pick a scope (the user scope is the first one). The mod is active as soon as it is installed, with no restart.
Or from your shell:
claude plugin marketplace add visiblesoft-es/claude-mods
claude plugin install agent-dolls@claude-mods
To try it from a local clone, without installing:
git clone https://github.com/visiblesoft-es/claude-mods.git
claude --plugin-dir claude-mods/agent-dolls
To update or uninstall, see the repository README.
/dolls opens the pane. (It is not /agents, which is Claude Code's own command for managing subagents.)● ● while working (with a blinking antenna), – – idle, ^ ^ with raised arms when finished, × × when failed. Its color says the agent's type: Claude orange for the main agent, cyan for Explore, magenta for Plan, yellow for general-purpose. Subagents are indented under the agent that launched them.hooks/register.tsx the pane's logic and drawing
hooks/lib/robot.ts the robots, as pure functions
types/index.d.ts the mod's state ($.state) contract
tests/agent-dolls.test.tsx
tests/robot.test.ts
claude plugin validate .
claude plugin test .
When Claude Code loads the mod it writes the API types into .claude-plugin/types/ (ignored by the repository's .gitignore); with them, tsc -p . type-checks the mod.
Built on Claude Code's mod API, which is in early access and may change between releases.
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Detail, Doll, Line } from '../types'
5import { miniRobot, robot } from './lib/robot'
6
7const LIST = 'agent-dolls'
8const MAIN = 'main'
9const POLL_MS = 1500
10const MAX_LINES = 60
11
12const dolls = atom({ plugin: 'agent-dolls', key: 'dolls' } as const, [])
13const isMainBusy = atom({ plugin: 'agent-dolls', key: 'isMainBusy' } as const, false)
14const selected = atom({ plugin: 'agent-dolls', key: 'selected' } as const, null)
15const detail = atom({ plugin: 'agent-dolls', key: 'detail' } as const, null)
16const frame = atom({ plugin: 'agent-dolls', key: 'frame' } as const, 0)
17
18const STATUS: Record<string, { label: string; color: string }> = {
19 running: { label: 'working', color: 'success' },
20 pending: { label: 'starting', color: 'warning' },
21 waiting: { label: 'waiting', color: 'warning' },
22 idle: { label: 'idle', color: 'subtle' },
23 completed: { label: 'finished', color: 'subtle' },
24 failed: { label: 'failed', color: 'error' },
25 killed: { label: 'stopped', color: 'error' },
26}
27
28function toDoll(agent: AgentInfo): Doll {
29 return {
30 id: agent.id,
31 type: agent.type,
32 description: agent.description,
33 status: agent.status,
34 parentId: agent.parentId,
35 }
36}
37
38function clip(text: string, max: number): string {
39 const flat = text.replace(/\s+/g, ' ').trim()
40 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
41}
42
43function briefInput(input: Record<string, unknown>): string {
44 const first = ['description', 'command', 'file_path', 'pattern', 'path', 'url', 'prompt']
45 .map(k => input[k])
46 .find(v => typeof v === 'string')
47 return typeof first === 'string' ? first : ''
48}
49
50function toLines(messages: SessionMessage[]): Line[] {
51 const lines: Line[] = []
52 for (const m of messages) {
53 if (m.text) lines.push({ role: m.role, text: m.text })
54 for (const use of m.toolUses) {
55 lines.push({
56 role: 'tool',
57 text: `${use.tool} ${briefInput(use.input)}`.trim(),
58 isError: use.isError === true,
59 })
60 }
61 }
62 return lines.slice(-MAX_LINES)
63}
64
65function same(a: unknown, b: unknown): boolean {
66 return JSON.stringify(a) === JSON.stringify(b)
67}
68
69async function refresh($: EngineInterface) {
70 const next = (await $.agent.list()).map(toDoll)
71 if (!same(next, await read($, dolls))) await update($, dolls, () => next)
72 // Only a working robot animates, so only then is it worth a redraw.
73 const isAnyWorking = (await read($, isMainBusy)) || next.some(d => d.status === 'running')
74 if (isAnyWorking) await update($, frame, n => n + 1)
75
76 const id = await read($, selected)
77 if (id === null || id === MAIN) return
78 const answer = await $.session.messages({ agentId: id })
79 const fresh: Detail = Array.isArray(answer)
80 ? { agentId: id, lines: toLines(answer) }
81 : { agentId: id, lines: (await read($, detail))?.lines ?? [], deny: answer.deny }
82 if (!same(fresh, await read($, detail))) await update($, detail, () => fresh)
83}
84
85async function select($: EngineInterface, doll: Doll | null) {
86 const isSame = doll !== null && (await read($, selected)) === doll.id
87 if (doll === null || isSame) {
88 await update($, selected, () => MAIN)
89 await update($, detail, () => null)
90 return
91 }
92 await update($, selected, () => doll.id)
93 await update($, detail, () => ({ agentId: doll.id, lines: [] }))
94 await refresh($)
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await $.command.register({
100 name: 'dolls',
101 description: 'Open the pane with one doll for the main agent and each subagent',
102 })
103 $.clock.every(POLL_MS, () => void refresh($))
104
105 return next(e)
106 })
107
108 on('command.run', { command: 'dolls' }, async $ => {
109 await refresh($)
110 await $.ui.open({ id: LIST, title: 'Agents' })
111
112 return { text: 'Agents pane opened.' }
113 })
114
115 on('prompt.submit', async ($, e, next) => {
116 await update($, isMainBusy, () => true)
117 return next(e)
118 })
119
120 on('turn.complete', async ($, e, next) => {
121 if (e.agentId === undefined) await update($, isMainBusy, () => false)
122 void refresh($)
123 return next(e)
124 })
125
126 on('agent.spawn', async ($, e, next) => {
127 const spawned = await next(e)
128 void refresh($)
129 return spawned
130 })
131
132 on('ui.close', async ($, e, next) => {
133 if (e.id === LIST) await select($, null)
134 return next(e)
135 })
136
137 // A single pane: the terminal shows one mod pane at a time (the rest become
138 // tabs), so the robots and the conversation share it.
139 on('ui.render', { component: 'Pane', requestId: LIST }, async ($, e) => {
140 const { Box, Text, Button } = $.ui.resolve(e)
141 const list = await read($, dolls)
142 const current = (await read($, selected)) ?? MAIN
143 const tick = await read($, frame)
144 const shown = await read($, detail)
145 const viewed = e.props.view.agentId ?? MAIN
146 const main: Doll = {
147 id: MAIN,
148 type: 'main',
149 description: 'Main agent',
150 status: (await read($, isMainBusy)) ? 'running' : 'idle',
151 }
152 const columns = e.props.bodyColumns ?? 40
153 const width = Math.max(12, columns - 6)
154 const isCompact = shown !== null
155 const depth = (d: Doll): number =>
156 d.parentId ? 1 + depth(list.find(p => p.id === d.parentId) ?? { ...d, parentId: undefined }) : 1
157
158 const dollRow = (d: Doll) => {
159 const s = STATUS[d.status] ?? { label: d.status, color: 'subtle' }
160 const isCurrent = d.id === current
161 const indent = d.id === MAIN ? 0 : Math.min(depth(d), 3) * 2
162 const pick = (
163 <Button
164 key={`pick-${d.id}`}
165 label={d.id === MAIN ? 'Main' : d.type}
166 variant={isCurrent ? 'primary' : 'secondary'}
167 onPress={() => select($, d.id === MAIN ? null : d)}
168 />
169 )
170 const state = (
171 <Text color={s.color}>
172 {isCurrent ? '▶ ' : ''}
173 {s.label}
174 {d.id === viewed ? ' · 👁 on screen' : ''}
175 </Text>
176 )
177
178 if (isCompact) {
179 const mini = miniRobot(d.type, d.status, tick)
180 return (
181 <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
182 <Text color={mini.color} dimColor={mini.isDim} bold={isCurrent}>{mini.rows[0]}</Text>
183 {pick}
184 {state}
185 </Box>
186 )
187 }
188
189 const bot = robot(d.type, d.status, tick)
190 return (
191 <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
192 <Box flexDirection="column" flexShrink={0}>
193 {bot.rows.map((row, i) => (
194 <Text key={`r${i}`} color={bot.color} dimColor={bot.isDim} bold={isCurrent}>
195 {row}
196 </Text>
197 ))}
198 </Box>
199 <Box flexDirection="column" marginTop={1}>
200 {pick}
201 <Text dimColor wrap="truncate-end">{clip(d.description, width)}</Text>
202 {state}
203 </Box>
204 </Box>
205 )
206 }
207
208 const agents = [main, ...list]
209 const dollRows = isCompact ? agents.length : agents.length * 5
210 const room = Math.max(3, (e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30) - dollRows - 4)
211 const owner = shown ? list.find(d => d.id === shown.agentId) : undefined
212
213 return (
214 <Box flexDirection="column" gap={isCompact ? 0 : 1}>
215 {agents.map(dollRow)}
216 {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
217 {shown && (
218 <Box flexDirection="column" marginTop={1}>
219 <Text dimColor>{'─'.repeat(Math.max(4, columns - 2))}</Text>
220 <Box flexDirection="row" justifyContent="space-between">
221 <Box flexDirection="row" gap={1}>
222 <Text color={miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).color}>
223 {miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).rows[0]}
224 </Text>
225 <Text bold wrap="truncate-end">{clip(owner?.description ?? 'Subagent', width - 16)}</Text>
226 </Box>
227 <Button key="back" label="Close" role="dismiss" onPress={() => select($, null)} />
228 </Box>
229 {shown.deny && <Text color="warning">{clip(shown.deny, width * 2)}</Text>}
230 {shown.lines.length === 0 && !shown.deny && <Text dimColor>Loading…</Text>}
231 {shown.lines.slice(-room).map(line =>
232 line.role === 'tool' ? (
233 <Text color={line.isError ? 'error' : 'subtle'} wrap="truncate-end">
234 › {clip(line.text, width)}
235 </Text>
236 ) : (
237 <Text color={line.role === 'user' ? 'suggestion' : 'text'} wrap="wrap">
238 {line.role === 'user' ? '» ' : ''}
239 {clip(line.text, width * 3)}
240 </Text>
241 ),
242 )}
243 </Box>
244 )}
245 </Box>
246 )
247 })
248}
249hooks/lib/robot.ts 68 lines1// Block-character robots, one per agent: the face follows the agent's status,
2// the color its type. Every glyph is a single terminal cell wide.
3
4export type Robot = {
5 rows: string[]
6 color: string
7 isDim: boolean
8}
9
10// Width of the full robot: the head is 7 cells, plus 2 on each side for the
11// arms a finished robot raises.
12export const ROBOT_WIDTH = 11
13
14const TYPE_COLORS: Record<string, string> = {
15 main: 'claude',
16 Explore: 'cyan',
17 Plan: 'magenta',
18 'general-purpose': 'yellow',
19 teammate: 'blue',
20}
21
22type Face = { eyes: string; antenna: string; isCheering?: boolean }
23
24// `frame` ticks while an agent works: the antenna lights up and the eyes blink.
25function face(status: string, frame: number): Face {
26 switch (status) {
27 case 'running':
28 return frame % 4 === 3 ? { eyes: '▁ ▁', antenna: '╻' } : { eyes: '● ●', antenna: frame % 2 === 0 ? '✦' : '╻' }
29 case 'pending':
30 return { eyes: '◦ ◦', antenna: '╻' }
31 case 'waiting':
32 return { eyes: '● ●', antenna: '?' }
33 case 'completed':
34 return { eyes: '^ ^', antenna: '╻', isCheering: true }
35 case 'failed':
36 case 'killed':
37 return { eyes: '× ×', antenna: '╷' }
38 default:
39 return { eyes: '– –', antenna: '╷' }
40 }
41}
42
43export function robotColor(type: string, status: string): string {
44 if (status === 'failed' || status === 'killed') return 'red'
45 return TYPE_COLORS[type] ?? 'green'
46}
47
48export function robot(type: string, status: string, frame = 0): Robot {
49 const f = face(status, frame)
50 const arm = f.isCheering ? '▝▘' : ' '
51 return {
52 rows: [
53 ` ${f.antenna} `,
54 ' ▗▄▄█▄▄▖ ',
55 `${arm}▐ ${f.eyes} ▌${arm}`,
56 ' ▝▀▙▄▟▀▘ ',
57 ],
58 color: robotColor(type, status),
59 isDim: status === 'idle' || status === 'completed',
60 }
61}
62
63// The one-line robot, for rows where the conversation takes the room below.
64export function miniRobot(type: string, status: string, frame = 0): Robot {
65 const eyes = face(status, frame).eyes.replace(' ', '')
66 return { rows: [`▐${eyes}▌`], color: robotColor(type, status), isDim: status === 'idle' || status === 'completed' }
67}
68types/index.d.ts 32 lines1export type Doll = {
2 id: string
3 type: string
4 description: string
5 status: string
6 parentId?: string
7}
8
9export type Line = {
10 role: 'user' | 'assistant' | 'tool'
11 text: string
12 isError?: boolean
13}
14
15export type Detail = {
16 agentId: string
17 lines: Line[]
18 deny?: string
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'agent-dolls': {
24 dolls: Doll[]
25 isMainBusy: boolean
26 selected: string | null
27 detail: Detail | null
28 frame: number
29 }
30 }
31}
32