@narumitw/pi-subagents 3.2.0 → 3.3.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.
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface SubagentSettings {
6
+ showWidget: boolean;
7
+ showJobId: boolean;
8
+ showTools: boolean;
9
+ showTiming: boolean;
10
+ }
11
+
12
+ export const DEFAULT_SUBAGENT_SETTINGS: Readonly<SubagentSettings> = Object.freeze({
13
+ showWidget: true,
14
+ showJobId: true,
15
+ showTools: true,
16
+ showTiming: true,
17
+ });
18
+
19
+ const SETTINGS_KEYS = Object.keys(DEFAULT_SUBAGENT_SETTINGS) as (keyof SubagentSettings)[];
20
+
21
+ type SettingsDocument = {
22
+ document?: Record<string, unknown>;
23
+ diagnostics: string[];
24
+ };
25
+
26
+ export function subagentSettingsPath(): string {
27
+ return join(getAgentDir(), "pi-subagents.json");
28
+ }
29
+
30
+ /** User-only settings with in-process ordering; this does not lock other Pi processes. */
31
+ export class SubagentSettingsStore {
32
+ private effective: SubagentSettings = { ...DEFAULT_SUBAGENT_SETTINGS };
33
+ private tail: Promise<void> = Promise.resolve();
34
+
35
+ constructor(readonly userPath: string) {}
36
+
37
+ get settings(): SubagentSettings {
38
+ return { ...this.effective };
39
+ }
40
+
41
+ load(): Promise<{ settings: SubagentSettings; diagnostics: string[] }> {
42
+ return this.enqueue(async () => {
43
+ const loaded = await readDocument(this.userPath);
44
+ const settings = normalizeSettings(loaded.document ?? {}, loaded.diagnostics);
45
+ this.effective = settings;
46
+ return { settings: this.settings, diagnostics: loaded.diagnostics };
47
+ }).catch((error: unknown) => ({
48
+ settings: this.settings,
49
+ diagnostics: [readDiagnostic(this.userPath, error)],
50
+ }));
51
+ }
52
+
53
+ save(key: keyof SubagentSettings, value: boolean, signal: AbortSignal): Promise<SubagentSettings> {
54
+ return this.enqueue(async () => {
55
+ signal.throwIfAborted();
56
+ if (!SETTINGS_KEYS.includes(key) || typeof value !== "boolean") {
57
+ throw new Error("Invalid subagent settings update");
58
+ }
59
+ const loaded = await readDocument(this.userPath);
60
+ signal.throwIfAborted();
61
+ if (!loaded.document) {
62
+ throw new Error(`Cannot save subagent settings: ${loaded.diagnostics.join("; ")}`);
63
+ }
64
+ // Read again for each save and patch only the requested key, including after a failed save.
65
+ const document = { ...loaded.document, [key]: value };
66
+ const settings = normalizeSettings(document, []);
67
+ const parent = dirname(this.userPath);
68
+ await fs.mkdir(parent, { recursive: true });
69
+ signal.throwIfAborted();
70
+ const temporary = await fs.mkdtemp(join(parent, ".pi-subagents-"));
71
+ try {
72
+ signal.throwIfAborted();
73
+ const staged = join(temporary, "settings.json");
74
+ await fs.writeFile(staged, `${JSON.stringify(document, null, 2)}\n`, { flag: "wx", signal });
75
+ signal.throwIfAborted();
76
+ await fs.rename(staged, this.userPath);
77
+ // Rename is the commit point. Cancellation or cleanup must not turn a commit into a failure.
78
+ this.effective = settings;
79
+ return this.settings;
80
+ } finally {
81
+ await fs.rm(temporary, { recursive: true, force: true }).catch(() => {});
82
+ }
83
+ });
84
+ }
85
+
86
+ flush(): Promise<void> {
87
+ return this.tail;
88
+ }
89
+
90
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
91
+ const previous = this.tail;
92
+ // Register immediately so other stores and Pi file operations retain request order too.
93
+ const result = withFileMutationQueue(this.userPath, async () => {
94
+ await previous;
95
+ return operation();
96
+ });
97
+ this.tail = result.then(
98
+ () => {},
99
+ () => {},
100
+ );
101
+ return result;
102
+ }
103
+ }
104
+
105
+ async function readDocument(path: string): Promise<SettingsDocument> {
106
+ let text: string;
107
+ try {
108
+ text = await fs.readFile(path, "utf8");
109
+ } catch (error) {
110
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
111
+ return { document: {}, diagnostics: [] };
112
+ }
113
+ return { diagnostics: [readDiagnostic(path, error)] };
114
+ }
115
+ let document: unknown;
116
+ try {
117
+ document = JSON.parse(text);
118
+ } catch {
119
+ return { diagnostics: [`Cannot load subagent settings at ${path}: invalid JSON`] };
120
+ }
121
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
122
+ return { diagnostics: [`Cannot load subagent settings at ${path}: expected a JSON object`] };
123
+ }
124
+ return { document: document as Record<string, unknown>, diagnostics: [] };
125
+ }
126
+
127
+ function normalizeSettings(document: Record<string, unknown>, diagnostics: string[]): SubagentSettings {
128
+ const settings = { ...DEFAULT_SUBAGENT_SETTINGS };
129
+ for (const key of SETTINGS_KEYS) {
130
+ if (!Object.hasOwn(document, key)) continue;
131
+ if (typeof document[key] === "boolean") {
132
+ settings[key] = document[key];
133
+ } else {
134
+ diagnostics.push(`Ignored invalid subagent setting ${key}: expected a boolean`);
135
+ }
136
+ }
137
+ return settings;
138
+ }
139
+
140
+ function readDiagnostic(path: string, error: unknown): string {
141
+ return `Cannot load subagent settings at ${path}: ${error instanceof Error ? error.message : String(error)}`;
142
+ }
package/src/subagents.ts CHANGED
@@ -1,30 +1,158 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { registerCompletionRenderer } from "./completion-renderer.js";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeSubagentDisplayText } from "./display-text.js";
3
+ import { registerMessageRenderers } from "./message-renderers.js";
4
+ import {
5
+ DEFAULT_SUBAGENT_SETTINGS,
6
+ type SubagentSettings,
7
+ SubagentSettingsStore,
8
+ subagentSettingsPath,
9
+ } from "./settings.js";
3
10
  import { registerSubagentTools, type SubagentToolsDependencies } from "./tools.js";
