A live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens

Claude Code mods, packaged as a plugin marketplace. A mod is a plugin whose hooks module (TypeScript, here) runs inside the session. Mods need Claude Code 2.1.287 or later and are on by default.
| Plugin | What it does |
|---|---|
| time | The time you sent each message, drawn above it |
| gh-ci-status | GitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run |
| activity-log | Every event of the session, tool calls and their full results included, appended to a JSONL file per session |
| agent-flow | A live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens |
The time above a message is the one of its first drawing, which is when you sent it, and it is kept per message, so a resize or a redraw does not move it. A resumed session, or a reload of the plugin, draws the old messages with the current time.
⚙ owner/repo · Actions · 1 running · 1 finished
#167 ◐ Running PR 0m37s chore(ci): smoke-test PR
main ● Success Deploy 2m15s Release 1.4.0
gh does (gh repo view, the default remote), so gh must be on your PATH and logged in. Without a GitHub remote it stays quiet. The check runs at session start; if it fails there, no network or an expired token, it runs again at the next command that wakes the band (below), so after gh auth login, or once the network is back, a push is enough. Each failure goes to the debug log (claude --debug). While gh fails, the header says so (gh error for 2m05s) and keeps the last rows.gh run list every 60 s, and every 15 s while a run is in flight or for 6 minutes after a push. A git push, git subtree push, gh pr merge, gh workflow run or gh run rerun in Bash wakes it.#N links to the PR (matched by branch through gh pr list, or from refs/pull/N/head); the workflow name (on a narrow band, the status) links to the run.details on the band's header opens a pane with every run of the last list, and under each failed one its failed jobs and steps, each linking to the job: ctrl+x tab to focus the band, then d; d or Esc closes it. A mouse click reaches it only in fullscreen./gh-ci opens or closes the same pane, band or not; before the repo is found it looks again and says so.Writes everything the session does to ~/.claude/activity-log/<date>-<sessionId>.jsonl, one JSON object per line. The date is the local date of the session's first line; after a /clear the lines go to a new file, under the new session id.
seq: a start line with the event's input (and origin, who raised it), written before the event runs, then an end line with its result and durationMs, or an error line. Tool calls, the rows the conversation keeps, the model's requests, prompts, commands, agents and the $ calls of other plugins are all there.agentId on the start line. The model's response and a plugin's spawned process stream; their end line lists the chunks, which pass through unchanged as they come.ui.render and ui.resolve, which fire for each component on every redraw; prompt.edit, which fires on every keystroke with the draft so far (prompt.submit keeps the text sent); and this plugin's own calls. Telemetry is only the collector stream of telemetry.log; the anthropic stream, and telemetry.mark with it, is closed to installed plugins.tool.describe, command.describe and the $ calls of other plugins.seq starts again at 0 in the same file, and the last second of lines may be lost.Reading it with jq:
# tool calls, with input and result side by side
jq -s -c 'map(select(.event == "tool.call")) | group_by(.seq)[]
| {tool: .[0].input.tool, agentId: .[0].agentId, input: .[0].input, result: .[1].result}' FILE
# one subagent's lines (its ids: jq -r '.agentId // empty' FILE | sort -u)
jq -s -c --arg id AGENT_ID 'map(select(.agentId == $id) | .seq) as $seqs
| .[] | select(.seq | IN($seqs[]))' FILE
# how many of each event
jq -r 'select(.phase == "start") | .event' FILE | sort | uniq -c | sort -rn
2 running · 1 done · 1 waiting
● main · running 1m24s · Agent 1m23s
● general-purpose: find the auth flow · running 1m23s · Grep 4s · 7 calls
✓ Explore: read the session store · completed 36s · 12 calls · 20.4k tok
● general-purpose: run the test suite · running 1m22s · waiting for approval: Bash · 3 calls
/flow opens a pane with the main loop and every subagent and teammate under the loop that spawned it; /flow again closes it. The pane does not take the keyboard. Terminal only.Agent call, which lasts as long as its child, does not count), or while a running agent has had no event for 2 minutes. Ended rows are dimmed.agent.spawn, tool.call, turn.start, turn.complete, and classic.PermissionRequest when no settings hook decided it), checked against $.agent.list() every 2 s while something runs and the pane is open; the list has the last word on status. An agent the list named once and then left out is marked gone./clear and /resume start the tree empty; a background agent that survives /clear comes back once the list names it (while something runs with the pane open). A reload of the plugin starts it empty too; run /flow to draw the pane again, and running agents come back once the list names them.# mods need Claude Code 2.1.287 or later
claude --plugin-dir /path/to/claude-mods-diegorv/plugins
That folder loads activity-log too, which writes everything the session sees to disk; to leave it out, point --plugin-dir at single plugins instead (--plugin-dir plugins/time).
Under --plugin-dir, edits to a plugin's files reload it without restarting the session.
Or install them from the marketplace on GitHub:
/plugin marketplace add diegorv/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv
Or from a local clone, by the path of its root (the folder with .claude-plugin/marketplace.json, not plugins/):
/plugin marketplace add /path/to/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv
A marketplace added from a local path loads its plugins in place, so edits need no version bump or reinstall; they take effect at the next session start or /reload-plugins, not on save as under --plugin-dir. Both marketplaces are named claude-mods-diegorv, and only one marketplace per name can be registered, so remove one before adding the other.
The marketplace was renamed, and the install ids with it. If you installed under the old name, run /plugin marketplace remove claude-function-hooks, which also uninstalls its plugins, then one of the two marketplace blocks above.
Tested with Claude Code 2.1.289; the API may still change between releases. /plugin names the mods the session loaded, in a line such as 1 mod active · time. The old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is ignored; remove it.
To turn one off, disable its plugin in the Installed tab of /plugin. To turn off every installed mod, start the session with --safe-mode, which also disables your other customizations, or, for every session, set "disableAllHooks": true in ~/.claude/settings.json, which also stops your settings hooks and custom status line.
npm test # node --test, no dependencies
npm run typecheck # tsc
npm run format # prettier
npm run validate # claude plugin validate
claude plugin test runs every *.test.ts in a plugin with the claude-code/testing kit; these use node:test, so it is not used here.
The API's type declarations are not in git. Claude Code writes them to plugins/<name>/.claude-plugin/types/ each time it loads the plugins with --plugin-dir (a one-prompt headless run, claude -p --plugin-dir plugins "ok", will do). Before npm run typecheck, load the plugins once, and again after updating Claude Code.
plugins/gh-ci-status/
.claude-plugin/plugin.json manifest
hooks/hooks.json points at the entry module
src/core/ the run model and the text derived from it, no I/O
src/utils/ generic helpers (text)
src/app/ use cases (the poller, the start-up), dependencies injected
src/infra/ external clients (GitHub through gh)
src/components/ the band's view model and its JSX
src/hooks/ the wiring to the engine
*.test.ts next to the file it tests, run with node:testsrc/hooks/register.tsx 165 lines1// Wires the model to the engine: its hooks, no logic of its own.
2//
3// session.start registers /flow, in an interactive session only
4// session.end /clear and /resume forget the tree
5// agent.spawn a started agent joins the tree under its parent
6// tool.call a call is in flight from its start until next settles
7// classic.PermissionRequest a prompt no settings hook decided marks the call waiting
8// turn.start, turn.complete the main loop's turns; a subagent's run ends at its turn.complete
9// ui.render draws the pane from the tree
10// ui.close notes that the pane is gone, so the next /flow opens it
11// command.run /flow opens or closes the pane
12//
13// While isBusy and the pane is drawn, the tree is reconciled with $.agent.list()
14// every 2 s and the pane redrawn every 1 s, so the clocks move; both timers stop
15// once all is idle or the pane closes. Events update the tree either way.
16import type { EngineInterface, Register, Timer } from "claude-code";
17import {
18 initialState,
19 onPermission,
20 onSpawn,
21 onToolEnd,
22 onToolStart,
23 onTurnComplete,
24 onTurnStart,
25 reconcile,
26 type FlowState,
27} from "../core/flow.ts";
28import { isBusy } from "../core/rows.ts";
29import { Pane } from "../components/pane.tsx";
30import { paneModel } from "../components/pane-model.ts";
31
32const PANE_ID = "flow";
33const TICK_MS = 1000;
34const LIST_MS = 2000;
35
36const logFailure = ($: EngineInterface) => (error: unknown) =>
37 $.ui.log(error instanceof Error ? error.message : String(error), { to: "debug" });
38
39// What the module keeps between hooks; a reload starts it over.
40type Flow = {
41 isActive: boolean; // an interactive session: -p and the SDK draw nowhere, so nothing is tracked
42 state: FlowState;
43 timers: Timer[];
44 paneDrawn: boolean; // this module drew the pane since it last opened; cleared when it closes
45};
46
47// Starts or stops the timers by whether anything runs in a drawn pane.
48function syncTimers($: EngineInterface, flow: Flow) {
49 const busy = isBusy(flow.state) && flow.paneDrawn;
50 if (busy && flow.timers.length === 0) {
51 flow.timers = [
52 $.clock.every(TICK_MS, () => $.ui.invalidate("ui.render")),
53 $.clock.every(LIST_MS, () => void readList($, flow).catch(logFailure($))),
54 ];
55 } else if (!busy && flow.timers.length > 0) {
56 for (const timer of flow.timers) timer.cancel();
57 flow.timers = [];
58 }
59}
60
61// Changes the tree, redraws, and starts or stops the timers.
62async function apply($: EngineInterface, flow: Flow, change: (state: FlowState, now: number) => FlowState) {
63 flow.state = change(flow.state, await $.clock.now());
64 $.ui.invalidate("ui.render");
65 syncTimers($, flow);
66}
67
68async function readList($: EngineInterface, flow: Flow) {
69 const listed = await $.agent.list();
70 await apply($, flow, (state, now) => reconcile(state, listed, now));
71}
72
73// Opened by the person, so it is placed at any width; it does not take the keyboard, being a view to glance at.
74async function togglePane($: EngineInterface, flow: Flow): Promise<string | undefined> {
75 const listed = (await $.ui.panes()).find((pane) => pane.id === PANE_ID);
76 if (listed?.isShown && flow.paneDrawn) {
77 await $.ui.close({ id: PANE_ID });
78 return "Closed the agent flow pane.";
79 }
80 // Listed but not drawn here is what a reload leaves, and opening that again does not draw it: close it first.
81 if (listed && !flow.paneDrawn) await $.ui.close({ id: PANE_ID });
82 const opened = await $.ui.open({ id: PANE_ID, title: "Agent flow" });
83 return opened.isPlaced ? undefined : `The agent flow pane did not open: ${opened.reason}`;
84}
85
86export const register: Register = (on) => {
87 const flow: Flow = { isActive: false, state: initialState(0), timers: [], paneDrawn: false };
88
89 on("session.start", ($, event, next) => {
90 flow.isActive = event.isInteractive;
91 if (flow.isActive) {
92 void $.command
93 .register({ name: "flow", description: "Show or hide the tree of this session's subagents and teammates" })
94 .catch(logFailure($));
95 }
96 return next(event);
97 });
98
99 on("session.end", async ($, event, next) => {
100 if (flow.isActive && (event.reason === "clear" || event.reason === "resume")) {
101 await apply($, flow, (_, now) => initialState(now));
102 }
103 return next(event);
104 });
105
106 on("agent.spawn", async ($, event, next) => {
107 const result = await next(event);
108 if (flow.isActive) await apply($, flow, (current, now) => onSpawn(current, event, result, now));
109 return result;
110 });
111
112 on("tool.call", async ($, event, next) => {
113 if (!flow.isActive) return next(event);
114 await apply($, flow, (current, now) => onToolStart(current, event, now));
115 try {
116 return await next(event);
117 } finally {
118 await apply($, flow, (current, now) => onToolEnd(current, event, now));
119 }
120 });
121
122 // The chain beneath is the settings hooks: a decision of theirs means no prompt is shown.
123 on("classic.PermissionRequest", async ($, event, next) => {
124 const result = await next(event);
125 if (flow.isActive && !result.decision && !result.block) {
126 await apply($, flow, (current, now) =>
127 onPermission(current, { agentId: event.agent_id, tool: event.tool_name }, now),
128 );
129 }
130 return result;
131 });
132
133 on("turn.start", async ($, event, next) => {
134 if (flow.isActive) await apply($, flow, (current, now) => onTurnStart(current, now));
135 return next(event);
136 });
137
138 on("turn.complete", async ($, event, next) => {
139 if (flow.isActive) await apply($, flow, (current, now) => onTurnComplete(current, event, now));
140 return next(event);
141 });
142
143 // Registered only in an interactive session (session.start), where the pane can draw.
144 on("command.run", { command: "flow" }, async ($) => {
145 const text = await togglePane($, flow);
146 return text ? { text } : {};
147 });
148
149 on("ui.render", { component: "Pane", surface: "terminal" }, async ($, event, next) => {
150 if (event.requestId !== PANE_ID) return next(event);
151 if (!flow.paneDrawn) {
152 flow.paneDrawn = true;
153 syncTimers($, flow);
154 }
155 return Pane($.ui.resolve(event), paneModel(flow.state, await $.clock.now()));
156 });
157
158 // The person's close or ours ends the drawing; the close goes on.
159 on("ui.close", { id: PANE_ID }, ($, event, next) => {
160 flow.paneDrawn = false;
161 syncTimers($, flow);
162 return next(event);
163 });
164};
165src/core/flow.ts 190 lines1// The tree's state and how each engine event changes it: the main loop at the
2// root, every subagent and teammate under the loop that spawned it. Plain data
3// (no Map, no class), every reducer returning a new state and taking `now`.
4import type { AgentInfo, AgentSpawnInput, AgentSpawnResult, AgentStatus, TurnCompleteInput } from "claude-code";
5
6// `gone`: a loop that left `$.agent.list()` while it still looked alive.
7export type Status = AgentStatus | "gone";
8
9// A tool call in flight; `isWaiting` while its permission prompt is open.
10export type Call = { id: string; tool: string; since: number; isWaiting: boolean };
11
12export type FlowNode = {
13 id: string;
14 parentId: string | null; // null: spawned by the main loop, or a parent the tree does not know
15 type: string;
16 description: string;
17 name: string | null;
18 isTeammate: boolean;
19 status: Status;
20 startedAt: number;
21 endedAt: number | null;
22 lastEventAt: number;
23 calls: Call[]; // oldest first
24 toolCalls: number; // ended calls
25 tokens: number; // input + output, summed over the loop's turns
26 isListed: boolean; // some list named it: only such a node can be judged gone
27};
28
29export type FlowState = { root: FlowNode; agents: Record<string, FlowNode> };
30
31export type SpawnFacts = Pick<
32 AgentSpawnInput,
33 "subagentType" | "description" | "name" | "parentAgentId" | "isTeammate"
34>;
35export type ToolStart = { agentId?: string; tool: string; tool_use_id: string };
36export type ToolEnd = { agentId?: string; tool_use_id: string };
37// From `classic.PermissionRequest`: `agent_id` and `tool_name` under these names.
38export type PermissionAsk = { agentId?: string; tool: string };
39export type TurnEnd = Pick<TurnCompleteInput, "agentId" | "reason" | "usage">;
40export type Listed = Pick<AgentInfo, "id" | "type" | "description" | "status" | "parentId" | "name" | "teammateId">;
41
42const ENDED: readonly Status[] = ["completed", "failed", "killed", "gone"];
43
44export const isEnded = (status: Status): boolean => ENDED.includes(status);
45
46function node(id: string, now: number, facts: Partial<FlowNode> = {}): FlowNode {
47 return {
48 id,
49 parentId: null,
50 type: "",
51 description: "",
52 name: null,
53 isTeammate: false,
54 status: "running",
55 startedAt: now,
56 endedAt: null,
57 lastEventAt: now,
58 calls: [],
59 toolCalls: 0,
60 tokens: 0,
61 isListed: false,
62 ...facts,
63 };
64}
65
66// Also what `/clear` and `/resume` leave: the main loop, idle, and no agent.
67export function initialState(now: number): FlowState {
68 return { root: node("main", now, { type: "main", status: "idle", endedAt: now }), agents: {} };
69}
70
71// Applies `change` to the loop an event names (absent: the main loop); an id
72// the tree does not know (a workflow's agent, a compaction fork) is ignored.
73function updateLoop(state: FlowState, agentId: string | undefined, change: (node: FlowNode) => FlowNode): FlowState {
74 if (agentId === undefined) return { ...state, root: change(state.root) };
75 const current = state.agents[agentId];
76 if (!current) return state;
77 return { ...state, agents: { ...state.agents, [agentId]: change(current) } };
78}
79
80// A started spawn adds a running node under its parent; one the list added
81// first keeps its status and takes the spawn's facts. A refused one adds none.
82export function onSpawn(state: FlowState, spawn: SpawnFacts, result: AgentSpawnResult, now: number): FlowState {
83 if (result.agentId === undefined) return state;
84 const existing = state.agents[result.agentId];
85 const facts = {
86 type: spawn.subagentType,
87 description: spawn.description,
88 // A teammate's listed name carries the suffix added when the requested one was taken.
89 name: (spawn.isTeammate === true ? existing?.name : null) ?? spawn.name ?? null,
90 isTeammate: spawn.isTeammate === true,
91 parentId: spawn.parentAgentId ?? existing?.parentId ?? null,
92 lastEventAt: now,
93 };
94 const next = existing ? { ...existing, ...facts } : node(result.agentId, now, facts);
95 return { ...state, agents: { ...state.agents, [next.id]: next } };
96}
97
98// A call starts: an agent runs (a message may wake an ended agent or an idle
99// teammate). The main loop's status is its turn's: a call outside one (a
100// plugin's `$.tool.call`) leaves it idle.
101export function onToolStart(state: FlowState, call: ToolStart, now: number): FlowState {
102 const isAgent = call.agentId !== undefined;
103 return updateLoop(state, call.agentId, (loop) => ({
104 ...loop,
105 status: isAgent ? "running" : loop.status,
106 endedAt: isAgent ? null : loop.endedAt,
107 lastEventAt: now,
108 calls: [...loop.calls, { id: call.tool_use_id, tool: call.tool, since: now, isWaiting: false }],
109 }));
110}
111
112// A call ends, answered, denied or failed: it is counted, even one whose start
113// came before its loop's spawn resolved.
114export function onToolEnd(state: FlowState, call: ToolEnd, now: number): FlowState {
115 return updateLoop(state, call.agentId, (loop) => ({
116 ...loop,
117 lastEventAt: now,
118 calls: loop.calls.filter((inFlight) => inFlight.id !== call.tool_use_id),
119 toolCalls: loop.toolCalls + 1,
120 }));
121}
122
123// A permission prompt opens: the request names no call, so it marks the
124// loop's oldest call of that tool not already waiting. The call's end clears it.
125export function onPermission(state: FlowState, ask: PermissionAsk, now: number): FlowState {
126 return updateLoop(state, ask.agentId, (loop) => {
127 const index = loop.calls.findIndex((call) => call.tool === ask.tool && !call.isWaiting);
128 if (index === -1) return loop;
129 const calls = loop.calls.map((call, at) => (at === index ? { ...call, isWaiting: true } : call));
130 return { ...loop, calls, lastEventAt: now };
131 });
132}
133
134// The main loop starts a turn; a subagent's run raises no `turn.start`.
135export function onTurnStart(state: FlowState, now: number): FlowState {
136 return { ...state, root: { ...state.root, status: "running", startedAt: now, endedAt: null, lastEventAt: now } };
137}
138
139const STATUS_OF_REASON: Record<TurnEnd["reason"], Status> = {
140 answer: "completed",
141 error: "failed",
142 refusal: "failed",
143 aborted: "killed",
144};
145
146// A loop's turn ends: the main loop and a teammate go idle, a subagent ends by
147// the reason. Calls still in flight ended with the turn; its tokens are added.
148export function onTurnComplete(state: FlowState, turn: TurnEnd, now: number): FlowState {
149 return updateLoop(state, turn.agentId, (loop) => ({
150 ...loop,
151 status: turn.agentId === undefined || loop.isTeammate ? "idle" : STATUS_OF_REASON[turn.reason],
152 endedAt: now,
153 lastEventAt: now,
154 calls: [],
155 tokens: loop.tokens + (turn.usage ? turn.usage.input_tokens + turn.usage.output_tokens : 0),
156 }));
157}
158
159// What `$.agent.list()` answers has the last word on status and fills a
160// parent the events never gave. Its name is taken for a teammate only (for
161// another agent it is the engine's handle, not a label). A node alive by the events
162// that a list named and the next one leaves out is gone; one no list ever named
163// (a loop the list may not cover, or a spawn the list was read before) is left to its events.
164export function reconcile(state: FlowState, listed: readonly Listed[], now: number): FlowState {
165 const agents: Record<string, FlowNode> = {};
166 const seen = new Set(listed.map((info) => info.id));
167 for (const loop of Object.values(state.agents)) {
168 if (seen.has(loop.id) || isEnded(loop.status) || !loop.isListed) agents[loop.id] = loop;
169 else agents[loop.id] = { ...loop, status: "gone", endedAt: now, lastEventAt: now, calls: [] };
170 }
171 for (const info of listed) {
172 const existing = agents[info.id];
173 const base =
174 existing ??
175 node(info.id, now, { type: info.type, description: info.description, isTeammate: info.teammateId !== undefined });
176 const isChanged = existing !== undefined && existing.status !== info.status;
177 agents[info.id] = {
178 ...base,
179 status: info.status,
180 endedAt: isEnded(info.status) ? (base.endedAt ?? now) : null,
181 lastEventAt: isChanged ? now : base.lastEventAt,
182 calls: isEnded(info.status) ? [] : base.calls,
183 parentId: base.parentId ?? info.parentId ?? null,
184 name: info.teammateId !== undefined ? (info.name ?? base.name) : base.name,
185 isListed: true,
186 };
187 }
188 return { ...state, agents };
189}
190src/core/rows.ts 109 lines1// The tree as the pane draws it: one row per loop in depth-first order, the
2// header's counts, and whether anything runs (the timers' condition).
3import { isEnded, type Call, type FlowNode, type FlowState, type Status } from "./flow.ts";
4
5const SLOW_TOOL_MS = 30_000;
6const STALE_MS = 120_000;
7
8// What makes a row stand out, strongest first.
9export type Signal = "approval" | "slow-tool" | "stale" | null;
10
11export type Row = {
12 id: string;
13 depth: number; // 0 for the main loop
14 status: Status;
15 type: string;
16 label: string; // name, or else description
17 elapsed: string;
18 activity: string | null; // the oldest call, or the prompt waiting
19 toolCalls: number;
20 tokens: string | null; // once ended
21 signal: Signal;
22};
23
24export type Counts = { running: number; done: number; waiting: number };
25
26// `12s`, `3m05s`, `1h02m`.
27export function duration(ms: number): string {
28 const seconds = Math.max(0, Math.floor(ms / 1000));
29 const twoDigits = (value: number) => String(value).padStart(2, "0");
30 if (seconds < 60) return `${seconds}s`;
31 if (seconds < 3600) return `${Math.floor(seconds / 60)}m${twoDigits(seconds % 60)}s`;
32 return `${Math.floor(seconds / 3600)}h${twoDigits(Math.floor(seconds / 60) % 60)}m`;
33}
34
35// `820 tok`, `4.1k tok`, `1.2M tok`.
36export function tokens(count: number): string {
37 if (count < 1000) return `${count} tok`;
38 if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k tok`;
39 return `${(count / 1_000_000).toFixed(1)}M tok`;
40}
41
42const waitingCall = (loop: FlowNode): Call | undefined => loop.calls.find((call) => call.isWaiting);
43
44// An Agent call runs as long as the foreground child it waits for, whose own row shows how that goes.
45function signalOf(loop: FlowNode, isRoot: boolean, now: number): Signal {
46 if (waitingCall(loop)) return "approval";
47 const oldest = loop.calls.find((call) => call.tool !== "Agent");
48 if (oldest && now - oldest.since > SLOW_TOOL_MS) return "slow-tool";
49 if (!isRoot && loop.status === "running" && now - loop.lastEventAt > STALE_MS) return "stale";
50 return null;
51}
52
53function activityOf(loop: FlowNode, now: number): string | null {
54 const waiting = waitingCall(loop);
55 if (waiting) return `waiting for approval: ${waiting.tool}`;
56 const oldest = loop.calls[0];
57 return oldest ? `${oldest.tool} ${duration(now - oldest.since)}` : null;
58}
59
60function rowOf(loop: FlowNode, depth: number, now: number): Row {
61 return {
62 id: loop.id,
63 depth,
64 status: loop.status,
65 type: loop.type,
66 label: loop.name ?? loop.description,
67 elapsed: duration((loop.endedAt ?? now) - loop.startedAt),
68 activity: activityOf(loop, now),
69 toolCalls: loop.toolCalls,
70 tokens: isEnded(loop.status) && loop.tokens > 0 ? tokens(loop.tokens) : null,
71 signal: signalOf(loop, depth === 0, now),
72 };
73}
74
75// The main loop, then each agent under its parent, siblings in spawn order; an
76// agent whose parent the tree does not know sits under the main loop.
77export function rowsOf(state: FlowState, now: number): Row[] {
78 const agents = Object.values(state.agents).sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
79 const parentOf = (loop: FlowNode) => (loop.parentId !== null && state.agents[loop.parentId] ? loop.parentId : null);
80 const rows: Row[] = [rowOf(state.root, 0, now)];
81 const walk = (parentId: string | null, depth: number) => {
82 for (const loop of agents) {
83 if (parentOf(loop) !== parentId) continue;
84 rows.push(rowOf(loop, depth, now));
85 walk(loop.id, depth + 1);
86 }
87 };
88 walk(null, 1);
89 return rows;
90}
91
92// The header: agents alive and not idle (pending and held ones too), agents
93// ended, and the loops (the main one too) waiting for approval.
94export function countsOf(state: FlowState): Counts {
95 const agents = Object.values(state.agents);
96 return {
97 running: agents.filter((loop) => !isEnded(loop.status) && loop.status !== "idle").length,
98 done: agents.filter((loop) => isEnded(loop.status)).length,
99 waiting: [state.root, ...agents].filter((loop) => waitingCall(loop)).length,
100 };
101}
102
103// Whether the clock and the list are worth reading: a turn or a call of the
104// main loop, or an agent neither ended nor idle.
105export function isBusy(state: FlowState): boolean {
106 if (state.root.status === "running" || state.root.calls.length > 0) return true;
107 return Object.values(state.agents).some((loop) => !isEnded(loop.status) && loop.status !== "idle");
108}
109src/components/pane.tsx 34 lines1// Maps the pane's view model to elements. No decisions here: see pane-model.ts.
2//
3// A .map() array goes inside a Fragment, and a bare Fragment lays out as a row
4// Box, so it lives inside a column Box.
5import type { Elements as EngineElements } from "claude-code";
6import type { PaneModel } from "./pane-model.ts";
7
8type Elements = Pick<EngineElements["terminal"], "Box" | "Text">;
9
10export function Pane({ Box, Text }: Elements, model: PaneModel) {
11 return (
12 <Box flexDirection="column">
13 <Text dimColor wrap="truncate-end">
14 {model.header}
15 </Text>
16 <Box flexDirection="column">
17 <>
18 {model.lines.map((line) => (
19 <Text
20 {...(line.color ? { color: line.color } : {})}
21 dimColor={line.isDim}
22 bold={line.isBold}
23 wrap="truncate-end"
24 >
25 {line.text}
26 </Text>
27 ))}
28 </>
29 </Box>
30 {model.note ? <Text dimColor>{model.note}</Text> : null}
31 </Box>
32 );
33}
34src/components/pane-model.ts 66 lines1// The pane's view model: the header's counts and one line per row of the tree,
2// with how each line is styled. pane.tsx maps it to elements and decides nothing.
3import { isEnded, type FlowState, type Status } from "../core/flow.ts";
4import { countsOf, rowsOf, type Row } from "../core/rows.ts";
5
6export type PaneLine = { text: string; color: string | null; isDim: boolean; isBold: boolean };
7
8export type PaneModel = {
9 header: string;
10 lines: PaneLine[];
11 note: string | null; // under the main loop while no agent has been seen
12};
13
14const GLYPHS: Record<Status, string> = {
15 pending: "○",
16 running: "●",
17 waiting: "◐",
18 idle: "○",
19 completed: "✓",
20 failed: "✗",
21 killed: "⊘",
22 gone: "?",
23};
24
25// Theme keys, so they follow the person's theme, except failures: raw red, as gh-ci-status does.
26// A running row is uncolored: the colors are kept for what is done or needs attention.
27const STATUS_COLORS: Partial<Record<Status, string>> = {
28 completed: "success",
29 failed: "red",
30};
31const SIGNAL_COLORS = { approval: "permission", "slow-tool": "warning", stale: "warning" } as const;
32
33// `● Explore: find the bug · running 12s · Bash 3s · 4 calls`; the main loop
34// reads `● main · running 12s · Bash 3s`, or `○ main · idle`.
35function textOf(row: Row): string {
36 const isRoot = row.depth === 0;
37 const name = isRoot ? "main" : row.label ? `${row.type}: ${row.label}` : row.type;
38 const clock = isRoot && row.status === "idle" ? "" : ` ${row.elapsed}`;
39 const parts = [
40 `${" ".repeat(row.depth)}${GLYPHS[row.status]} ${name}`,
41 `${row.status}${clock}`,
42 row.activity,
43 isRoot || row.toolCalls === 0 ? null : `${row.toolCalls} ${row.toolCalls === 1 ? "call" : "calls"}`,
44 row.tokens,
45 ];
46 return parts.filter((part) => part !== null).join(" · ");
47}
48
49// A row that needs attention takes its signal's color and bold; an ended one is dimmed.
50const lineOf = (row: Row): PaneLine => ({
51 text: textOf(row),
52 color: row.signal ? SIGNAL_COLORS[row.signal] : (STATUS_COLORS[row.status] ?? null),
53 isDim: row.signal === null && isEnded(row.status),
54 isBold: row.signal !== null,
55});
56
57export function paneModel(state: FlowState, now: number): PaneModel {
58 const counts = countsOf(state);
59 const rows = rowsOf(state, now);
60 return {
61 header: `${counts.running} running · ${counts.done} done · ${counts.waiting} waiting`,
62 lines: rows.map(lineOf),
63 note: rows.length === 1 ? "No subagent or teammate yet." : null,
64 };
65}
66