SLOPSHOPPER

agent-board

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

newpaneguardcommandtimeragents
v0.2.1MITupdated 2026-10-09niksavis/handily/mods/agent-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-board
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ Subagents │ ┃ No subagents in this session yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-board │ ⎿ agent-board: Agent board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
Subagents No subagents in this session yet.
README

agent-board

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.

Use

/agent-board          opens the board
/agent-board close    closes the board
  • Each card has a header line, up to two detail lines and the task list of the agent. A dim rule line separates two cards.
  • The header shows a status mark in a theme colour, the name or the type in bold, the state word and the time since the spawn. A dim badge shows the type when the agent has a name.
MarkStateColour
●runningsuccess
◐waitingwarning
○pending, idle, done, killeddim
✕failederror
?unknown: the engine no longer lists itdim
  • The first detail line is the task description of the Agent call. When the agent has a parent, under <parent> follows. <parent> is the name of the parent, else its description, else the first 8 characters of its id.
  • The second detail line is the tool call: ▸ 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.
  • A 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.
  • The task list starts with a dim plan 2/7 done line, then one line per task in list order:
MarkTaskTitle
✓donedim, mark in success
▶in progressbold
○openplain, mark dim
  • A list of more than 5 tasks folds. The card shows the task in progress, the 2 tasks before it and the 2 tasks after it, and · 5 of 7 shown. With no task in progress, the fold centres on the first open task, else on the last task.
  • On a folded list, plan 2/7 done is a button. A click on it unfolds the whole list: · all 7 shown. A second click folds it.
  • The open pane does not take the keyboard focus. Measured in Claude Code 2.1.295 in fullscreen: Tab and 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.
  • A plan title shows a control, format or line separator character as a \uXXXX escape, as task-pane shows it.
  • A subagent with no task list, or a session without task-pane, draws the card without the list.
  • The time counts every second while the agent runs. It stops when the agent ends. For an unknown row, it stops at the last moment that the board saw the agent.
  • An agent whose turn.complete arrived stays done when $.agent.list() no longer lists it. An agent that the list drops before its turn ends becomes unknown.
  • The header counts the cards: 2 active · 1 done. When every listed subagent is done, failed or killed, it reads all N done.
  • With no subagent, the pane shows No subagents in this session yet.
  • Order: the active cards first, then the unknown ones, then the ended ones, then the loops that the engine does not list. Inside a group, the order of the first sight.

What it reads

SourceWhat the board takes from it
$.agent.list()id, type, status, description, name and parent id, at each draw
agent.spawnthe agent id from the result, and the spawn time
tool.call with agentIdthe tool, its target, and the count when the call resolves
turn.complete with agentIdthe end time of the run of a subagent
task-pane agentList statethe task list of each agent, by its agent id, at each draw
  • A tool.call with no agentId is a call of the main loop. The mod passes it on at once and changes no card.
  • Each 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.
  • The 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).
  • The board reads the 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.
  • The board redraws every second while its pane is the shown tab. While the pane is a hidden tab, the timer only reads the list of panes. The board reads $.agent.list() only when it draws. The timer stops when the pane closes.

Loops that the engine does not list

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.

Limits

  • The board keeps its cards in the memory of the hooks module. A reload of the mod, for example after a change to its files, clears them. A subagent that ended before the reload comes back only while $.agent.list() still lists it. An ended Explore agent stayed listed for at least 90 s in the probe.
  • An agent that the board saw first in $.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.
  • The board does not drop the card of a subagent that the list named. Only the cards that the list never named have a bound, of 20.
  • Two panes do not show at once. With session-board open, the two boards are tabs.

Develop

claude --plugin-dir mods/agent-board
claude plugin test mods/agent-board
Source 5 files
hooks/register.tsx 158 lines
1import 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}
158
hooks/agents.ts 194 lines
1import 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}
194
hooks/board.tsx 301 lines
1import 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}
301
hooks/plan.ts 77 lines
1declare 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}
77
types/index.d.ts 8 lines
1export type AgentBoardReady = { root: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'agent-board': { ready: AgentBoardReady }
6  }
7}
8