@clanker-code/pi-subagents 0.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.plans/PLAN-next-changes.md +183 -0
- package/.plans/README.md +14 -0
- package/AGENTS.md +31 -0
- package/CHANGELOG.md +583 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/RELEASE.md +39 -0
- package/dist/abort-resend.d.ts +35 -0
- package/dist/abort-resend.js +71 -0
- package/dist/agent-details.d.ts +17 -0
- package/dist/agent-details.js +22 -0
- package/dist/agent-manager.d.ts +132 -0
- package/dist/agent-manager.js +493 -0
- package/dist/agent-runner.d.ts +165 -0
- package/dist/agent-runner.js +732 -0
- package/dist/agent-tool-description.d.ts +9 -0
- package/dist/agent-tool-description.js +147 -0
- package/dist/agent-types.d.ts +60 -0
- package/dist/agent-types.js +157 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +76 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +149 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +119 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1918 -0
- package/dist/invocation-config.d.ts +25 -0
- package/dist/invocation-config.js +19 -0
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/notifications.d.ts +6 -0
- package/dist/notifications.js +107 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +86 -0
- package/dist/peek.d.ts +37 -0
- package/dist/peek.js +121 -0
- package/dist/prompts.d.ts +40 -0
- package/dist/prompts.js +95 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +135 -0
- package/dist/settings.js +168 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.js +7 -0
- package/dist/ui/agent-tool-rendering.d.ts +34 -0
- package/dist/ui/agent-tool-rendering.js +154 -0
- package/dist/ui/agent-widget-tree.d.ts +33 -0
- package/dist/ui/agent-widget-tree.js +130 -0
- package/dist/ui/agent-widget.d.ts +156 -0
- package/dist/ui/agent-widget.js +408 -0
- package/dist/ui/conversation-viewer.d.ts +47 -0
- package/dist/ui/conversation-viewer.js +290 -0
- package/dist/ui/menu-select.d.ts +20 -0
- package/dist/ui/menu-select.js +46 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +99 -0
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/dist/wait.d.ts +10 -0
- package/dist/wait.js +37 -0
- package/dist/worktree.d.ts +45 -0
- package/dist/worktree.js +160 -0
- package/docs/design/default-extension-tool-exposure.md +56 -0
- package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
- package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
- package/examples/agent-tool-description.md +45 -0
- package/package.json +56 -0
- package/reviews/proposal-structured-output-schema.md +135 -0
- package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
- package/reviews/recursive-subagent-widget-preview.html +137 -0
- package/reviews/recursive-subagent-widget-preview.png +0 -0
- package/reviews/subagent-features-comparison.md +350 -0
- package/src/abort-resend.ts +75 -0
- package/src/agent-details.ts +31 -0
- package/src/agent-manager.ts +596 -0
- package/src/agent-runner.ts +872 -0
- package/src/agent-tool-description.ts +163 -0
- package/src/agent-types.ts +189 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +122 -0
- package/src/custom-agents.ts +160 -0
- package/src/default-agents.ts +123 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +2115 -0
- package/src/invocation-config.ts +42 -0
- package/src/memory.ts +165 -0
- package/src/model-resolver.ts +81 -0
- package/src/notifications.ts +120 -0
- package/src/output-file.ts +96 -0
- package/src/peek.ts +155 -0
- package/src/prompts.ts +129 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +289 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +195 -0
- package/src/ui/agent-tool-rendering.ts +175 -0
- package/src/ui/agent-widget-tree.ts +169 -0
- package/src/ui/agent-widget.ts +497 -0
- package/src/ui/conversation-viewer.ts +297 -0
- package/src/ui/menu-select.ts +68 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/usage.ts +60 -0
- package/src/wait.ts +44 -0
- package/src/worktree.ts +191 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
|
+
* Uses the callback form of setWidget for themed rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
9
|
+
import { getConfig } from "../agent-types.js";
|
|
10
|
+
import type { AgentInvocation, SubagentType } from "../types.js";
|
|
11
|
+
import type { LifetimeUsage, SessionLike } from "../usage.js";
|
|
12
|
+
import {
|
|
13
|
+
renderAgentTree,
|
|
14
|
+
type WidgetAgentSnapshot,
|
|
15
|
+
type WidgetDisplayMode,
|
|
16
|
+
} from "./agent-widget-tree.js";
|
|
17
|
+
|
|
18
|
+
// ---- Constants ----
|
|
19
|
+
|
|
20
|
+
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
21
|
+
const MAX_WIDGET_LINES = 12;
|
|
22
|
+
/** Default cap for the status-bar text before the widget knows the terminal width. */
|
|
23
|
+
const DEFAULT_STATUS_TEXT_WIDTH = 20;
|
|
24
|
+
|
|
25
|
+
/** Braille spinner frames for animated running indicator. */
|
|
26
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
27
|
+
|
|
28
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
29
|
+
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
30
|
+
|
|
31
|
+
/** Tool name → human-readable action for activity descriptions. */
|
|
32
|
+
const TOOL_DISPLAY: Record<string, string> = {
|
|
33
|
+
read: "reading",
|
|
34
|
+
bash: "running command",
|
|
35
|
+
edit: "editing",
|
|
36
|
+
write: "writing",
|
|
37
|
+
grep: "searching",
|
|
38
|
+
find: "finding files",
|
|
39
|
+
ls: "listing",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---- Types ----
|
|
43
|
+
|
|
44
|
+
export type Theme = {
|
|
45
|
+
fg(color: string, text: string): string;
|
|
46
|
+
bold(text: string): string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type UICtx = {
|
|
50
|
+
setStatus(key: string, text: string | undefined): void;
|
|
51
|
+
setWidget(
|
|
52
|
+
key: string,
|
|
53
|
+
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
|
|
54
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
55
|
+
): void;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Per-agent live activity state. */
|
|
59
|
+
export interface AgentActivity {
|
|
60
|
+
activeTools: Map<string, string>;
|
|
61
|
+
toolUses: number;
|
|
62
|
+
responseText: string;
|
|
63
|
+
session?: SessionLike;
|
|
64
|
+
/** Current turn count. */
|
|
65
|
+
turnCount: number;
|
|
66
|
+
/** Effective max turns for this agent (undefined = unlimited). */
|
|
67
|
+
maxTurns?: number;
|
|
68
|
+
/** Lifetime usage breakdown — see LifetimeUsage docs. */
|
|
69
|
+
lifetimeUsage: LifetimeUsage;
|
|
70
|
+
/** Last rendered activity description and the time it became current. */
|
|
71
|
+
activityDescription?: string;
|
|
72
|
+
activityDescriptionUpdatedAt?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Metadata attached to Agent tool results for custom rendering. */
|
|
76
|
+
export interface AgentDetails {
|
|
77
|
+
displayName: string;
|
|
78
|
+
description: string;
|
|
79
|
+
subagentType: string;
|
|
80
|
+
toolUses: number;
|
|
81
|
+
tokens: string;
|
|
82
|
+
durationMs: number;
|
|
83
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
|
|
84
|
+
/** Human-readable description of what the agent is currently doing. */
|
|
85
|
+
activity?: string;
|
|
86
|
+
/** Current spinner frame index (for animated running indicator). */
|
|
87
|
+
spinnerFrame?: number;
|
|
88
|
+
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
|
|
89
|
+
modelName?: string;
|
|
90
|
+
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
|
|
91
|
+
tags?: string[];
|
|
92
|
+
/** Current turn count. */
|
|
93
|
+
turnCount?: number;
|
|
94
|
+
/** Effective max turns (undefined = unlimited). */
|
|
95
|
+
maxTurns?: number;
|
|
96
|
+
agentId?: string;
|
|
97
|
+
error?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- Formatting helpers ----
|
|
101
|
+
|
|
102
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
103
|
+
export function formatTokens(count: number): string {
|
|
104
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
|
|
105
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
|
|
106
|
+
return `${count} token`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Format a model context window: "200k", "1M". */
|
|
110
|
+
export function formatContextWindow(tokens: number): string {
|
|
111
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
112
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
113
|
+
return String(tokens);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Token count with optional context-fill % and compaction-count annotations.
|
|
118
|
+
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
119
|
+
* Compaction count rendered as `⇊N` in dim.
|
|
120
|
+
*
|
|
121
|
+
* "12.3k token" — no annotations
|
|
122
|
+
* "12.3k token (45%)" — percent only
|
|
123
|
+
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
|
|
124
|
+
* "12.3k token (45% · ⇊2)" — both
|
|
125
|
+
*/
|
|
126
|
+
export function formatSessionTokens(
|
|
127
|
+
tokens: number,
|
|
128
|
+
percent: number | null,
|
|
129
|
+
theme: Theme,
|
|
130
|
+
compactions = 0,
|
|
131
|
+
): string {
|
|
132
|
+
const tokenStr = formatTokens(tokens);
|
|
133
|
+
const annot: string[] = [];
|
|
134
|
+
if (percent !== null) {
|
|
135
|
+
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
|
136
|
+
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
137
|
+
}
|
|
138
|
+
if (compactions > 0) {
|
|
139
|
+
annot.push(theme.fg("dim", `⇊${compactions}`));
|
|
140
|
+
}
|
|
141
|
+
if (annot.length === 0) return tokenStr;
|
|
142
|
+
return `${tokenStr} (${annot.join(" · ")})`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
146
|
+
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
147
|
+
return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format milliseconds as a compact, humanized duration using the two largest
|
|
152
|
+
* relevant units: "12.3s", "5m 12s", "1h 5m". A bare seconds value keeps one
|
|
153
|
+
* decimal (matches the old output for sub-minute durations); larger units drop
|
|
154
|
+
* decimals so "12m 3s" never reads "12.0m 3s".
|
|
155
|
+
*/
|
|
156
|
+
export function formatMs(ms: number): string {
|
|
157
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
158
|
+
const totalSeconds = ms / 1000;
|
|
159
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
|
160
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
161
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
162
|
+
if (totalMinutes < 60) return `${totalMinutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
|
|
163
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
164
|
+
const minutes = totalMinutes % 60;
|
|
165
|
+
return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Format duration from start/completed timestamps. */
|
|
169
|
+
export function formatDuration(startedAt: number, completedAt?: number): string {
|
|
170
|
+
if (completedAt) return formatMs(completedAt - startedAt);
|
|
171
|
+
return `${formatMs(Date.now() - startedAt)} (running)`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
175
|
+
export function getDisplayName(type: SubagentType): string {
|
|
176
|
+
return getConfig(type).displayName;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
180
|
+
export function getPromptModeLabel(type: SubagentType): string | undefined {
|
|
181
|
+
const config = getConfig(type);
|
|
182
|
+
return config.promptMode === "append" ? "twin" : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function truncatePlainText(text: string, width: number): string {
|
|
186
|
+
if (!Number.isFinite(width) || width <= 0) return "";
|
|
187
|
+
const max = Math.floor(width);
|
|
188
|
+
if (text.length <= max) return text;
|
|
189
|
+
if (max <= 1) return "…".slice(0, max);
|
|
190
|
+
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Status bar text for the subagents entry, truncated to the available width. */
|
|
194
|
+
export function formatSubagentStatusText(
|
|
195
|
+
runningCount: number,
|
|
196
|
+
queuedCount: number,
|
|
197
|
+
width = DEFAULT_STATUS_TEXT_WIDTH,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (runningCount > 0) parts.push(`${runningCount} running`);
|
|
201
|
+
if (queuedCount > 0) parts.push(`${queuedCount} queued`);
|
|
202
|
+
if (parts.length === 0) return undefined;
|
|
203
|
+
const total = runningCount + queuedCount;
|
|
204
|
+
return truncatePlainText(`${parts.join(", ")} agent${total === 1 ? "" : "s"}`, width);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
208
|
+
export function buildInvocationTags(
|
|
209
|
+
invocation: AgentInvocation | undefined,
|
|
210
|
+
): { modelName?: string; tags: string[] } {
|
|
211
|
+
const tags: string[] = [];
|
|
212
|
+
if (!invocation) return { tags };
|
|
213
|
+
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
|
|
214
|
+
if (invocation.isolated) tags.push("isolated");
|
|
215
|
+
if (invocation.isolation === "worktree") tags.push("worktree");
|
|
216
|
+
if (invocation.inheritContext) tags.push("inherit context");
|
|
217
|
+
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
218
|
+
if (invocation.depth != null) tags.push(`depth: ${invocation.depth}/${invocation.maxDepth ?? 4}`);
|
|
219
|
+
return { modelName: invocation.modelName, tags };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Truncate text to a single line, max `len` chars. */
|
|
223
|
+
function truncateLine(text: string, len = 60): string {
|
|
224
|
+
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
225
|
+
if (line.length <= len) return line;
|
|
226
|
+
return line.slice(0, len) + "…";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
230
|
+
export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
|
|
231
|
+
if (activeTools.size > 0) {
|
|
232
|
+
const groups = new Map<string, number>();
|
|
233
|
+
for (const toolName of activeTools.values()) {
|
|
234
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
235
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const parts: string[] = [];
|
|
239
|
+
for (const [action, count] of groups) {
|
|
240
|
+
if (count > 1) {
|
|
241
|
+
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
|
|
242
|
+
} else {
|
|
243
|
+
parts.push(action);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return parts.join(", ") + "…";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// No tools active — show truncated response text if available
|
|
250
|
+
if (responseText && responseText.trim().length > 0) {
|
|
251
|
+
return truncateLine(responseText);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return "thinking…";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function describeActivityWithAge(
|
|
258
|
+
activeTools: Map<string, string>,
|
|
259
|
+
responseText?: string,
|
|
260
|
+
updatedAt?: number,
|
|
261
|
+
now = Date.now(),
|
|
262
|
+
): string {
|
|
263
|
+
const activity = describeActivity(activeTools, responseText);
|
|
264
|
+
if (updatedAt == null) return activity;
|
|
265
|
+
return `${activity} · ${formatMs(now - updatedAt)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Widget manager ----
|
|
269
|
+
|
|
270
|
+
export class AgentWidget {
|
|
271
|
+
private uiCtx: UICtx | undefined;
|
|
272
|
+
private widgetFrame = 0;
|
|
273
|
+
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
274
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
275
|
+
private finishedTurnAge = new Map<string, number>();
|
|
276
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
277
|
+
private static readonly ERROR_LINGER_TURNS = 2;
|
|
278
|
+
|
|
279
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
280
|
+
private widgetRegistered = false;
|
|
281
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
282
|
+
private tui: any | undefined;
|
|
283
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
284
|
+
private lastStatusText: string | undefined;
|
|
285
|
+
/** Descendant snapshots observed from recursive child managers. */
|
|
286
|
+
private descendantSnapshots = new Map<string, WidgetAgentSnapshot>();
|
|
287
|
+
/** User-selected widget display mode. */
|
|
288
|
+
private displayMode: WidgetDisplayMode = "auto";
|
|
289
|
+
|
|
290
|
+
constructor(
|
|
291
|
+
private manager: AgentManager,
|
|
292
|
+
private agentActivity: Map<string, AgentActivity>,
|
|
293
|
+
) {}
|
|
294
|
+
|
|
295
|
+
setDisplayMode(mode: WidgetDisplayMode) {
|
|
296
|
+
this.displayMode = mode;
|
|
297
|
+
this.update();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
upsertSnapshot(snapshot: WidgetAgentSnapshot) {
|
|
301
|
+
this.descendantSnapshots.set(snapshot.id, snapshot);
|
|
302
|
+
if (snapshot.status !== "running" && snapshot.status !== "queued") {
|
|
303
|
+
this.markFinished(snapshot.id);
|
|
304
|
+
}
|
|
305
|
+
this.update();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
removeSnapshot(id: string) {
|
|
309
|
+
this.descendantSnapshots.delete(id);
|
|
310
|
+
this.update();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
clearSnapshots() {
|
|
314
|
+
this.descendantSnapshots.clear();
|
|
315
|
+
this.update();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
319
|
+
setUICtx(ctx: UICtx) {
|
|
320
|
+
if (ctx !== this.uiCtx) {
|
|
321
|
+
// UICtx changed — the widget registered on the old context is gone.
|
|
322
|
+
// Force re-registration on next update().
|
|
323
|
+
this.uiCtx = ctx;
|
|
324
|
+
this.widgetRegistered = false;
|
|
325
|
+
this.tui = undefined;
|
|
326
|
+
this.lastStatusText = undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Called on each new turn (tool_execution_start).
|
|
332
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
333
|
+
*/
|
|
334
|
+
onTurnStart() {
|
|
335
|
+
// Age all finished agents
|
|
336
|
+
for (const [id, age] of this.finishedTurnAge) {
|
|
337
|
+
this.finishedTurnAge.set(id, age + 1);
|
|
338
|
+
}
|
|
339
|
+
// Trigger a widget refresh (will filter out expired agents)
|
|
340
|
+
this.update();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Ensure the widget update timer is running. */
|
|
344
|
+
ensureTimer() {
|
|
345
|
+
if (!this.widgetInterval) {
|
|
346
|
+
this.widgetInterval = setInterval(() => this.update(), 80);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
351
|
+
private shouldShowFinished(agentId: string, status: string): boolean {
|
|
352
|
+
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
353
|
+
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
|
|
354
|
+
return age < maxAge;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Record an agent as finished (call when agent completes). */
|
|
358
|
+
markFinished(agentId: string) {
|
|
359
|
+
if (!this.finishedTurnAge.has(agentId)) {
|
|
360
|
+
this.finishedTurnAge.set(agentId, 0);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private recordToSnapshot(a: any): WidgetAgentSnapshot {
|
|
365
|
+
const activity = this.agentActivity.get(a.id);
|
|
366
|
+
return {
|
|
367
|
+
id: a.id,
|
|
368
|
+
type: a.type,
|
|
369
|
+
description: a.description,
|
|
370
|
+
status: a.status,
|
|
371
|
+
startedAt: a.startedAt,
|
|
372
|
+
completedAt: a.completedAt,
|
|
373
|
+
error: a.error,
|
|
374
|
+
toolUses: activity?.toolUses ?? a.toolUses ?? 0,
|
|
375
|
+
depth: a.depth,
|
|
376
|
+
parentAgentId: a.parentAgentId,
|
|
377
|
+
invocation: a.invocation,
|
|
378
|
+
activity,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private visibleSnapshots(): WidgetAgentSnapshot[] {
|
|
383
|
+
const merged = new Map(this.descendantSnapshots);
|
|
384
|
+
const allAgents = this.manager.listAgents();
|
|
385
|
+
for (const a of allAgents) {
|
|
386
|
+
if (a.status === "running" || a.status === "queued" || (a.completedAt && this.shouldShowFinished(a.id, a.status))) {
|
|
387
|
+
merged.set(a.id, this.recordToSnapshot(a));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const [id, snapshot] of merged) {
|
|
391
|
+
if (snapshot.status !== "running" && snapshot.status !== "queued" && snapshot.completedAt && !this.shouldShowFinished(id, snapshot.status)) {
|
|
392
|
+
merged.delete(id);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return [...merged.values()];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Render the widget content. Called from the registered widget's render() callback,
|
|
400
|
+
* reading live state each time instead of capturing it in a closure.
|
|
401
|
+
*/
|
|
402
|
+
private renderWidget(tui: any, theme: Theme): string[] {
|
|
403
|
+
const snapshots = this.visibleSnapshots();
|
|
404
|
+
if (snapshots.length === 0) return [];
|
|
405
|
+
|
|
406
|
+
return renderAgentTree(snapshots, {
|
|
407
|
+
mode: this.displayMode,
|
|
408
|
+
width: tui.terminal.columns,
|
|
409
|
+
maxLines: MAX_WIDGET_LINES,
|
|
410
|
+
theme,
|
|
411
|
+
frame: SPINNER[this.widgetFrame % SPINNER.length],
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Force an immediate widget update. */
|
|
416
|
+
update() {
|
|
417
|
+
if (!this.uiCtx) return;
|
|
418
|
+
const allAgents = this.manager.listAgents();
|
|
419
|
+
const snapshots = this.visibleSnapshots();
|
|
420
|
+
|
|
421
|
+
// Lightweight existence checks — full categorization happens in renderWidget()
|
|
422
|
+
let runningCount = 0;
|
|
423
|
+
let queuedCount = 0;
|
|
424
|
+
let hasFinished = false;
|
|
425
|
+
for (const a of snapshots) {
|
|
426
|
+
if (a.status === "running") { runningCount++; }
|
|
427
|
+
else if (a.status === "queued") { queuedCount++; }
|
|
428
|
+
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
|
|
429
|
+
}
|
|
430
|
+
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
431
|
+
|
|
432
|
+
// Nothing to show — clear widget
|
|
433
|
+
if (!hasActive && !hasFinished) {
|
|
434
|
+
if (this.widgetRegistered) {
|
|
435
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
436
|
+
this.widgetRegistered = false;
|
|
437
|
+
this.tui = undefined;
|
|
438
|
+
}
|
|
439
|
+
if (this.lastStatusText !== undefined) {
|
|
440
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
441
|
+
this.lastStatusText = undefined;
|
|
442
|
+
}
|
|
443
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
444
|
+
// Clean up stale entries
|
|
445
|
+
for (const [id] of this.finishedTurnAge) {
|
|
446
|
+
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Status bar — only call setStatus when the text actually changes
|
|
452
|
+
const statusWidth = this.tui?.terminal.columns ?? DEFAULT_STATUS_TEXT_WIDTH;
|
|
453
|
+
const newStatusText = hasActive
|
|
454
|
+
? formatSubagentStatusText(runningCount, queuedCount, statusWidth)
|
|
455
|
+
: undefined;
|
|
456
|
+
if (newStatusText !== this.lastStatusText) {
|
|
457
|
+
this.uiCtx.setStatus("subagents", newStatusText);
|
|
458
|
+
this.lastStatusText = newStatusText;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
this.widgetFrame++;
|
|
462
|
+
|
|
463
|
+
// Register widget callback once; subsequent updates use requestRender()
|
|
464
|
+
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
465
|
+
if (!this.widgetRegistered) {
|
|
466
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
467
|
+
this.tui = tui;
|
|
468
|
+
return {
|
|
469
|
+
render: () => this.renderWidget(tui, theme),
|
|
470
|
+
invalidate: () => {
|
|
471
|
+
// Theme changed — force re-registration so factory captures fresh theme.
|
|
472
|
+
this.widgetRegistered = false;
|
|
473
|
+
this.tui = undefined;
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}, { placement: "aboveEditor" });
|
|
477
|
+
this.widgetRegistered = true;
|
|
478
|
+
} else {
|
|
479
|
+
// Widget already registered — just request a re-render of existing components.
|
|
480
|
+
this.tui?.requestRender();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
dispose() {
|
|
485
|
+
if (this.widgetInterval) {
|
|
486
|
+
clearInterval(this.widgetInterval);
|
|
487
|
+
this.widgetInterval = undefined;
|
|
488
|
+
}
|
|
489
|
+
if (this.uiCtx) {
|
|
490
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
491
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
492
|
+
}
|
|
493
|
+
this.widgetRegistered = false;
|
|
494
|
+
this.tui = undefined;
|
|
495
|
+
this.lastStatusText = undefined;
|
|
496
|
+
}
|
|
497
|
+
}
|