pi-subagents 0.35.0 → 0.36.0
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/CHANGELOG.md +59 -0
- package/README.md +132 -24
- package/agents/advisor.md +73 -0
- package/package.json +8 -12
- package/skills/pi-subagents/SKILL.md +22 -9
- package/src/agents/agents.ts +22 -5
- package/src/api/delegation.ts +125 -0
- package/src/extension/config.ts +7 -1
- package/src/extension/index.ts +50 -38
- package/src/extension/rpc.ts +27 -2
- package/src/extension/schemas.ts +22 -2
- package/src/extension/tool-description.ts +2 -2
- package/src/intercom/intercom-bridge.ts +1 -1
- package/src/intercom/native-supervisor-channel.ts +45 -6
- package/src/intercom/result-intercom.ts +7 -0
- package/src/runs/background/async-execution.ts +34 -4
- package/src/runs/background/async-job-tracker.ts +4 -0
- package/src/runs/background/async-resume.ts +27 -5
- package/src/runs/background/async-status.ts +76 -3
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/completion-batcher.ts +6 -4
- package/src/runs/background/completion-dedupe.ts +2 -11
- package/src/runs/background/fleet-view.ts +9 -4
- package/src/runs/background/notify.ts +132 -120
- package/src/runs/background/result-watcher.ts +138 -78
- package/src/runs/background/run-status.ts +3 -1
- package/src/runs/background/subagent-runner.ts +225 -43
- package/src/runs/background/subagent-wait.ts +130 -4
- package/src/runs/background/wait-tool.ts +2 -2
- package/src/runs/foreground/chain-execution.ts +176 -111
- package/src/runs/foreground/execution.ts +90 -36
- package/src/runs/foreground/foreground-control.ts +90 -0
- package/src/runs/foreground/subagent-executor.ts +394 -163
- package/src/runs/shared/acceptance.ts +55 -13
- package/src/runs/shared/agent-contract.ts +38 -0
- package/src/runs/shared/child-protocol.ts +1 -1
- package/src/runs/shared/completion-guard.ts +36 -5
- package/src/runs/shared/context-mode.ts +44 -0
- package/src/runs/shared/dynamic-fanout.ts +4 -4
- package/src/runs/shared/long-running-guard.ts +4 -0
- package/src/runs/shared/nested-events.ts +27 -2
- package/src/runs/shared/parallel-handoff.ts +154 -0
- package/src/runs/shared/parallel-utils.ts +6 -0
- package/src/runs/shared/pi-args.ts +23 -14
- package/src/runs/shared/run-history.ts +90 -5
- package/src/runs/shared/structured-output.ts +112 -7
- package/src/runs/shared/subagent-control.ts +4 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
- package/src/runs/shared/task-intent.ts +10 -5
- package/src/runs/shared/tool-availability.ts +3 -1
- package/src/runs/shared/tool-budget.ts +11 -5
- package/src/runs/shared/turn-budget.ts +2 -1
- package/src/runs/shared/worktree.ts +63 -14
- package/src/shared/accessible-dir.ts +25 -0
- package/src/shared/artifacts.ts +37 -7
- package/src/shared/atomic-json.ts +14 -42
- package/src/shared/child-transcript.ts +52 -0
- package/src/shared/file-system-retry.ts +47 -0
- package/src/shared/settings.ts +9 -1
- package/src/shared/types.ts +211 -22
- package/src/slash/delegation-adapters.ts +152 -5
- package/src/slash/delegation-json.ts +108 -0
- package/src/slash/delegation-request.ts +182 -36
- package/src/slash/prompt-template-bridge.ts +222 -37
- package/src/slash/selector.ts +147 -0
- package/src/slash/slash-commands.ts +14 -5
- package/src/slash/slash-live-state.ts +2 -2
- package/src/slash/subagents-admin.ts +42 -42
- package/src/tui/fleet-status.ts +362 -0
- package/src/tui/fleet-transcript.ts +472 -0
- package/src/tui/fleet.ts +318 -59
- package/src/tui/render.ts +25 -15
- package/src/watchdog/change-signature.ts +105 -12
- package/src/watchdog/review.ts +7 -2
- package/src/watchdog/runtime.ts +5 -3
- package/src/slash/subagents-editor.ts +0 -86
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import {
|
|
@@ -15,7 +14,7 @@ import {
|
|
|
15
14
|
import { serializeAgent } from "../agents/agent-serializer.ts";
|
|
16
15
|
import { editableAgentConfig, preservedAgentFrontmatterFields } from "../agents/agent-management.ts";
|
|
17
16
|
import { findModelInfo, getSupportedThinkingLevels, toModelInfo } from "../shared/model-info.ts";
|
|
18
|
-
import {
|
|
17
|
+
import { SelectorComponent, type SelectorItem, type SelectorResult } from "./selector.ts";
|
|
19
18
|
|
|
20
19
|
const ADMIN_MESSAGE_TYPE = "subagents-admin";
|
|
21
20
|
const INHERIT_MODEL_CHOICE = "Default / inherit session model";
|
|
@@ -52,6 +51,10 @@ function agentChoices(agents: AgentConfig[]): Map<string, AgentConfig> {
|
|
|
52
51
|
}));
|
|
53
52
|
}
|
|
54
53
|
|
|
54
|
+
function agentSelectItems(byLabel: Map<string, AgentConfig>): SelectorItem[] {
|
|
55
|
+
return [...byLabel.keys()].map((label) => ({ value: label, label }));
|
|
56
|
+
}
|
|
57
|
+
|
|
55
58
|
function agentMatches(agent: AgentConfig, rawName: string): boolean {
|
|
56
59
|
const name = rawName.trim();
|
|
57
60
|
return agent.name === name || frontmatterNameForConfig(agent) === name;
|
|
@@ -156,7 +159,7 @@ async function selectAgent(ctx: ExtensionContext, args: string): Promise<AgentSe
|
|
|
156
159
|
if (matches.length > 1 && !ctx.hasUI) return { kind: "ambiguous", requestedName, matches };
|
|
157
160
|
if (matches.length > 1) {
|
|
158
161
|
const byLabel = agentChoices(matches);
|
|
159
|
-
const choice = await ctx
|
|
162
|
+
const choice = await selectFromList(ctx, `Multiple subagents named '${requestedName}'`, undefined, agentSelectItems(byLabel));
|
|
160
163
|
return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
|
|
161
164
|
}
|
|
162
165
|
return { kind: "not-found", agents, requestedName };
|
|
@@ -164,7 +167,7 @@ async function selectAgent(ctx: ExtensionContext, args: string): Promise<AgentSe
|
|
|
164
167
|
|
|
165
168
|
if (!ctx.hasUI) return { kind: "not-found", agents };
|
|
166
169
|
const byLabel = agentChoices(agents);
|
|
167
|
-
const choice = await ctx
|
|
170
|
+
const choice = await selectFromList(ctx, "Select subagent", undefined, agentSelectItems(byLabel));
|
|
168
171
|
return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
|
|
169
172
|
}
|
|
170
173
|
|
|
@@ -198,13 +201,33 @@ function metadataFor(agent: AgentConfig): string {
|
|
|
198
201
|
return lines.join("\n");
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
async function selectFromList(ctx: ExtensionContext, title: string, subtitle: string | undefined, items: SelectorItem[]): Promise<string | undefined> {
|
|
205
|
+
if (typeof ctx.ui.custom === "function") {
|
|
206
|
+
const result = await ctx.ui.custom<SelectorResult>(
|
|
207
|
+
(tui, theme, kb, done) => new SelectorComponent(tui, theme, kb, { title, subtitle, items, done }),
|
|
208
|
+
{ overlay: false },
|
|
209
|
+
);
|
|
210
|
+
return result?.confirmed ? result.value : undefined;
|
|
211
|
+
}
|
|
212
|
+
const flatTitle = subtitle ? `${title}\nCurrent: ${subtitle}` : title;
|
|
213
|
+
const labelToValue = new Map(items.map((item) => [item.label, item.value] as const));
|
|
214
|
+
const choice = await ctx.ui.select(flatTitle, items.map((item) => item.value));
|
|
215
|
+
return choice ? (items.find((item) => item.value === choice)?.value ?? labelToValue.get(choice)) : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
201
218
|
async function chooseModel(ctx: ExtensionContext, agent: AgentConfig): Promise<string | undefined | null> {
|
|
202
|
-
const models = liveAvailableModels(ctx)
|
|
219
|
+
const models = liveAvailableModels(ctx);
|
|
203
220
|
const current = agent.model ?? INHERIT_MODEL_CHOICE;
|
|
204
|
-
const
|
|
205
|
-
if (agent.model && !
|
|
206
|
-
|
|
207
|
-
|
|
221
|
+
const items: SelectorItem[] = [{ value: INHERIT_MODEL_CHOICE, label: INHERIT_MODEL_CHOICE, current: !agent.model }];
|
|
222
|
+
if (agent.model && !models.some((model) => modelFullId(model) === agent.model)) {
|
|
223
|
+
items.push({ value: agent.model, label: agent.model, current: true });
|
|
224
|
+
}
|
|
225
|
+
for (const model of models) {
|
|
226
|
+
const fullId = modelFullId(model);
|
|
227
|
+
items.push({ value: fullId, label: model.id, badge: model.provider, current: fullId === agent.model });
|
|
228
|
+
}
|
|
229
|
+
const choice = await selectFromList(ctx, `Select model for ${agent.name}`, current, items);
|
|
230
|
+
if (choice === undefined) return null;
|
|
208
231
|
return choice === INHERIT_MODEL_CHOICE ? undefined : choice;
|
|
209
232
|
}
|
|
210
233
|
|
|
@@ -214,18 +237,16 @@ async function chooseThinking(ctx: ExtensionContext, agent: AgentConfig): Promis
|
|
|
214
237
|
const modelInfo = findModelInfo(effectiveModel, availableModels, ctx.model?.provider);
|
|
215
238
|
const levels = getSupportedThinkingLevels(modelInfo);
|
|
216
239
|
const current = agent.thinking === false ? "off" : agent.thinking ?? INHERIT_THINKING_CHOICE;
|
|
217
|
-
const
|
|
218
|
-
if (current !== INHERIT_THINKING_CHOICE && !
|
|
240
|
+
const values: string[] = [INHERIT_THINKING_CHOICE, ...levels];
|
|
241
|
+
if (current !== INHERIT_THINKING_CHOICE && !values.includes(current)) values.splice(1, 0, current);
|
|
219
242
|
const modelNote = agent.model
|
|
220
243
|
? `Model: ${agent.model}`
|
|
221
244
|
: effectiveModel
|
|
222
245
|
? `Session model: ${effectiveModel}`
|
|
223
246
|
: "Model: default / inherit";
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
);
|
|
228
|
-
if (!choice) return null;
|
|
247
|
+
const items: SelectorItem[] = values.map((value) => ({ value, label: value, current: value === current }));
|
|
248
|
+
const choice = await selectFromList(ctx, `Select thinking level for ${agent.name}`, `${modelNote} · ${current}`, items);
|
|
249
|
+
if (choice === undefined) return null;
|
|
229
250
|
return choice === INHERIT_THINKING_CHOICE ? undefined : choice;
|
|
230
251
|
}
|
|
231
252
|
|
|
@@ -327,38 +348,17 @@ async function saveAgentSystemPrompt(ctx: ExtensionContext, agent: AgentConfig,
|
|
|
327
348
|
return `Updated '${agent.name}' system prompt in ${updated.filePath}.`;
|
|
328
349
|
}
|
|
329
350
|
|
|
330
|
-
/**
|
|
331
|
-
* Round-trip the agent's system prompt through the user's external editor: write it to a
|
|
332
|
-
* temp .md file, open the editor and wait for it to close, then persist the result.
|
|
333
|
-
* Returns the status message, or null when nothing should be posted.
|
|
334
|
-
*/
|
|
335
351
|
async function editSystemPrompt(ctx: ExtensionContext, agent: AgentConfig): Promise<string | null> {
|
|
336
352
|
if (!savesThroughSettings(agent, "systemPrompt")) {
|
|
337
353
|
const readOnlyMessage = readOnlyAgentMessage(agent, "systemPrompt");
|
|
338
354
|
if (readOnlyMessage) return readOnlyMessage;
|
|
339
355
|
}
|
|
340
|
-
const
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const label = editorLabel(editor);
|
|
345
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-prompt-"));
|
|
346
|
-
const safeName = path.basename(agent.name.replace(/[^a-z0-9._-]/gi, "_")) || "agent";
|
|
347
|
-
const tmpPath = path.join(dir, `${safeName}.md`);
|
|
348
|
-
try {
|
|
349
|
-
fs.writeFileSync(tmpPath, agent.systemPrompt ?? "", "utf-8");
|
|
350
|
-
ctx.ui.notify(`Editing '${agent.name}' system prompt in ${label}. Save and close the editor window to apply.`, "info");
|
|
351
|
-
ctx.ui.setStatus("subagents-edit", `Waiting for ${label} to close…`);
|
|
352
|
-
await runEditorAndWait(editor, tmpPath);
|
|
353
|
-
const edited = fs.readFileSync(tmpPath, "utf-8");
|
|
354
|
-
if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
|
|
355
|
-
return `System prompt for '${agent.name}' left unchanged.`;
|
|
356
|
-
}
|
|
357
|
-
return await saveAgentSystemPrompt(ctx, agent, edited);
|
|
358
|
-
} finally {
|
|
359
|
-
ctx.ui.setStatus("subagents-edit", undefined);
|
|
360
|
-
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort cleanup */ }
|
|
356
|
+
const edited = await ctx.ui.editor(`Edit '${agent.name}' system prompt`, agent.systemPrompt ?? "");
|
|
357
|
+
if (edited === undefined) return null;
|
|
358
|
+
if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
|
|
359
|
+
return `System prompt for '${agent.name}' left unchanged.`;
|
|
361
360
|
}
|
|
361
|
+
return saveAgentSystemPrompt(ctx, agent, edited);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
export async function openSubagentsAdmin(pi: ExtensionAPI, ctx: ExtensionContext, args = ""): Promise<void> {
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AsyncJobStep, SubagentState } from "../shared/types.ts";
|
|
4
|
+
|
|
5
|
+
export const FLEET_STATUS_WIDGET_KEY = "subagent-fleet-status";
|
|
6
|
+
|
|
7
|
+
const MAX_AGENT_ROWS = 5;
|
|
8
|
+
const REFRESH_MS = 500;
|
|
9
|
+
|
|
10
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
11
|
+
type FleetStatusTui = {
|
|
12
|
+
requestRender(): void;
|
|
13
|
+
};
|
|
14
|
+
type FleetStatusEntry = {
|
|
15
|
+
key: string;
|
|
16
|
+
agent: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
tokens: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FleetStatusOptions {
|
|
23
|
+
refreshMs?: number;
|
|
24
|
+
maxAgentRows?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatFleetElapsed(ms: number): string {
|
|
28
|
+
return `${Math.max(0, Math.round(ms / 1000))}s`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatFleetTokens(count: number): string {
|
|
32
|
+
let compact: string;
|
|
33
|
+
if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
|
|
34
|
+
else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
|
|
35
|
+
else compact = `${Math.max(0, Math.round(count))}`;
|
|
36
|
+
return `↓ ${compact} tokens`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rightAlign(left: string, right: string, width: number): string {
|
|
40
|
+
const rightWidth = visibleWidth(right);
|
|
41
|
+
const maxLeftWidth = Math.max(0, width - rightWidth - 1);
|
|
42
|
+
const leftClamped = truncateToWidth(left, maxLeftWidth);
|
|
43
|
+
const gap = Math.max(1, width - visibleWidth(leftClamped) - rightWidth);
|
|
44
|
+
return truncateToWidth(`${leftClamped}${" ".repeat(gap)}${right}`, width);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isActiveState(value: string): boolean {
|
|
48
|
+
return value === "running" || value === "queued" || value === "pending";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntry[] {
|
|
52
|
+
const entries: FleetStatusEntry[] = [];
|
|
53
|
+
for (const control of state.foregroundControls.values()) {
|
|
54
|
+
if (control.activeChildren) {
|
|
55
|
+
for (const child of [...control.activeChildren.values()].sort((left, right) => left.index - right.index)) {
|
|
56
|
+
entries.push({
|
|
57
|
+
key: `foreground-active:${control.runId}:${child.index}`,
|
|
58
|
+
agent: child.agent,
|
|
59
|
+
description: child.description,
|
|
60
|
+
startedAt: child.startedAt,
|
|
61
|
+
tokens: child.tokens ?? 0,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
entries.push({
|
|
67
|
+
key: `foreground-active:${control.runId}:${control.currentIndex ?? 0}`,
|
|
68
|
+
agent: control.currentAgent ?? control.mode,
|
|
69
|
+
description: control.description,
|
|
70
|
+
startedAt: control.startedAt,
|
|
71
|
+
tokens: control.tokens ?? 0,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const job of state.asyncJobs.values()) {
|
|
76
|
+
if (!isActiveState(job.status)) continue;
|
|
77
|
+
const startedAt = job.startedAt ?? job.updatedAt ?? Date.now();
|
|
78
|
+
const steps: AsyncJobStep[] | undefined = job.steps?.length
|
|
79
|
+
? job.steps
|
|
80
|
+
: job.agents?.map((agent, index) => {
|
|
81
|
+
const pending = job.status === "queued"
|
|
82
|
+
|| (job.mode === "chain" && !job.activeParallelGroup && index !== (job.currentStep ?? 0));
|
|
83
|
+
return { agent, index, status: pending ? "pending" : "running" };
|
|
84
|
+
});
|
|
85
|
+
if (!steps?.length) {
|
|
86
|
+
entries.push({
|
|
87
|
+
key: `async:${job.asyncId}`,
|
|
88
|
+
agent: job.mode ?? "subagent",
|
|
89
|
+
description: job.description,
|
|
90
|
+
startedAt,
|
|
91
|
+
tokens: job.totalTokens?.total ?? 0,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const [offset, step] of steps.entries()) {
|
|
96
|
+
if (!isActiveState(step.status)) continue;
|
|
97
|
+
const index = step.index ?? offset;
|
|
98
|
+
if (step.status === "pending" && job.mode === "chain" && !job.activeParallelGroup && index !== (job.currentStep ?? 0)) continue;
|
|
99
|
+
entries.push({
|
|
100
|
+
key: `async:${job.asyncId}:${index}`,
|
|
101
|
+
agent: step.label ? `${step.label} (${step.agent})` : step.agent,
|
|
102
|
+
description: job.description,
|
|
103
|
+
startedAt: step.startedAt ?? startedAt,
|
|
104
|
+
tokens: step.tokens?.total ?? (steps.length === 1 ? job.totalTokens?.total ?? 0 : 0),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return entries.sort((left, right) => left.startedAt - right.startedAt || left.key.localeCompare(right.key));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class SubagentFleetStatus {
|
|
113
|
+
private ctx: ExtensionContext | undefined;
|
|
114
|
+
private tui: FleetStatusTui | undefined;
|
|
115
|
+
private inputUnsubscribe: (() => void) | undefined;
|
|
116
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
117
|
+
private widgetRegistered = false;
|
|
118
|
+
private active = false;
|
|
119
|
+
private selectedKey = "main";
|
|
120
|
+
private inspectorOpen = false;
|
|
121
|
+
private lastRenderKey = "";
|
|
122
|
+
private entries: FleetStatusEntry[] = [];
|
|
123
|
+
private readonly state: SubagentState;
|
|
124
|
+
private readonly openInspector: (itemKey: string) => Promise<void> | void;
|
|
125
|
+
private readonly refreshMs: number;
|
|
126
|
+
private readonly maxAgentRows: number;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
state: SubagentState,
|
|
130
|
+
openInspector: (itemKey: string) => Promise<void> | void,
|
|
131
|
+
options: FleetStatusOptions = {},
|
|
132
|
+
) {
|
|
133
|
+
this.state = state;
|
|
134
|
+
this.openInspector = openInspector;
|
|
135
|
+
this.refreshMs = options.refreshMs ?? REFRESH_MS;
|
|
136
|
+
this.maxAgentRows = options.maxAgentRows ?? MAX_AGENT_ROWS;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setContext(ctx: ExtensionContext): void {
|
|
140
|
+
if (!ctx.hasUI) return;
|
|
141
|
+
if (this.ctx?.ui === ctx.ui) {
|
|
142
|
+
this.ctx = ctx;
|
|
143
|
+
this.refresh();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.clearUiRegistration();
|
|
147
|
+
this.ctx = ctx;
|
|
148
|
+
if (typeof ctx.ui.onTerminalInput === "function") {
|
|
149
|
+
this.inputUnsubscribe = ctx.ui.onTerminalInput((data) => this.handleKey(data));
|
|
150
|
+
}
|
|
151
|
+
this.timer = setInterval(() => this.refresh(), this.refreshMs);
|
|
152
|
+
this.timer.unref?.();
|
|
153
|
+
this.refresh();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
dispose(): void {
|
|
157
|
+
this.clearUiRegistration();
|
|
158
|
+
this.ctx = undefined;
|
|
159
|
+
this.entries = [];
|
|
160
|
+
this.active = false;
|
|
161
|
+
this.selectedKey = "main";
|
|
162
|
+
this.inspectorOpen = false;
|
|
163
|
+
this.lastRenderKey = "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
refresh(): void {
|
|
167
|
+
const ctx = this.ctx;
|
|
168
|
+
if (!ctx?.hasUI) return;
|
|
169
|
+
this.entries = collectFleetStatusEntries(this.state);
|
|
170
|
+
this.clampSelection();
|
|
171
|
+
if (this.inspectorOpen || this.state.fleetInspectorOpen) {
|
|
172
|
+
this.lastRenderKey = "";
|
|
173
|
+
if (this.widgetRegistered) {
|
|
174
|
+
ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
|
|
175
|
+
this.widgetRegistered = false;
|
|
176
|
+
this.tui = undefined;
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (this.entries.length === 0) {
|
|
181
|
+
this.active = false;
|
|
182
|
+
this.selectedKey = "main";
|
|
183
|
+
this.lastRenderKey = "";
|
|
184
|
+
if (this.widgetRegistered) {
|
|
185
|
+
ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
|
|
186
|
+
this.widgetRegistered = false;
|
|
187
|
+
this.tui = undefined;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const renderKey = this.getRenderKey();
|
|
193
|
+
if (!this.widgetRegistered) {
|
|
194
|
+
ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, (tui, theme) => {
|
|
195
|
+
this.tui = tui;
|
|
196
|
+
return {
|
|
197
|
+
render: (width: number) => this.render(width, theme),
|
|
198
|
+
invalidate: () => {
|
|
199
|
+
this.lastRenderKey = "";
|
|
200
|
+
},
|
|
201
|
+
dispose: () => {
|
|
202
|
+
if (this.tui !== tui) return;
|
|
203
|
+
this.widgetRegistered = false;
|
|
204
|
+
this.tui = undefined;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}, { placement: "belowEditor" });
|
|
208
|
+
this.widgetRegistered = true;
|
|
209
|
+
this.lastRenderKey = renderKey;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (renderKey === this.lastRenderKey) return;
|
|
213
|
+
this.lastRenderKey = renderKey;
|
|
214
|
+
this.tui?.requestRender();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
handleKey(data: string): { consume?: boolean; data?: string } | undefined {
|
|
218
|
+
const ctx = this.ctx;
|
|
219
|
+
if (!ctx?.hasUI || this.entries.length === 0 || isKeyRelease(data)) return undefined;
|
|
220
|
+
if (this.inspectorOpen) return undefined;
|
|
221
|
+
if (!this.editorHasFocus()) {
|
|
222
|
+
if (this.active) this.deactivate();
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!this.active) {
|
|
227
|
+
const activates = matchesKey(data, "down") || matchesKey(data, "left");
|
|
228
|
+
if (!activates || ctx.ui.getEditorText() !== "") return undefined;
|
|
229
|
+
this.active = true;
|
|
230
|
+
this.selectedKey = "main";
|
|
231
|
+
this.refresh();
|
|
232
|
+
return { consume: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const roster = this.rosterKeys();
|
|
236
|
+
const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
|
|
237
|
+
if (matchesKey(data, "down")) {
|
|
238
|
+
this.selectedKey = roster[Math.min(roster.length - 1, selectedIndex + 1)] ?? "main";
|
|
239
|
+
this.refresh();
|
|
240
|
+
return { consume: true };
|
|
241
|
+
}
|
|
242
|
+
if (matchesKey(data, "up")) {
|
|
243
|
+
if (selectedIndex === 0) {
|
|
244
|
+
this.deactivate();
|
|
245
|
+
return { consume: true };
|
|
246
|
+
}
|
|
247
|
+
this.selectedKey = roster[selectedIndex - 1] ?? "main";
|
|
248
|
+
this.refresh();
|
|
249
|
+
return { consume: true };
|
|
250
|
+
}
|
|
251
|
+
if (matchesKey(data, "escape")) {
|
|
252
|
+
this.deactivate();
|
|
253
|
+
return { consume: true };
|
|
254
|
+
}
|
|
255
|
+
if (matchesKey(data, Key.enter)) {
|
|
256
|
+
if (this.selectedKey === "main") {
|
|
257
|
+
this.deactivate();
|
|
258
|
+
return { consume: true };
|
|
259
|
+
}
|
|
260
|
+
this.inspectorOpen = true;
|
|
261
|
+
this.refresh();
|
|
262
|
+
const selectedKey = this.selectedKey;
|
|
263
|
+
void Promise.resolve()
|
|
264
|
+
.then(() => this.openInspector(selectedKey))
|
|
265
|
+
.catch((error) => ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"))
|
|
266
|
+
.finally(() => {
|
|
267
|
+
this.inspectorOpen = false;
|
|
268
|
+
this.refresh();
|
|
269
|
+
});
|
|
270
|
+
return { consume: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.deactivate();
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
render(width: number, theme: Theme): string[] {
|
|
278
|
+
if (this.entries.length === 0) return [];
|
|
279
|
+
const roster = this.rosterKeys();
|
|
280
|
+
const selectedIndex = Math.max(0, roster.indexOf(this.selectedKey));
|
|
281
|
+
const hint = this.active
|
|
282
|
+
? "↑↓ select · enter inspect · esc back"
|
|
283
|
+
: "esc to interrupt · ← for agents · ↓ to manage";
|
|
284
|
+
const lines = [truncateToWidth(` ${theme.fg("dim", hint)}`, width), ""];
|
|
285
|
+
lines.push(truncateToWidth(` ${this.bullet(0, selectedIndex, theme)} main`, width));
|
|
286
|
+
|
|
287
|
+
const visibleCount = Math.min(this.maxAgentRows, this.entries.length);
|
|
288
|
+
const selectedAgentIndex = Math.max(0, selectedIndex - 1);
|
|
289
|
+
const start = selectedAgentIndex < visibleCount ? 0 : selectedAgentIndex - visibleCount + 1;
|
|
290
|
+
const hiddenBelow = this.entries.length - (start + visibleCount);
|
|
291
|
+
if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
|
|
292
|
+
for (let index = start; index < start + visibleCount; index++) {
|
|
293
|
+
lines.push(this.renderEntry(index + 1, selectedIndex, this.entries[index]!, width, theme));
|
|
294
|
+
}
|
|
295
|
+
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private renderEntry(rosterIndex: number, selectedIndex: number, entry: FleetStatusEntry, width: number, theme: Theme): string {
|
|
300
|
+
const description = entry.description?.replace(/\s+/g, " ").trim();
|
|
301
|
+
const left = ` ${this.bullet(rosterIndex, selectedIndex, theme)} ${theme.fg("muted", entry.agent)}${description ? ` ${description}` : ""}`;
|
|
302
|
+
const elapsed = Date.now() - entry.startedAt;
|
|
303
|
+
const right = theme.fg("dim", `${formatFleetElapsed(elapsed)} · ${formatFleetTokens(entry.tokens)}`);
|
|
304
|
+
return rightAlign(left, right, width);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private bullet(rosterIndex: number, selectedIndex: number, theme: Theme): string {
|
|
308
|
+
return rosterIndex === selectedIndex ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private rosterKeys(): string[] {
|
|
312
|
+
return ["main", ...this.entries.map((entry) => entry.key)];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private clampSelection(): void {
|
|
316
|
+
if (!this.rosterKeys().includes(this.selectedKey)) this.selectedKey = "main";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private deactivate(): void {
|
|
320
|
+
this.active = false;
|
|
321
|
+
this.selectedKey = "main";
|
|
322
|
+
this.refresh();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private editorHasFocus(): boolean {
|
|
326
|
+
// pi-tui exposes focus mutation but no focus getter; fail closed if this compatibility seam disappears.
|
|
327
|
+
const focused = (this.tui as unknown as { focusedComponent?: unknown } | undefined)?.focusedComponent;
|
|
328
|
+
return focused instanceof Editor;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private getRenderKey(): string {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
return JSON.stringify({
|
|
334
|
+
active: this.active,
|
|
335
|
+
selected: this.selectedKey,
|
|
336
|
+
inspectorOpen: this.inspectorOpen,
|
|
337
|
+
entries: this.entries.map((entry) => [
|
|
338
|
+
entry.key,
|
|
339
|
+
entry.agent,
|
|
340
|
+
entry.description,
|
|
341
|
+
Math.round((now - entry.startedAt) / 1000),
|
|
342
|
+
entry.tokens,
|
|
343
|
+
]),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private clearUiRegistration(): void {
|
|
348
|
+
if (this.timer) clearInterval(this.timer);
|
|
349
|
+
this.timer = undefined;
|
|
350
|
+
this.inputUnsubscribe?.();
|
|
351
|
+
this.inputUnsubscribe = undefined;
|
|
352
|
+
if (this.ctx?.hasUI && this.widgetRegistered) {
|
|
353
|
+
try {
|
|
354
|
+
this.ctx.ui.setWidget(FLEET_STATUS_WIDGET_KEY, undefined);
|
|
355
|
+
} catch {
|
|
356
|
+
// The previous extension context may already be stale during reload/session replacement.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
this.widgetRegistered = false;
|
|
360
|
+
this.tui = undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|