4
11
  import { createSubagentWidgetController } from "./widget.js";
5
12
 
6
- export type SubagentsDependencies = SubagentToolsDependencies;
13
+ export interface SubagentsDependencies extends SubagentToolsDependencies {
14
+ settingsStore?: Pick<SubagentSettingsStore, "userPath" | "load" | "save" | "flush">;
15
+ }
7
16
 
8
17
  export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDependencies = {}): void {
9
- registerCompletionRenderer(pi);
18
+ registerMessageRenderers(pi);
10
19
  const tools = registerSubagentTools(pi, dependencies);
11
20
  const widget = createSubagentWidgetController(tools.runtime);
12
- let activeSession: ExtensionContext["sessionManager"] | undefined;
21
+ const store = dependencies.settingsStore ?? new SubagentSettingsStore(subagentSettingsPath());
22
+ let activeContext: ExtensionContext | undefined;
13
23
  let sessionGeneration = 0;
24
+ let owner = new AbortController();
25
+ let ready = false;
26
+ let menuOpen = false;
27
+ let settings: SubagentSettings = { ...DEFAULT_SUBAGENT_SETTINGS };
28
+ const menuTasks = new Set<Promise<void>>();
29
+ const ownsSession = (ctx: ExtensionContext) => ctx.sessionManager === activeContext?.sessionManager;
30
+
31
+ const openSettings = async (
32
+ ctx: ExtensionCommandContext,
33
+ signal: AbortSignal,
34
+ isCurrent: () => boolean,
35
+ ): Promise<"back" | "close" | "stale"> => {
36
+ if (signal.aborted || !isCurrent()) return "stale";
37
+ if (ctx.mode !== "tui") {
38
+ ctx.ui.notify(sanitizeSubagentDisplayText(`Edit user settings: ${store.userPath}. Then run /reload.`), "info");
39
+ return "back";
40
+ }
41
+ const { showSubagentSettings } = await import("./settings-ui.js");
42
+ if (signal.aborted || !isCurrent()) return "stale";
43
+ return showSubagentSettings(ctx, {
44
+ signal,
45
+ isCurrent,
46
+ get: () => ({ ...settings }),
47
+ save: (key, value, actionSignal) => {
48
+ actionSignal.throwIfAborted();
49
+ if (!isCurrent()) throw new Error("The subagents session changed.");
50
+ return store.save(key, value, actionSignal);
51
+ },
52
+ apply: (next) => {
53
+ if (!isCurrent()) return;
54
+ settings = { ...next };
55
+ widget.update(ctx, settings);
56
+ },
57
+ });
58
+ };
59
+
60
+ pi.registerCommand("subagents", {
61
+ description: "Manage subagent jobs and widget settings",
62
+ getArgumentCompletions: (prefix) =>
63
+ "settings".startsWith(prefix)
64
+ ? [{ value: "settings", label: "settings", description: "Configure the subagent widget" }]
65
+ : null,
66
+ handler: async (args, ctx) => {
67
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
68
+ throw new Error("/subagents requires TUI or RPC mode.");
69
+ }
70
+ const route = args.trim();
71
+ if (route !== "" && route !== "settings") {
72
+ ctx.ui.notify("Usage: /subagents | /subagents settings", "warning");
73
+ return;
74
+ }
75
+ if (!ownsSession(ctx) || !ready || menuOpen) {
76
+ ctx.ui.notify("Subagent controls are loading, unavailable, or already open.", "warning");
77
+ return;
78
+ }
79
+ menuOpen = true;
80
+ const generation = sessionGeneration;
81
+ const signal = owner.signal;
82
+ const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !signal.aborted;
83
+ const task = (async () => {
84
+ if (route === "settings") {
85
+ await openSettings(ctx, signal, isCurrent);
86
+ return;
87
+ }
88
+ const { showSubagentsMenu } = await import("./menu.js");
89
+ if (!isCurrent()) return;
90
+ await showSubagentsMenu(ctx, {
91
+ signal,
92
+ isCurrent,
93
+ getState: () => ({
94
+ settings: { ...settings },
95
+ settingsPath: store.userPath,
96
+ ...tools.runtime.inspectJobs(),
97
+ }),
98
+ cancelJob: async (jobId, actionSignal) => {
99
+ actionSignal.throwIfAborted();
100
+ if (!isCurrent()) throw new Error("The subagents session changed.");
101
+ await tools.runtime.cancel(jobId);
102
+ },
103
+ showSettings: (actionSignal) => openSettings(ctx, actionSignal, isCurrent),
104
+ });
105
+ })();
106
+ menuTasks.add(task);
107
+ try {
108
+ await task;
109
+ } catch (error) {
110
+ if (isCurrent()) {
111
+ ctx.ui.notify(sanitizeSubagentDisplayText(error instanceof Error ? error.message : String(error)), "error");
112
+ }
113
+ } finally {
114
+ menuTasks.delete(task);
115
+ if (isCurrent()) menuOpen = false;
116
+ }
117
+ },
118
+ });
14
119
 
