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 | Signal | State | Colour |
|---|---|---|---|
▶ | Doing | running, waiting | claude |
○ | To do | pending, idle | subtle |
✓ | Done | done, killed | success |
■ | Blocked | failed | error |
? | none | unknown: the engine no longer lists it | dim |
docs/design.md. hooks/signals.ts holds the table. task-pane and session-board keep the same table, and scripts/signals.test.mjs fails when two tables differ.waiting draws as Doing, not as Waits for you. The engine types say that an agent is waiting on background work that it owns, on an Agent call, or on the approval of a plan. Only the approval needs a person, and the state does not say which wait it is. So a yellow ◆ would often be a false alarm. idle draws as To do: the agent is between turns until a message wakes it. killed draws as Done, because the agent left the work. The state word still says killed.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 | Mark colour | Title |
|---|---|---|---|
✓ | done | success | dim |
▶ | in progress | claude | bold |
○ | open | subtle | plain |
· 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).handily-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/handily-agent-board
claude plugin test mods/handily-agent-boardhooks/register.tsx 159 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: 'handily-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:
77 'handily · Shows what each subagent of this session does: its tool, tool count and time.',
78 argumentHint: '[close]',
79 })
80 await $.state.set({ plugin: 'handily-agent-board', key: 'ready' }, { root: $.plugin.root })
81 return started
82 })
83
84 on('session.end', (_$, e, next) => {
85 board.tracks.clear()
86 board.unfolded.clear()
87 return next(e)
88 }).catch((_$, e, next) => next(e))
89
90 on('tool.call', async ($, e, next) => {
91 const { agentId } = e
92 if (agentId === undefined) return next(e)
93 const callId = e.tool_use_id
94 const sight = { tool: e.tool, target: toolTarget(e) }
95 startCall(board.tracks, agentId, callId, sight, await $.clock.now())
96 let result: Awaited<ReturnType<typeof next>>
97 try {
98 result = await next(e)
99 } catch (error) {
100 dropCall(board.tracks, agentId, callId)
101 throw error
102 }
103 finishCall(board.tracks, agentId, callId)
104 return result
105 }).catch((_$, e, next) => next(e))
106
107 on('agent.spawn', async ($, e, next) => {
108 const spawned = await next(e)
109 if (spawned.agentId === undefined) return spawned
110 const facts = {
111 description: e.description,
112 type: e.subagentType,
113 name: e.name,
114 parentId: e.parentAgentId,
115 }
116 noteSpawn(board.tracks, spawned.agentId, facts, await $.clock.now())
117 return spawned
118 }).catch((_$, e, next) => next(e))
119
120 on('turn.complete', async ($, e, next) => {
121 const completed = await next(e)
122 if (e.agentId !== undefined) noteTurnEnd(board.tracks, e.agentId, await $.clock.now())
123 return completed
124 }).catch((_$, e, next) => next(e))
125
126 on('ui.close', { id: PANE_ID }, async (_$, e, next) => {
127 const closed = await next(e)
128 stopRedraw(board)
129 return closed
130 }).catch((_$, e, next) => next(e))
131
132 on('command.run', { command: 'agent-board' }, async ($, e) => {
133 const argument = e.args.trim()
134 if (argument === 'close') {
135 stopRedraw(board)
136 await $.ui.close({ id: PANE_ID })
137 return { text: 'Agent board closed.' }
138 }
139 if (argument !== '') {
140 return { text: `Unknown argument "${argument}". Use /agent-board or /agent-board close.` }
141 }
142 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
143 startRedraw($, board)
144 return { text: 'Agent board opened.' }
145 })
146
147 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
148 const elements = $.ui.resolve(e)
149 const now = await $.clock.now()
150 mergeList(board.tracks, await $.agent.list(), now)
151 startRedraw($, board)
152 const plans = cardPlans($, board, await readPlans($, board.tracks))
153 return renderBoard(elements, boardRows(board.tracks, now), e.props.bodyColumns, plans)
154 }).catch(($, e, next) => {
155 $.ui.log(`agent-board: the board could not be drawn (${next.error.kind})`, { to: 'debug' })
156 return next(e)
157 })
158}
159hooks/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 315 lines1import type { AgentStatus, 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'
14import { SIGNALS, type Signal } from './signals'
15
16export const EMPTY_TEXT = 'No subagents in this session yet.'
17export const NOT_LISTED_BADGE = 'not listed'
18
19type BoardElements = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
20type TextStyle = Omit<TextProps, 'children'>
21type BoxStyle = Omit<Parameters<BoardElements['Box']>[0], 'children'>
22type Child = RenderElement | null
23
24type Group = 'active' | 'unknown' | 'done' | 'unlisted'
25
26const DETAIL_INDENT = 2
27const CARD_RULE = '─'
28const SHORT_ID_CHARS = 8
29
30const STATUS_WORDS: Partial<Record<RowStatus, string>> = { completed: 'done' }
31
32type Mark = { glyph: string; style: TextStyle }
33
34const STATUS_SIGNALS: Record<AgentStatus, Signal> = {
35 running: 'doing',
36 pending: 'toDo',
37 waiting: 'doing',
38 idle: 'toDo',
39 completed: 'done',
40 failed: 'blocked',
41 killed: 'done',
42}
43
44const UNKNOWN_MARK: Mark = { glyph: '?', style: { dimColor: true } }
45
46const TASK_SIGNALS: Record<PlanStatus, { signal: Signal; title: TextStyle }> = {
47 completed: { signal: 'done', title: { dimColor: true } },
48 in_progress: { signal: 'doing', title: { bold: true } },
49 pending: { signal: 'toDo', title: {} },
50}
51
52function signalMark(signal: Signal): Mark {
53 const { mark, color } = SIGNALS[signal]
54 return { glyph: mark, style: { color } }
55}
56
57function statusMark(status: RowStatus): Mark {
58 return status === 'unknown' ? UNKNOWN_MARK : signalMark(STATUS_SIGNALS[status])
59}
60
61const GROUP_ORDER: Record<Group, number> = { active: 0, unknown: 1, done: 2, unlisted: 3 }
62
63export type CardPlans = {
64 lists: ReadonlyMap<string, readonly PlanTask[]>
65 unfolded: ReadonlySet<string>
66 toggle: (agentId: string) => void
67}
68
69export type BoardRow = {
70 id: string
71 title: string
72 badge: string | null
73 status: RowStatus
74 group: Group
75 time: string
76 description: string | null
77 parent: string | null
78 call: ToolSight | null
79 isCallRunning: boolean
80 tools: number
81}
82
83export function formatElapsed(ms: number): string {
84 const seconds = Math.max(Math.floor(ms / 1000), 0)
85 if (seconds < 60) return `${String(seconds)}s`
86 const minutes = Math.floor(seconds / 60)
87 if (minutes < 60) return `${String(minutes)}m ${String(seconds % 60).padStart(2, '0')}s`
88 return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60).padStart(2, '0')}m`
89}
90
91function groupOf(track: AgentTrack, status: RowStatus): Group {
92 if (isUnlisted(track)) return 'unlisted'
93 if (isEnded(status)) return 'done'
94 return status === 'unknown' ? 'unknown' : 'active'
95}
96
97function shortId(id: string): string {
98 return id.slice(0, SHORT_ID_CHARS)
99}
100
101function labelOf(track: AgentTrack): string {
102 return track.name ?? track.description ?? shortId(track.id)
103}
104
105function parentLabel(tracks: Tracks, parentId: string | null): string | null {
106 if (parentId === null) return null
107 const parent = tracks.get(parentId)
108 return parent ? labelOf(parent) : shortId(parentId)
109}
110
111function badgeOf(track: AgentTrack): string | null {
112 if (isUnlisted(track)) return NOT_LISTED_BADGE
113 return track.name === null ? null : track.type
114}
115
116function boardRow(tracks: Tracks, track: AgentTrack, now: number): BoardRow {
117 const running = currentCall(track)
118 const status = rowStatus(track)
119 return {
120 id: track.id,
121 title: track.name ?? track.type ?? shortId(track.id),
122 badge: badgeOf(track),
123 status,
124 group: groupOf(track, status),
125 time: formatElapsed(elapsedMs(track, now)),
126 description: track.description === '' ? null : track.description,
127 parent: parentLabel(tracks, track.parentId),
128 call: running ?? track.last,
129 isCallRunning: running !== null,
130 tools: track.tools,
131 }
132}
133
134export function boardRows(tracks: Tracks, now: number): BoardRow[] {
135 return [...tracks.values()]
136 .map((track) => ({ track, row: boardRow(tracks, track, now) }))
137 .sort(
138 (a, b) =>
139 GROUP_ORDER[a.row.group] - GROUP_ORDER[b.row.group] ||
140 a.track.startedAt - b.track.startedAt,
141 )
142 .map(({ row }) => row)
143}
144
145function countPart(count: number, words: string): string | null {
146 return count > 0 ? `${String(count)} ${words}` : null
147}
148
149export function headerText(rows: readonly BoardRow[]): string {
150 const count = (group: Group) => rows.filter((row) => row.group === group).length
151 const done = count('done')
152 const listed = rows.length - count('unlisted')
153 const parts =
154 listed > 0 && done === listed
155 ? [`all ${String(done)} done`]
156 : [countPart(count('active'), 'active'), countPart(done, 'done')]
157 return [
158 ...parts,
159 countPart(count('unknown'), 'unknown'),
160 countPart(count('unlisted'), 'not listed'),
161 ]
162 .filter((part) => part !== null)
163 .join(' · ')
164}
165
166export function countText(tools: number): string {
167 return tools === 1 ? '1 tool' : `${String(tools)} tools`
168}
169
170export function callText(call: ToolSight): string {
171 return call.target === null ? call.tool : `${call.tool} ${call.target}`
172}
173
174function text(elements: BoardElements, children: string, style: TextStyle = {}): RenderElement {
175 return elements.Text({ ...style, children })
176}
177
178function box(elements: BoardElements, style: BoxStyle, children: readonly Child[]): RenderElement {
179 return elements.Box({ ...style, children: children.filter((child) => child !== null) })
180}
181
182function kept(elements: BoardElements, child: RenderElement, style: BoxStyle = {}): RenderElement {
183 return box(elements, { ...style, flexShrink: 0 }, [child])
184}
185
186function cut(elements: BoardElements, line: string, style: TextStyle = {}): RenderElement {
187 return box(elements, { flexShrink: 1 }, [
188 text(elements, line, { ...style, wrap: 'truncate-end' }),
189 ])
190}
191
192function cardHeader(elements: BoardElements, row: BoardRow): RenderElement {
193 const mark = statusMark(row.status)
194 return box(elements, { flexDirection: 'row' }, [
195 kept(elements, text(elements, `${mark.glyph} `, mark.style)),
196 cut(elements, row.title, { bold: true }),
197 row.badge === null
198 ? null
199 : kept(elements, text(elements, row.badge, { dimColor: true }), { marginLeft: 2 }),
200 kept(elements, text(elements, STATUS_WORDS[row.status] ?? row.status, mark.style), {
201 marginLeft: 2,
202 }),
203 kept(elements, text(elements, row.time, { dimColor: true }), { marginLeft: 2 }),
204 ])
205}
206
207function aboutLine(elements: BoardElements, row: BoardRow): Child {
208 if (row.description === null && row.parent === null) return null
209 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
210 row.description === null ? null : cut(elements, row.description),
211 row.parent === null
212 ? null
213 : box(elements, { flexShrink: 1, marginLeft: row.description === null ? 0 : 2 }, [
214 text(elements, `under ${row.parent}`, { dimColor: true, wrap: 'truncate-end' }),
215 ]),
216 ])
217}
218
219function toolLine(elements: BoardElements, row: BoardRow): RenderElement {
220 if (row.call === null) {
221 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
222 kept(elements, text(elements, 'no tool calls yet', { dimColor: true })),
223 ])
224 }
225 const lead = row.isCallRunning ? '▸ ' : 'last '
226 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
227 kept(elements, text(elements, lead, { dimColor: true })),
228 cut(elements, callText(row.call), row.isCallRunning ? {} : { dimColor: true }),
229 kept(elements, text(elements, ` · ${countText(row.tools)}`, { dimColor: true })),
230 ])
231}
232
233export function planKey(agentId: string): string {
234 return `plan:${agentId}`
235}
236
237function shownText(view: PlanView): string {
238 return view.shown.length < view.total
239 ? ` · ${String(view.shown.length)} of ${String(view.total)} shown`
240 : ` · all ${String(view.total)} shown`
241}
242
243function planHead(
244 elements: BoardElements,
245 row: BoardRow,
246 view: PlanView,
247 plans: CardPlans,
248): RenderElement {
249 const count = `plan ${String(view.done)}/${String(view.total)} done`
250 if (!view.isFoldable) {
251 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
252 kept(elements, text(elements, count, { dimColor: true })),
253 ])
254 }
255 const fold = elements.Button({
256 key: planKey(row.id),
257 label: count,
258 plain: true,
259 dimColor: true,
260 onPress: () => {
261 plans.toggle(row.id)
262 },
263 })
264 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
265 kept(elements, fold),
266 cut(elements, shownText(view), { dimColor: true }),
267 ])
268}
269
270function taskLine(elements: BoardElements, task: PlanTask): RenderElement {
271 const { signal, title } = TASK_SIGNALS[task.status]
272 const mark = signalMark(signal)
273 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
274 kept(elements, text(elements, `${mark.glyph} `, mark.style)),
275 cut(elements, task.title, title),
276 ])
277}
278
279function planLines(elements: BoardElements, row: BoardRow, plans: CardPlans): RenderElement[] {
280 const tasks = plans.lists.get(row.id)
281 if (tasks === undefined) return []
282 const view = planView(tasks, plans.unfolded.has(row.id))
283 return [
284 planHead(elements, row, view, plans),
285 ...view.shown.map((task) => taskLine(elements, task)),
286 ]
287}
288
289function card(elements: BoardElements, row: BoardRow, plans: CardPlans): RenderElement {
290 return box(elements, { key: `agent:${row.id}`, flexDirection: 'column' }, [
291 cardHeader(elements, row),
292 aboutLine(elements, row),
293 toolLine(elements, row),
294 ...planLines(elements, row, plans),
295 ])
296}
297
298export function renderBoard(
299 elements: BoardElements,
300 rows: readonly BoardRow[],
301 bodyColumns: number,
302 plans: CardPlans,
303): RenderElement {
304 const rule = () => text(elements, CARD_RULE.repeat(bodyColumns), { dimColor: true })
305 const header = headerText(rows)
306 return box(elements, { flexDirection: 'column' }, [
307 box(elements, { flexDirection: 'row' }, [
308 kept(elements, text(elements, 'Subagents', { bold: true })),
309 header === '' ? null : cut(elements, ` ${header}`, { dimColor: true }),
310 ]),
311 rows.length === 0 ? text(elements, EMPTY_TEXT, { dimColor: true }) : null,
312 ...rows.flatMap((row, index) => [index === 0 ? null : rule(), card(elements, row, plans)]),
313 ])
314}
315hooks/plan.ts 77 lines1declare module 'claude-code' {
2 interface PluginState {
3 'handily-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}
77hooks/signals.ts 14 lines1import type { ThemeKey } from 'claude-code'
2
3export type Signal = 'doing' | 'done' | 'toDo' | 'blocked' | 'waitsForYou'
4
5export type SignalLook = { mark: string; color: ThemeKey }
6
7export const SIGNALS: Readonly<Record<Signal, SignalLook>> = {
8 doing: { mark: '▶', color: 'claude' },
9 done: { mark: '✓', color: 'success' },
10 toDo: { mark: '○', color: 'subtle' },
11 blocked: { mark: '■', color: 'error' },
12 waitsForYou: { mark: '◆', color: 'warning' },
13}
14types/index.d.ts 8 lines1export type AgentBoardReady = { root: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'handily-agent-board': { ready: AgentBoardReady }
6 }
7}
8