@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.ui.notify(`running ${parsed.agent}...`, "info");
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
- ctx.ui.setStatus("subagent", undefined);
268
+ stopTicker();
256
269
  }
257
270
  const status = isFailed(result) ? "failed" : "done";
258
- ctx.ui.notify(`${parsed.agent} ${status}`, isFailed(result) ? "warning" : "info");
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"