15
120
  pi.on("session_start", async (_event, ctx) => {
16
- activeSession = ctx.sessionManager;
121
+ owner.abort();
122
+ if (activeContext) widget.shutdown(activeContext);
123
+ owner = new AbortController();
124
+ activeContext = ctx;
125
+ ready = false;
126
+ menuOpen = false;
17
127
  const generation = ++sessionGeneration;
128
+ const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !owner.signal.aborted;
129
+ // Stop old children and broker delivery before waiting for slow UI storage work.
18
130
  await tools.startSession();
19
- if (generation !== sessionGeneration) return;
20
- widget.start(ctx);
131
+ if (!isCurrent()) return;
132
+ await Promise.allSettled([...menuTasks]);
133
+ if (!isCurrent()) return;
134
+ const loaded = await store.load();
135
+ if (!isCurrent()) return;
136
+ settings = { ...loaded.settings };
137
+ ready = true;
138
+ widget.start(ctx, settings);
139
+ if (loaded.diagnostics.length > 0) {
140
+ const message = sanitizeSubagentDisplayText(`Subagent settings: ${loaded.diagnostics.join(" ")}`);
141
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
142
+ else throw new Error(message);
143
+ }
21
144
  });
22
145
 
23
146
  pi.on("session_shutdown", async (_event, ctx) => {
24
- if (ctx.sessionManager !== activeSession) return;
147
+ if (!ownsSession(ctx)) return;
25
148
  sessionGeneration++;
26
- activeSession = undefined;
149
+ owner.abort();
150
+ ready = false;
151
+ menuOpen = false;
152
+ activeContext = undefined;
27
153
  widget.shutdown(ctx);
28
- await tools.shutdown();
154
+ const results = await Promise.allSettled([tools.shutdown(), ...menuTasks, store.flush()]);
155
+ const failed = results.find((result) => result.status === "rejected");
156
+ if (failed?.status === "rejected") throw failed.reason;
29
157
  });
