@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.
- package/README.md +51 -3
- package/dist/chunks/chunk-FY5HIU6Y.ts +42 -0
- package/dist/chunks/chunk-FY5HIU6Y.ts.map +7 -0
- package/dist/chunks/menu-KAIUEWL7.ts +171 -0
- package/dist/chunks/menu-KAIUEWL7.ts.map +7 -0
- package/dist/chunks/settings-ui-PSEYB6JX.ts +196 -0
- package/dist/chunks/settings-ui-PSEYB6JX.ts.map +7 -0
- package/dist/index.ts +344 -100
- package/dist/index.ts.map +4 -4
- package/package.json +7 -6
- package/src/display-text.ts +14 -0
- package/src/menu.ts +188 -0
- package/src/message-renderers.ts +60 -0
- package/src/runtime.ts +5 -1
- package/src/settings-ui.ts +216 -0
- package/src/settings.ts +142 -0
- package/src/subagents.ts +139 -11
- package/src/tools.ts +2 -2
- package/src/widget.ts +64 -28
- package/src/completion-renderer.ts +0 -58
package/src/settings.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
18
|
+
registerMessageRenderers(pi);
|
|
10
19
|
const tools = registerSubagentTools(pi, dependencies);
|
|
11
20
|
const widget = createSubagentWidgetController(tools.runtime);
|
|
12
|
-
|
|
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
|
-
|
|
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 (
|
|
20
|
-
|
|
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
|
|
147
|
+
if (!ownsSession(ctx)) return;
|
|
25
148
|
sessionGeneration++;
|
|
26
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
];
|
|
81
|
-
return
|
|
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
|
|
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
|
|
92
|
-
|
|
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
|
-
}
|