SLOPSHOPPER

handily-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
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handily-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 │ ⎿ handily-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 in the colour of the mark, and the time since the spawn. A dim badge shows the type when the agent has a name.
MarkSignalStateColour
▶Doingrunning, waitingclaude
○To dopending, idlesubtle
✓Donedone, killedsuccess
■Blockedfailederror
?noneunknown: the engine no longer lists itdim
  • The marks and colours are the signals of 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.
  • 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:
MarkTaskMark colourTitle
✓donesuccessdim
▶in progressclaudebold
○opensubtleplain
  • 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 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.
  • 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/handily-agent-board
claude plugin test mods/handily-agent-board
Source 6 files
hooks/register.tsx 159 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: '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}
159
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 315 lines
1import 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}
315
hooks/plan.ts 77 lines
1declare 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}
77
hooks/signals.ts 14 lines
1import 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}
14
types/index.d.ts 8 lines
1export type AgentBoardReady = { root: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'handily-agent-board': { ready: AgentBoardReady }
6  }
7}
8