30
158
  }
package/src/tools.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  sanitizeTerminalText,
12
12
  validateMessage,
13
13
  } from "./message-broker.js";
14
+ import { BROKER_MESSAGE_TYPE } from "./message-renderers.js";
14
15
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
15
16
  import { assertChildCommandCapacity } from "./process.js";
16
17
  import {
@@ -32,7 +33,6 @@ import {
32
33
 
33
34
  const MAX_TASK_BYTES = 50 * 1024;
34
35
  const MAX_TOOLS = MAX_SELECTED_TOOLS;
35
- const MESSAGE_TYPE = "pi-subagents-message";
36
36
  const CHILD_CORE_TOOL_SET = new Set<string>(CHILD_CORE_TOOL_NAMES);
37
37
  const THINKING_LEVEL_SET = new Set<string>(SUBAGENT_THINKING_LEVELS);
38
38
 
@@ -347,7 +347,7 @@ function deliverMessage(pi: ExtensionAPI, message: BrokerInboundMessage): void {
347
347
  );
348
348
  pi.sendMessage(
349
349
  {
350
- customType: MESSAGE_TYPE,
350
+ customType: BROKER_MESSAGE_TYPE,
351
351
  content,
352
352
  display: true,
353
353
  details: {
package/src/widget.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import { sanitizeTerminalText } from "./message-broker.js";
2
+ import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { EditorStatusWidget } from "@narumitw/pi-tui-kit/editor-status-widget";
4
+ import { sanitizeSubagentDisplayText as sanitizeLabel } from "./display-text.js";
4
5
  import type { ActiveJobDisplay, SubagentRuntime } from "./runtime.js";
6
+ import { DEFAULT_SUBAGENT_SETTINGS, type SubagentSettings } from "./settings.js";
5
7
 
6
8
  export const SUBAGENT_WIDGET_KEY = "subagents";
7
9
  export const SUBAGENT_WIDGET_REFRESH_INTERVAL_MS = 1_000;
8
10
 
9
11
  const WIDGET_OPTIONS = { placement: "aboveEditor" } as const;
12
+ const TASK_PREVIEW_WIDTH = 40;
10
13
 
11
14
  export interface SubagentWidgetController {
12
- start(ctx: ExtensionContext): void;
15
+ start(ctx: ExtensionContext, settings?: SubagentSettings): void;
16
+ update(ctx: ExtensionContext, settings: SubagentSettings): void;
13
17
  shutdown(ctx: ExtensionContext): void;
14
18
  }
15
19
 
@@ -18,6 +22,8 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
18
22
  let refreshTimer: ReturnType<typeof setInterval> | undefined;
19
23
  let unsubscribeJobs: (() => void) | undefined;
20
24
  let publishedValue: string | undefined;
25
+ let settings = { ...DEFAULT_SUBAGENT_SETTINGS };
26
+ let clearWidget: (() => void) | undefined;
21
27
 
22
28
  const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
23
29
 
@@ -30,19 +36,17 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
30
36
 
31
37
  const publish = (ctx: ExtensionContext): void => {
32
38
  if (ctx.mode !== "tui" || !ownsSession(ctx)) return;
33
- const jobs = runtime.activeJobsForDisplay();
34
- const value = widgetValue(jobs);
39
+ const jobs = settings.showWidget ? runtime.activeJobsForDisplay() : [];
40
+ const value = `${JSON.stringify(settings)}\n${widgetValue(jobs)}`;
35
41
  if (value === publishedValue) return;
36
42
  if (jobs.length === 0) {
37
43
  ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
38
44
  } else {
39
45
  const snapshot = jobs.map(cloneDisplayJob);
46
+ const preferences = { ...settings };
40
47
  ctx.ui.setWidget(
41
48
  SUBAGENT_WIDGET_KEY,
42
- (_tui, theme) => ({
43
- render: (width) => renderSubagentWidget(snapshot, theme, width),
44
- invalidate: () => {},
45
- }),
49
+ (_tui, theme) => createSubagentWidget(snapshot, theme, preferences),
46
50
  WIDGET_OPTIONS,
47
51
  );
48
52
  }
@@ -50,53 +54,89 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
50
54
  };
51
55
 
52
56
  return {
53
- start(ctx) {
57
+ start(ctx, preferences = DEFAULT_SUBAGENT_SETTINGS) {
54
58
  stop();
59
+ clearWidget?.();
60
+ clearWidget = undefined;
61
+ settings = { ...preferences };
55
62
  activeSession = ctx.sessionManager;
56
63
  publishedValue = undefined;
57
64
  if (ctx.mode !== "tui") return;
58
- ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
65
+ clearWidget = () => ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
66
+ clearWidget();
59
67
  unsubscribeJobs = runtime.subscribeJobs(() => publish(ctx));
60
68
  refreshTimer = setInterval(() => publish(ctx), SUBAGENT_WIDGET_REFRESH_INTERVAL_MS);
61
69
  refreshTimer.unref();
62
70
  publish(ctx);
63
71
  },
72
+ update(ctx, preferences) {
73
+ if (!ownsSession(ctx)) return;
74
+ settings = { ...preferences };
75
+ publish(ctx);
76
+ },
64
77
  shutdown(ctx) {
65
78
  if (!ownsSession(ctx)) return;
66
79
  stop();
67
- if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
80
+ clearWidget?.();
81
+ clearWidget = undefined;
68
82
  publishedValue = undefined;
69
83
  activeSession = undefined;
70
84
  },
71
85
  };
72
86
  }
73
87
 
74
- export function renderSubagentWidget(jobs: readonly ActiveJobDisplay[], theme: Theme, width: number): string[] {
75
- const renderWidth = Math.max(0, width);
76
- const lines = [
77
- theme.fg("borderMuted", "─".repeat(renderWidth)),
78
- theme.fg("muted", `Subagents · ${jobs.length} active`),
79
- ...jobs.map((job) => renderJob(job, theme)),
80
- ];
81
- return lines.map((line) => truncateToWidth(line, renderWidth, ""));
88
+ export function renderSubagentWidget(
89
+ jobs: readonly ActiveJobDisplay[],
90
+ theme: Theme,
91
+ width: number,
92
+ settings: SubagentSettings = DEFAULT_SUBAGENT_SETTINGS,
93
+ ): string[] {
94
+ if (!settings.showWidget) return [];
95
+ return createSubagentWidget(jobs, theme, settings).render(width);
82
96
  }
83
97
 
84
- function renderJob(job: ActiveJobDisplay, theme: Theme): string {
98
+ function createSubagentWidget(
99
+ jobs: readonly ActiveJobDisplay[],
100
+ theme: Theme,
101
+ settings: SubagentSettings,
102
+ ): EditorStatusWidget {
103
+ return new EditorStatusWidget({
104
+ theme,
105
+ renderBody: () => [
106
+ theme.fg("muted", `Subagents · ${jobs.length} active`),
107
+ ...jobs.map((job) => renderJob(job, theme, settings)),
108
+ ],
109
+ });
110
+ }
111
+
112
+ function renderJob(job: ActiveJobDisplay, theme: Theme, settings: SubagentSettings): string {
85
113
  const running = job.state === "running";
86
114
  const symbol = theme.fg(running ? "accent" : "dim", running ? "▶ " : "○ ");
87
115
  const state = theme.fg(running ? "accent" : "muted", job.state);
88
116
  const jobId = sanitizeLabel(job.jobId);
117
+ const taskText = sanitizeLabel(job.task);
118
+ // Pi's truncation adds ANSI resets; remove them before applying the callback theme.
119
+ const task = stripTerminalSequences(truncateToWidth(taskText, TASK_PREVIEW_WIDTH, "…"));
120
+ const labels = [
121
+ ...(settings.showJobId ? [theme.fg("text", jobId)] : []),
122
+ ...(visibleWidth(task) > 0 ? [theme.fg("text", task)] : []),
123
+ state,
124
+ ];
89
125
  const tools = job.tools.length > 0 ? job.tools.map(sanitizeLabel).join(", ") : "none";
90
126
  const timeout = job.timeout === undefined ? "no timeout" : formatSeconds(job.timeout);
91
- const detail = ` · ${formatSeconds(Math.floor(job.elapsedMs / 1_000))} / ${timeout} · tools: ${tools}`;
92
- return `${symbol}${theme.fg("text", jobId)} · ${state}${theme.fg("muted", detail)}`;
127
+ const details = [
128
+ ...(settings.showTiming ? [`${formatSeconds(Math.floor(job.elapsedMs / 1_000))} / ${timeout}`] : []),
129
+ ...(settings.showTools ? [`tools: ${tools}`] : []),
130
+ ];
131
+ const detail = details.length > 0 ? theme.fg("muted", ` · ${details.join(" · ")}`) : "";
132
+ return `${symbol}${labels.join(" · ")}${detail}`;
93
133
  }
94
134
 
95
135
  function widgetValue(jobs: readonly ActiveJobDisplay[]): string {
96
136
  return jobs
97
137
  .map(
98
138
  (job) =>
99
- `${job.jobId}\0${job.state}\0${Math.floor(job.elapsedMs / 1_000)}\0${job.timeout ?? ""}\0${job.tools.join(",")}`,
139
+ `${job.jobId}\0${job.task}\0${job.state}\0${Math.floor(job.elapsedMs / 1_000)}\0${job.timeout ?? ""}\0${job.tools.join(",")}`,
100
140
  )
101
141
  .join("\n");
102
142
  }
@@ -105,10 +145,6 @@ function cloneDisplayJob(job: ActiveJobDisplay): ActiveJobDisplay {
105
145
  return { ...job, tools: [...job.tools] };
106
146
  }
107
147
 
108
- function sanitizeLabel(value: string): string {
109
- return sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
110
- }
111
-
112
148
  function formatSeconds(value: number): string {
113
149
  if (value < 60) return `${formatNumber(value)}s`;
114
150
  const wholeSeconds = Math.floor(value);
@@ -1,58 +0,0 @@
1
- import { type ExtensionAPI, getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
2
- import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { sanitizeTerminalText } from "./message-broker.js";
4
-
5
- export const COMPLETION_MESSAGE_TYPE = "pi-subagents-completion";
6
-
7
- function createCompletionBox(
8
- children: Component[],
9
- outputPad: number,
10
- background: (text: string) => string,
11
- ): Component {
12
- return {
13
- render(width) {
14
- if (width <= 0) return [];
15
- const maxPadding = Math.floor((width - 1) / 2);
16
- const box = new Box(Math.min(Math.max(0, outputPad), maxPadding), 1, background);
17
- for (const child of children) box.addChild(child);
18
- return box.render(width);
19
- },
20
- invalidate() {
21
- for (const child of children) child.invalidate();
22
- },
23
- };
24
- }
25
-
26
- export function registerCompletionRenderer(pi: ExtensionAPI): void {
27
- pi.registerMessageRenderer(COMPLETION_MESSAGE_TYPE, (message, options, theme) => {
28
- const children: Component[] = [
29
- new Text(theme.fg("customMessageLabel", theme.bold(`[${COMPLETION_MESSAGE_TYPE}]`)), 0, 0),
30
- new Spacer(1),
31
- ];
32
- if (!options.expanded) {
33
- children.push(
34
- new Text(
35
- theme.fg("customMessageText", "Subagent job completion (") +
36
- theme.fg("dim", keyText("app.tools.expand")) +
37
- theme.fg("customMessageText", " to expand)"),
38
- 0,
39
- 0,
40
- ),
41
- );
42
- } else {
43
- const content =
44
- typeof message.content === "string"
45
- ? message.content
46
- : message.content
47
- .filter((part) => part.type === "text")
48
- .map((part) => part.text)
49
- .join("\n");
50
- children.push(
51
- new Markdown(sanitizeTerminalText(content), 0, 0, getMarkdownTheme(), {
52
- color: (text) => theme.fg("customMessageText", text),
53
- }),
54
- );
55
- }
56
- return createCompletionBox(children, options.outputPad, (text) => theme.bg("customMessageBg", text));
57
- });
58
- }