A pane that shows what each subagent of this session does: its tool, its tool count, its time and its task list.

agent-board shows the subagents of this session in one pane. Each subagent is a card: its type, its state, the time since its spawn, its task, the tool that it runs now, its count of tool calls and, when the subagent keeps one in task-pane, its task list.
/agent-board opens the board
/agent-board close closes the board
| Mark | State | Colour |
|---|---|---|
● | running | success |
◐ | waiting | warning |
○ | pending, idle, done, killed | dim |
✕ | failed | error |
? | unknown: the engine no longer lists it | dim |
under <parent> follows. <parent> is the name of the parent, else its description, else the first 8 characters of its id.▸ Read docs/design.md · 3 tools while a call runs, and a dim last Read docs/design.md · 4 tools after it. The target is the first line of the file path, pattern, path, URL, query, description, command or skill of the call, at most 200 characters, cut to fit.SubagentHandback call adds 1 to the count, but the line keeps the tool and target of the last other call. The line shows SubagentHandback only when it was the one call.plan 2/7 done line, then one line per task in list order:| Mark | Task | Title |
|---|---|---|
✓ | done | dim, mark in success |
▶ | in progress | bold |
○ | open | plain, mark dim |
· 5 of 7 shown. With no task in progress, the fold centres on the first open task, else on the last task.plan 2/7 done is a button. A click on it unfolds the whole list: · all 7 shown. A second click folds it.ctrl+x tab leave the focus at the prompt or on the engine's own agent list, and Enter after a click goes to the prompt.\uXXXX escape, as task-pane shows it.task-pane, draws the card without the list.unknown row, it stops at the last moment that the board saw the agent.turn.complete arrived stays done when $.agent.list() no longer lists it. An agent that the list drops before its turn ends becomes unknown.2 active · 1 done. When every listed subagent is done, failed or killed, it reads all N done.No subagents in this session yet.unknown ones, then the ended ones, then the loops that the engine does not list. Inside a group, the order of the first sight.| Source | What the board takes from it |
|---|---|
$.agent.list() | id, type, status, description, name and parent id, at each draw |
agent.spawn | the agent id from the result, and the spawn time |
tool.call with agentId | the tool, its target, and the count when the call resolves |
turn.complete with agentId | the end time of the run of a subagent |
task-pane agentList state | the task list of each agent, by its agent id, at each draw |
tool.call with no agentId is a call of the main loop. The mod passes it on at once and changes no card.session.end clears every card. A /clear and a resume end the session in the same process, and no session.start follows, so the cards of the last conversation do not stay.session.end, tool.call, agent.spawn, turn.complete and ui.close hooks have a .catch that answers next(e). A hook that fails never changes the result of the call, the spawn or the turn. The ui.render hook logs a failure to the debug log and answers next(e).task-pane state with a type of its own and no plugin dependency, so it loads and draws without task-pane. A later write of a list draws the board again.$.agent.list() only when it draws. The timer stops when the pane closes.A tool.call can carry an agentId that $.agent.list() never names. The engine types say that the agents of a workflow and the own forks of the engine (compaction, memory) carry such ids. A live probe on Claude Code 2.1.294 saw two of them, and the consumer run of this mod saw one that called AskUserQuestion.
The board keeps such a loop as a card, so no tool call is hidden. The card shows the first 8 characters of the id, a dim not listed badge, and running while a call runs. After its turn.complete it shows done, else unknown.
An agent that agent.spawn started, with no list row and no loop event, gets the same badge. A remote workflow agent is one: no local loop carries its id. Its card shows its type and unknown.
The header counts all of these cards apart, as N not listed, so that they do not block all N done.
The board keeps at most 20 cards that the list never named and that run no call. When a 21st comes, it drops the card that it saw least recently. It never drops a card that the list named or a card with a call in flight.
$.agent.list() still lists it. An ended Explore agent stayed listed for at least 90 s in the probe.$.agent.list() gets the time of that sight as its spawn time. Only an agent.spawn that this mod saw gives the true spawn time.session-board open, the two boards are tabs.claude --plugin-dir mods/agent-board
claude plugin test mods/agent-boardhooks/register.tsx 158 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import {
3 dropCall,
4 finishCall,
5 mergeList,
6 noteSpawn,
7 noteTurnEnd,
8 startCall,
9 toolTarget,
10 type Tracks,
11} from './agents'
12import { boardRows, renderBoard, type CardPlans } from './board'
13import { readPlan, type PlanTask } from './plan'
14
15export const PANE_ID = 'agent-board'
16export const PANE_TITLE = 'Subagents'
17export const REDRAW_INTERVAL_MS = 1000
18
19type Board = {
20 tracks: Tracks
21 unfolded: Set<string>
22 redraw: Timer | undefined
23}
24
25function stopRedraw(board: Board): void {
26 board.redraw?.cancel()
27 board.redraw = undefined
28}
29
30async function redrawShownBoard($: EngineInterface, board: Board): Promise<void> {
31 const pane = (await $.ui.panes()).find((open) => open.id === PANE_ID)
32 if (!pane) {
33 stopRedraw(board)
34 return
35 }
36 if (pane.isShown) $.ui.invalidate('ui.render')
37}
38
39function startRedraw($: EngineInterface, board: Board): void {
40 if (board.redraw) return
41 board.redraw = $.clock.every(REDRAW_INTERVAL_MS, () => {
42 redrawShownBoard($, board).catch((error: unknown) => {
43 $.ui.log(`agent-board: the redraw failed: ${String(error)}`, { to: 'debug' })
44 })
45 })
46}
47
48async function readPlans($: EngineInterface, tracks: Tracks): Promise<Map<string, PlanTask[]>> {
49 const plans = new Map<string, PlanTask[]>()
50 for (const agentId of tracks.keys()) {
51 const ref = { plugin: 'task-pane', key: 'agentList', id: agentId } as const
52 const plan = readPlan((await $.state.get(ref)).value)
53 if (plan !== null) plans.set(agentId, plan)
54 }
55 return plans
56}
57
58function cardPlans($: EngineInterface, board: Board, lists: Map<string, PlanTask[]>): CardPlans {
59 return {
60 lists,
61 unfolded: board.unfolded,
62 toggle: (agentId) => {
63 if (!board.unfolded.delete(agentId)) board.unfolded.add(agentId)
64 $.ui.invalidate('ui.render')
65 },
66 }
67}
68
69export const register: Register = (on) => {
70 const board: Board = { tracks: new Map(), unfolded: new Set(), redraw: undefined }
71
72 on('session.start', async ($, e, next) => {
73 const started = await next(e)
74 await $.command.register({
75 name: 'agent-board',
76 description: 'Shows what each subagent of this session does: its tool, tool count and time.',
77 argumentHint: '[close]',
78 })
79 await $.state.set({ plugin: 'agent-board', key: 'ready' }, { root: $.plugin.root })
80 return started
81 })
82
83 on('session.end', (_$, e, next) => {
84 board.tracks.clear()
85 board.unfolded.clear()
86 return next(e)
87 }).catch((_$, e, next) => next(e))
88
89 on('tool.call', async ($, e, next) => {
90 const { agentId } = e
91 if (agentId === undefined) return next(e)
92 const callId = e.tool_use_id
93 const sight = { tool: e.tool, target: toolTarget(e) }
94 startCall(board.tracks, agentId, callId, sight, await $.clock.now())
95 let result: Awaited<ReturnType<typeof next>>
96 try {
97 result = await next(e)
98 } catch (error) {
99 dropCall(board.tracks, agentId, callId)
100 throw error
101 }
102 finishCall(board.tracks, agentId, callId)
103 return result
104 }).catch((_$, e, next) => next(e))
105
106 on('agent.spawn', async ($, e, next) => {
107 const spawned = await next(e)
108 if (spawned.agentId === undefined) return spawned
109 const facts = {
110 description: e.description,
111 type: e.subagentType,
112 name: e.name,
113 parentId: e.parentAgentId,
114 }
115 noteSpawn(board.tracks, spawned.agentId, facts, await $.clock.now())
116 return spawned
117 }).catch((_$, e, next) => next(e))
118
119 on('turn.complete', async ($, e, next) => {
120 const completed = await next(e)
121 if (e.agentId !== undefined) noteTurnEnd(board.tracks, e.agentId, await $.clock.now())
122 return completed
123 }).catch((_$, e, next) => next(e))
124
125 on('ui.close', { id: PANE_ID }, async (_$, e, next) => {
126 const closed = await next(e)
127 stopRedraw(board)
128 return closed
129 }).catch((_$, e, next) => next(e))
130
131 on('command.run', { command: 'agent-board' }, async ($, e) => {
132 const argument = e.args.trim()
133 if (argument === 'close') {
134 stopRedraw(board)
135 await $.ui.close({ id: PANE_ID })
136 return { text: 'Agent board closed.' }
137 }
138 if (argument !== '') {
139 return { text: `Unknown argument "${argument}". Use /agent-board or /agent-board close.` }
140 }
141 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
142 startRedraw($, board)
143 return { text: 'Agent board opened.' }
144 })
145
146 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
147 const elements = $.ui.resolve(e)
148 const now = await $.clock.now()
149 mergeList(board.tracks, await $.agent.list(), now)
150 startRedraw($, board)
151 const plans = cardPlans($, board, await readPlans($, board.tracks))
152 return renderBoard(elements, boardRows(board.tracks, now), e.props.bodyColumns, plans)
153 }).catch(($, e, next) => {
154 $.ui.log(`agent-board: the board could not be drawn (${next.error.kind})`, { to: 'debug' })
155 return next(e)
156 })
157}
158hooks/agents.ts 194 lines1import type { AgentInfo, AgentStatus } from 'claude-code'
2
3export const TARGET_CHARS_AT_MOST = 200
4export const UNLISTED_KEPT_AT_MOST = 20
5const HANDBACK_TOOL = 'SubagentHandback'
6
7const TARGET_KEYS = [
8 'file_path',
9 'notebook_path',
10 'pattern',
11 'path',
12 'url',
13 'query',
14 'description',
15 'command',
16 'skill',
17] as const
18
19const ENDED_STATUSES: ReadonlySet<string> = new Set<AgentStatus>(['completed', 'failed', 'killed'])
20
21export type RowStatus = AgentStatus | 'unknown'
22
23export type ToolSight = { tool: string; target: string | null }
24
25export type SpawnFacts = {
26 description: string
27 type: string
28 name: string | undefined
29 parentId: string | undefined
30}
31
32export type AgentTrack = {
33 id: string
34 startedAt: number
35 seenAt: number
36 endedAt: number | null
37 tools: number
38 running: Map<string, ToolSight>
39 last: ToolSight | null
40 description: string | null
41 type: string | null
42 name: string | null
43 parentId: string | null
44 listedStatus: AgentStatus | null
45 isListed: boolean
46 hasLoopEvents: boolean
47}
48
49export type Tracks = Map<string, AgentTrack>
50
51export function isEnded(status: RowStatus): boolean {
52 return ENDED_STATUSES.has(status)
53}
54
55function firstLine(text: string): string {
56 return text.split(/\r?\n/, 1)[0]?.trim() ?? ''
57}
58
59export function toolTarget(input: Readonly<Record<string, unknown>>): string | null {
60 for (const key of TARGET_KEYS) {
61 const value = input[key]
62 if (typeof value !== 'string') continue
63 const line = firstLine(value)
64 if (line !== '') return line.slice(0, TARGET_CHARS_AT_MOST)
65 }
66 return null
67}
68
69export function trackOf(tracks: Tracks, id: string, now: number): AgentTrack {
70 const known = tracks.get(id)
71 if (known) return known
72 const created: AgentTrack = {
73 id,
74 startedAt: now,
75 seenAt: now,
76 endedAt: null,
77 tools: 0,
78 running: new Map(),
79 last: null,
80 description: null,
81 type: null,
82 name: null,
83 parentId: null,
84 listedStatus: null,
85 isListed: false,
86 hasLoopEvents: false,
87 }
88 tracks.set(id, created)
89 return created
90}
91
92export function isUnlisted(track: AgentTrack): boolean {
93 return track.listedStatus === null && (track.type === null || !track.hasLoopEvents)
94}
95
96function pruneUnlisted(tracks: Tracks): void {
97 const idle = [...tracks.values()]
98 .filter((track) => track.listedStatus === null && track.running.size === 0)
99 .sort((a, b) => a.seenAt - b.seenAt)
100 for (const track of idle.slice(0, Math.max(idle.length - UNLISTED_KEPT_AT_MOST, 0))) {
101 tracks.delete(track.id)
102 }
103}
104
105export function noteSpawn(tracks: Tracks, id: string, facts: SpawnFacts, now: number): void {
106 const track = trackOf(tracks, id, now)
107 track.description ??= facts.description
108 track.type ??= facts.type
109 track.name ??= facts.name ?? null
110 track.parentId ??= facts.parentId ?? null
111 pruneUnlisted(tracks)
112}
113
114export function startCall(
115 tracks: Tracks,
116 agentId: string,
117 callId: string,
118 sight: ToolSight,
119 now: number,
120): void {
121 const track = trackOf(tracks, agentId, now)
122 track.seenAt = now
123 track.endedAt = null
124 track.hasLoopEvents = true
125 track.running.set(callId, sight)
126}
127
128export function finishCall(tracks: Tracks, agentId: string, callId: string): void {
129 const track = tracks.get(agentId)
130 const sight = track?.running.get(callId)
131 if (!track || !sight) return
132 track.running.delete(callId)
133 track.tools += 1
134 if (sight.tool !== HANDBACK_TOOL || track.last === null) track.last = sight
135 pruneUnlisted(tracks)
136}
137
138export function dropCall(tracks: Tracks, agentId: string, callId: string): void {
139 tracks.get(agentId)?.running.delete(callId)
140 pruneUnlisted(tracks)
141}
142
143export function noteTurnEnd(tracks: Tracks, agentId: string, now: number): void {
144 const track = trackOf(tracks, agentId, now)
145 track.seenAt = now
146 track.endedAt = now
147 track.hasLoopEvents = true
148 pruneUnlisted(tracks)
149}
150
151export function mergeList(tracks: Tracks, listed: readonly AgentInfo[], now: number): void {
152 const seen = new Set<string>()
153 for (const info of listed) {
154 seen.add(info.id)
155 const track = trackOf(tracks, info.id, now)
156 track.isListed = true
157 track.seenAt = now
158 track.listedStatus = info.status
159 track.description = info.description === '' ? track.description : info.description
160 track.type = info.type
161 track.name = info.name ?? track.name
162 track.parentId = info.parentId ?? track.parentId
163 if (isEnded(info.status)) track.endedAt ??= now
164 }
165 for (const track of tracks.values()) {
166 if (!seen.has(track.id)) track.isListed = false
167 }
168}
169
170export function rowStatus(track: AgentTrack): RowStatus {
171 if (track.listedStatus !== null) {
172 if (track.isListed || isEnded(track.listedStatus)) return track.listedStatus
173 return track.endedAt === null ? 'unknown' : 'completed'
174 }
175 if (track.running.size > 0) return 'running'
176 return track.endedAt === null ? 'unknown' : 'completed'
177}
178
179export function currentCall(track: AgentTrack): ToolSight | null {
180 let current: ToolSight | null = null
181 for (const sight of track.running.values()) current = sight
182 return current
183}
184
185function endOf(track: AgentTrack, now: number): number {
186 const status = rowStatus(track)
187 if (isEnded(status)) return track.endedAt ?? now
188 return status === 'unknown' ? track.seenAt : now
189}
190
191export function elapsedMs(track: AgentTrack, now: number): number {
192 return Math.max(endOf(track, now) - track.startedAt, 0)
193}
194hooks/board.tsx 301 lines1import type { Elements, RenderElement, RenderSurface, TextProps } from 'claude-code'
2import {
3 currentCall,
4 elapsedMs,
5 isEnded,
6 isUnlisted,
7 rowStatus,
8 type AgentTrack,
9 type RowStatus,
10 type ToolSight,
11 type Tracks,
12} from './agents'
13import { planView, type PlanStatus, type PlanTask, type PlanView } from './plan'
14
15export const EMPTY_TEXT = 'No subagents in this session yet.'
16export const NOT_LISTED_BADGE = 'not listed'
17
18type BoardElements = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
19type TextStyle = Omit<TextProps, 'children'>
20type BoxStyle = Omit<Parameters<BoardElements['Box']>[0], 'children'>
21type Child = RenderElement | null
22
23type Group = 'active' | 'unknown' | 'done' | 'unlisted'
24
25const DETAIL_INDENT = 2
26const CARD_RULE = '─'
27const SHORT_ID_CHARS = 8
28
29const STATUS_WORDS: Partial<Record<RowStatus, string>> = { completed: 'done' }
30
31const MARKS: Record<RowStatus, { glyph: string; style: TextStyle }> = {
32 running: { glyph: '●', style: { color: 'success' } },
33 pending: { glyph: '○', style: { dimColor: true } },
34 waiting: { glyph: '◐', style: { color: 'warning' } },
35 idle: { glyph: '○', style: { dimColor: true } },
36 completed: { glyph: '○', style: { dimColor: true } },
37 failed: { glyph: '✕', style: { color: 'error' } },
38 killed: { glyph: '○', style: { dimColor: true } },
39 unknown: { glyph: '?', style: { dimColor: true } },
40}
41
42const TASK_MARKS: Record<PlanStatus, { glyph: string; style: TextStyle; title: TextStyle }> = {
43 completed: { glyph: '✓', style: { color: 'success' }, title: { dimColor: true } },
44 in_progress: { glyph: '▶', style: {}, title: { bold: true } },
45 pending: { glyph: '○', style: { dimColor: true }, title: {} },
46}
47
48const GROUP_ORDER: Record<Group, number> = { active: 0, unknown: 1, done: 2, unlisted: 3 }
49
50export type CardPlans = {
51 lists: ReadonlyMap<string, readonly PlanTask[]>
52 unfolded: ReadonlySet<string>
53 toggle: (agentId: string) => void
54}
55
56export type BoardRow = {
57 id: string
58 title: string
59 badge: string | null
60 status: RowStatus
61 group: Group
62 time: string
63 description: string | null
64 parent: string | null
65 call: ToolSight | null
66 isCallRunning: boolean
67 tools: number
68}
69
70export function formatElapsed(ms: number): string {
71 const seconds = Math.max(Math.floor(ms / 1000), 0)
72 if (seconds < 60) return `${String(seconds)}s`
73 const minutes = Math.floor(seconds / 60)
74 if (minutes < 60) return `${String(minutes)}m ${String(seconds % 60).padStart(2, '0')}s`
75 return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60).padStart(2, '0')}m`
76}
77
78function groupOf(track: AgentTrack, status: RowStatus): Group {
79 if (isUnlisted(track)) return 'unlisted'
80 if (isEnded(status)) return 'done'
81 return status === 'unknown' ? 'unknown' : 'active'
82}
83
84function shortId(id: string): string {
85 return id.slice(0, SHORT_ID_CHARS)
86}
87
88function labelOf(track: AgentTrack): string {
89 return track.name ?? track.description ?? shortId(track.id)
90}
91
92function parentLabel(tracks: Tracks, parentId: string | null): string | null {
93 if (parentId === null) return null
94 const parent = tracks.get(parentId)
95 return parent ? labelOf(parent) : shortId(parentId)
96}
97
98function badgeOf(track: AgentTrack): string | null {
99 if (isUnlisted(track)) return NOT_LISTED_BADGE
100 return track.name === null ? null : track.type
101}
102
103function boardRow(tracks: Tracks, track: AgentTrack, now: number): BoardRow {
104 const running = currentCall(track)
105 const status = rowStatus(track)
106 return {
107 id: track.id,
108 title: track.name ?? track.type ?? shortId(track.id),
109 badge: badgeOf(track),
110 status,
111 group: groupOf(track, status),
112 time: formatElapsed(elapsedMs(track, now)),
113 description: track.description === '' ? null : track.description,
114 parent: parentLabel(tracks, track.parentId),
115 call: running ?? track.last,
116 isCallRunning: running !== null,
117 tools: track.tools,
118 }
119}
120
121export function boardRows(tracks: Tracks, now: number): BoardRow[] {
122 return [...tracks.values()]
123 .map((track) => ({ track, row: boardRow(tracks, track, now) }))
124 .sort(
125 (a, b) =>
126 GROUP_ORDER[a.row.group] - GROUP_ORDER[b.row.group] ||
127 a.track.startedAt - b.track.startedAt,
128 )
129 .map(({ row }) => row)
130}
131
132function countPart(count: number, words: string): string | null {
133 return count > 0 ? `${String(count)} ${words}` : null
134}
135
136export function headerText(rows: readonly BoardRow[]): string {
137 const count = (group: Group) => rows.filter((row) => row.group === group).length
138 const done = count('done')
139 const listed = rows.length - count('unlisted')
140 const parts =
141 listed > 0 && done === listed
142 ? [`all ${String(done)} done`]
143 : [countPart(count('active'), 'active'), countPart(done, 'done')]
144 return [
145 ...parts,
146 countPart(count('unknown'), 'unknown'),
147 countPart(count('unlisted'), 'not listed'),
148 ]
149 .filter((part) => part !== null)
150 .join(' · ')
151}
152
153export function countText(tools: number): string {
154 return tools === 1 ? '1 tool' : `${String(tools)} tools`
155}
156
157export function callText(call: ToolSight): string {
158 return call.target === null ? call.tool : `${call.tool} ${call.target}`
159}
160
161function text(elements: BoardElements, children: string, style: TextStyle = {}): RenderElement {
162 return elements.Text({ ...style, children })
163}
164
165function box(elements: BoardElements, style: BoxStyle, children: readonly Child[]): RenderElement {
166 return elements.Box({ ...style, children: children.filter((child) => child !== null) })
167}
168
169function kept(elements: BoardElements, child: RenderElement, style: BoxStyle = {}): RenderElement {
170 return box(elements, { ...style, flexShrink: 0 }, [child])
171}
172
173function cut(elements: BoardElements, line: string, style: TextStyle = {}): RenderElement {
174 return box(elements, { flexShrink: 1 }, [
175 text(elements, line, { ...style, wrap: 'truncate-end' }),
176 ])
177}
178
179function cardHeader(elements: BoardElements, row: BoardRow): RenderElement {
180 const mark = MARKS[row.status]
181 return box(elements, { flexDirection: 'row' }, [
182 kept(elements, text(elements, `${mark.glyph} `, mark.style)),
183 cut(elements, row.title, { bold: true }),
184 row.badge === null
185 ? null
186 : kept(elements, text(elements, row.badge, { dimColor: true }), { marginLeft: 2 }),
187 kept(elements, text(elements, STATUS_WORDS[row.status] ?? row.status, mark.style), {
188 marginLeft: 2,
189 }),
190 kept(elements, text(elements, row.time, { dimColor: true }), { marginLeft: 2 }),
191 ])
192}
193
194function aboutLine(elements: BoardElements, row: BoardRow): Child {
195 if (row.description === null && row.parent === null) return null
196 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
197 row.description === null ? null : cut(elements, row.description),
198 row.parent === null
199 ? null
200 : box(elements, { flexShrink: 1, marginLeft: row.description === null ? 0 : 2 }, [
201 text(elements, `under ${row.parent}`, { dimColor: true, wrap: 'truncate-end' }),
202 ]),
203 ])
204}
205
206function toolLine(elements: BoardElements, row: BoardRow): RenderElement {
207 if (row.call === null) {
208 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
209 kept(elements, text(elements, 'no tool calls yet', { dimColor: true })),
210 ])
211 }
212 const lead = row.isCallRunning ? '▸ ' : 'last '
213 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
214 kept(elements, text(elements, lead, { dimColor: true })),
215 cut(elements, callText(row.call), row.isCallRunning ? {} : { dimColor: true }),
216 kept(elements, text(elements, ` · ${countText(row.tools)}`, { dimColor: true })),
217 ])
218}
219
220export function planKey(agentId: string): string {
221 return `plan:${agentId}`
222}
223
224function shownText(view: PlanView): string {
225 return view.shown.length < view.total
226 ? ` · ${String(view.shown.length)} of ${String(view.total)} shown`
227 : ` · all ${String(view.total)} shown`
228}
229
230function planHead(
231 elements: BoardElements,
232 row: BoardRow,
233 view: PlanView,
234 plans: CardPlans,
235): RenderElement {
236 const count = `plan ${String(view.done)}/${String(view.total)} done`
237 if (!view.isFoldable) {
238 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
239 kept(elements, text(elements, count, { dimColor: true })),
240 ])
241 }
242 const fold = elements.Button({
243 key: planKey(row.id),
244 label: count,
245 plain: true,
246 dimColor: true,
247 onPress: () => {
248 plans.toggle(row.id)
249 },
250 })
251 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
252 kept(elements, fold),
253 cut(elements, shownText(view), { dimColor: true }),
254 ])
255}
256
257function taskLine(elements: BoardElements, task: PlanTask): RenderElement {
258 const mark = TASK_MARKS[task.status]
259 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
260 kept(elements, text(elements, `${mark.glyph} `, mark.style)),
261 cut(elements, task.title, mark.title),
262 ])
263}
264
265function planLines(elements: BoardElements, row: BoardRow, plans: CardPlans): RenderElement[] {
266 const tasks = plans.lists.get(row.id)
267 if (tasks === undefined) return []
268 const view = planView(tasks, plans.unfolded.has(row.id))
269 return [
270 planHead(elements, row, view, plans),
271 ...view.shown.map((task) => taskLine(elements, task)),
272 ]
273}
274
275function card(elements: BoardElements, row: BoardRow, plans: CardPlans): RenderElement {
276 return box(elements, { key: `agent:${row.id}`, flexDirection: 'column' }, [
277 cardHeader(elements, row),
278 aboutLine(elements, row),
279 toolLine(elements, row),
280 ...planLines(elements, row, plans),
281 ])
282}
283
284export function renderBoard(
285 elements: BoardElements,
286 rows: readonly BoardRow[],
287 bodyColumns: number,
288 plans: CardPlans,
289): RenderElement {
290 const rule = () => text(elements, CARD_RULE.repeat(bodyColumns), { dimColor: true })
291 const header = headerText(rows)
292 return box(elements, { flexDirection: 'column' }, [
293 box(elements, { flexDirection: 'row' }, [
294 kept(elements, text(elements, 'Subagents', { bold: true })),
295 header === '' ? null : cut(elements, ` ${header}`, { dimColor: true }),
296 ]),
297 rows.length === 0 ? text(elements, EMPTY_TEXT, { dimColor: true }) : null,
298 ...rows.flatMap((row, index) => [index === 0 ? null : rule(), card(elements, row, plans)]),
299 ])
300}
301hooks/plan.ts 77 lines1declare module 'claude-code' {
2 interface PluginState {
3 'task-pane': { agentList: StateFamily<unknown> }
4 }
5}
6
7export const FOLD_ABOVE = 5
8export const FOLD_SIDE = 2
9
10const PLAN_STATUSES = ['pending', 'in_progress', 'completed'] as const
11
12export type PlanStatus = (typeof PLAN_STATUSES)[number]
13
14export type PlanTask = { title: string; status: PlanStatus }
15
16export type PlanView = {
17 shown: readonly PlanTask[]
18 done: number
19 total: number
20 isFoldable: boolean
21}
22
23function isRecord(value: unknown): value is Record<string, unknown> {
24 return typeof value === 'object' && value !== null && !Array.isArray(value)
25}
26
27function isPlanTask(value: unknown): value is PlanTask {
28 return (
29 isRecord(value) &&
30 typeof value.title === 'string' &&
31 PLAN_STATUSES.some((status) => status === value.status)
32 )
33}
34
35const HIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu
36
37function escapedUnits(text: string): string {
38 return Array.from(
39 { length: text.length },
40 (_, index) => `\\u${text.charCodeAt(index).toString(16).padStart(4, '0')}`,
41 ).join('')
42}
43
44export function shownTitle(title: string): string {
45 return title.replace(HIDDEN_CHARACTER, escapedUnits)
46}
47
48export function readPlan(value: unknown): PlanTask[] | null {
49 if (!isRecord(value) || !Array.isArray(value.tasks)) return null
50 const tasks = value.tasks
51 .filter(isPlanTask)
52 .map((task) => ({ title: shownTitle(task.title), status: task.status }))
53 return tasks.length === 0 ? null : tasks
54}
55
56function anchorIndex(tasks: readonly PlanTask[]): number {
57 const running = tasks.findIndex((task) => task.status === 'in_progress')
58 if (running >= 0) return running
59 const next = tasks.findIndex((task) => task.status === 'pending')
60 return next >= 0 ? next : tasks.length - 1
61}
62
63export function planView(tasks: readonly PlanTask[], isUnfolded: boolean): PlanView {
64 const isFoldable = tasks.length > FOLD_ABOVE
65 const anchor = anchorIndex(tasks)
66 const shown =
67 isFoldable && !isUnfolded
68 ? tasks.slice(Math.max(anchor - FOLD_SIDE, 0), anchor + FOLD_SIDE + 1)
69 : tasks
70 return {
71 shown,
72 done: tasks.filter((task) => task.status === 'completed').length,
73 total: tasks.length,
74 isFoldable,
75 }
76}
77types/index.d.ts 8 lines1export type AgentBoardReady = { root: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'agent-board': { ready: AgentBoardReady }
6 }
7}
8