@astrofoundry/pi-astro 0.18.5 → 0.18.6
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.
|
@@ -193,7 +193,8 @@ describe("segments", () => {
|
|
|
193
193
|
expect(out).toContain("2.1k");
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
it("renderExtensionStatus uses muted colour", () => {
|
|
196
|
+
it("renderExtensionStatus uses muted colour, unless the status is already styled", () => {
|
|
197
197
|
expect(renderExtensionStatus(fakeTheme, "🪨 caveman:full")).toBe("<muted>🪨 caveman:full</muted>");
|
|
198
|
+
expect(renderExtensionStatus(fakeTheme, "\u001b[36m⠋ astro.arcane 0:05\u001b[0m")).toBe("\u001b[36m⠋ astro.arcane 0:05\u001b[0m");
|
|
198
199
|
});
|
|
199
200
|
});
|
|
@@ -133,6 +133,7 @@ export function renderCache(
|
|
|
133
133
|
return `${theme.fg("muted", icons.cacheRead)}${theme.fg("text", formatTokens(cacheRead))} ${theme.fg("muted", icons.cacheWrite)}${theme.fg("text", formatTokens(cacheWrite))}`;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Statuses that already carry ANSI styling (for example a coloured spinner) render as they are. */
|
|
136
137
|
export function renderExtensionStatus(theme: ThemeFn, value: string): string {
|
|
137
|
-
return theme.fg("muted", value);
|
|
138
|
+
return value.includes("\u001b[") ? value : theme.fg("muted", value);
|
|
138
139
|
}
|
|
@@ -3,10 +3,11 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "typebox";
|
|
6
|
+
import { type Static, Type } from "typebox";
|
|
7
7
|
import { type AgentConfig, type AgentDirs, type AgentScope, BUNDLED_NAMESPACE, defaultDirs, discoverAgents, formatAgentList, resolveSkills } from "./agents.ts";
|
|
8
8
|
import { childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
|
|
9
9
|
import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
|
|
10
|
+
import { startTicker } from "./ticker.ts";
|
|
10
11
|
|
|
11
12
|
const MAX_PARALLEL_TASKS = 8;
|
|
12
13
|
const MAX_CONCURRENCY = 4;
|
|
@@ -151,6 +152,23 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
151
152
|
],
|
|
152
153
|
parameters: Params,
|
|
153
154
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
155
|
+
const label = params.chain?.length ? `chain of ${params.chain.length}` : params.tasks?.length ? `${params.tasks.length} agents` : (params.agent ?? "subagent");
|
|
156
|
+
const stopTicker = startTicker(ctx, label);
|
|
157
|
+
try {
|
|
158
|
+
return await runTool(params, signal, onUpdate, ctx);
|
|
159
|
+
} finally {
|
|
160
|
+
stopTicker();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
renderCall: (args, theme) => renderCall(args, theme),
|
|
164
|
+
renderResult: (result, { expanded }, theme) => renderResult(result as AgentToolResult<SubagentDetails>, expanded, theme),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type ToolParams = Static<typeof Params>;
|
|
169
|
+
type ToolResult = AgentToolResult<SubagentDetails> & { isError?: boolean };
|
|
170
|
+
|
|
171
|
+
async function runTool(params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((r: ToolResult) => void) | undefined, ctx: ExtensionContext): Promise<ToolResult> {
|
|
154
172
|
const scope: AgentScope = params.agentScope ?? "user";
|
|
155
173
|
const discovery = listAgents(ctx.cwd, scope);
|
|
156
174
|
const agents = discovery.agents;
|
|
@@ -227,10 +245,6 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
227
245
|
return { content: [{ type: "text", text: `Agent ${result.stopReason ?? "failed"}: ${resultOutput(result)}` }], details: details("single", [result]), isError: true };
|
|
228
246
|
}
|
|
229
247
|
return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
|
|
230
|
-
},
|
|
231
|
-
renderCall: (args, theme) => renderCall(args, theme),
|
|
232
|
-
renderResult: (result, { expanded }, theme) => renderResult(result as AgentToolResult<SubagentDetails>, expanded, theme),
|
|
233
|
-
});
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
pi.registerCommand("run", {
|
|
@@ -246,16 +260,15 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
|
|
|
246
260
|
return;
|
|
247
261
|
}
|
|
248
262
|
const agents = listAgents(ctx.cwd, "user").agents;
|
|
249
|
-
ctx
|
|
250
|
-
ctx.ui.setStatus("subagent", `${parsed.agent} running`);
|
|
263
|
+
const stopTicker = startTicker(ctx, parsed.agent);
|
|
251
264
|
let result: RunResult;
|
|
252
265
|
try {
|
|
253
266
|
result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
|
|
254
267
|
} finally {
|
|
255
|
-
|
|
268
|
+
stopTicker();
|
|
256
269
|
}
|
|
257
270
|
const status = isFailed(result) ? "failed" : "done";
|
|
258
|
-
ctx.ui.notify(`${parsed.agent}
|
|
271
|
+
if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
|
|
259
272
|
pi.sendMessage(
|
|
260
273
|
{ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true },
|
|
261
274
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { formatElapsed, startTicker, tickerText } from "./ticker.ts";
|
|
3
|
+
|
|
4
|
+
describe("ticker", () => {
|
|
5
|
+
it("formats elapsed time as m:ss", () => {
|
|
6
|
+
expect(formatElapsed(0)).toBe("0:00");
|
|
7
|
+
expect(formatElapsed(59_999)).toBe("0:59");
|
|
8
|
+
expect(formatElapsed(61_000)).toBe("1:01");
|
|
9
|
+
expect(formatElapsed(3_600_000)).toBe("60:00");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("cycles spinner frames", () => {
|
|
13
|
+
expect(tickerText("astro.arcane", 0, 1000)).toBe("⠋ astro.arcane 0:01");
|
|
14
|
+
expect(tickerText("astro.arcane", 10, 1000)).toBe("⠋ astro.arcane 0:01");
|
|
15
|
+
expect(tickerText("astro.arcane", 1, 65_000)).toBe("⠙ astro.arcane 1:05");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("paints accent-coloured status until stopped and then clears it", () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const setStatus = vi.fn();
|
|
21
|
+
const fg = vi.fn((color: string, text: string) => `<${color}>${text}</${color}>`);
|
|
22
|
+
const ctx = { hasUI: true, ui: { setStatus, theme: { fg } } } as unknown as Parameters<typeof startTicker>[0];
|
|
23
|
+
let clock = 0;
|
|
24
|
+
const stop = startTicker(ctx, "astro.network", 100, () => clock);
|
|
25
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", "<accent>⠋ astro.network 0:00</accent>");
|
|
26
|
+
clock = 1500;
|
|
27
|
+
vi.advanceTimersByTime(100);
|
|
28
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", "<accent>⠙ astro.network 0:01</accent>");
|
|
29
|
+
stop();
|
|
30
|
+
expect(setStatus).toHaveBeenLastCalledWith("subagent", undefined);
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does nothing without a UI", () => {
|
|
35
|
+
const setStatus = vi.fn();
|
|
36
|
+
const stop = startTicker({ hasUI: false, ui: { setStatus } } as unknown as Parameters<typeof startTicker>[0], "x");
|
|
37
|
+
stop();
|
|
38
|
+
expect(setStatus).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
const STATUS_KEY = "subagent";
|
|
5
|
+
|
|
6
|
+
export function formatElapsed(ms: number): string {
|
|
7
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
8
|
+
const minutes = Math.floor(total / 60);
|
|
9
|
+
const seconds = total % 60;
|
|
10
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function tickerText(label: string, frameIndex: number, elapsedMs: number): string {
|
|
14
|
+
return `${FRAMES[frameIndex % FRAMES.length]} ${label} ${formatElapsed(elapsedMs)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Animated footer status in the accent colour while a subagent runs.
|
|
19
|
+
* Returns a stop function that clears the status. A no-op without UI.
|
|
20
|
+
*/
|
|
21
|
+
export function startTicker(ctx: ExtensionContext, label: string, intervalMs = 250, now: () => number = Date.now): () => void {
|
|
22
|
+
if (!ctx.hasUI) return () => {};
|
|
23
|
+
const started = now();
|
|
24
|
+
let frame = 0;
|
|
25
|
+
const paint = () => {
|
|
26
|
+
const text = tickerText(label, frame++, now() - started);
|
|
27
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme ? ctx.ui.theme.fg("accent", text) : text);
|
|
28
|
+
};
|
|
29
|
+
paint();
|
|
30
|
+
const timer = setInterval(paint, intervalMs);
|
|
31
|
+
timer.unref();
|
|
32
|
+
return () => {
|
|
33
|
+
clearInterval(timer);
|
|
34
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
35
|
+
};
|
|
36
|
+
}
|