@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-subagents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Subagent jobs with asynchronous main-agent messaging for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"typebox": "*"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@earendil-works/pi-ai": "1.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
44
|
-
"@earendil-works/pi-tui": "1.
|
|
45
|
-
"@types/node": "26.6.
|
|
42
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
44
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
45
|
+
"@types/node": "26.6.5",
|
|
46
46
|
"esbuild": "0.28.2",
|
|
47
|
-
"typebox": "1.
|
|
47
|
+
"typebox": "1.4.0",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
},
|
|
50
50
|
"repository": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"directory": "packages/pi-subagents"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"@narumitw/pi-tui-kit": "^0.65.6",
|
|
56
57
|
"ignore": "^7.0.12"
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sanitizeTerminalText } from "./message-broker.js";
|
|
2
|
+
|
|
3
|
+
/** Single-line terminal display only; keep raw tasks, paths, and identifiers unchanged. */
|
|
4
|
+
export function sanitizeSubagentDisplayText(value: string): string {
|
|
5
|
+
// Bound sequence bodies at another control introducer to keep malformed input linear.
|
|
6
|
+
const text = value.replace(
|
|
7
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal sequences before display.
|
|
8
|
+
/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|(?:\u001b[\]PX^_]|[\u0090\u0098\u009d-\u009f])[^\u0007\u001b\u0080-\u009f]*(?:\u0007|\u001b\\|\u009c)|\u001b[ -/]*[0-~]/gu,
|
|
9
|
+
"",
|
|
10
|
+
);
|
|
11
|
+
return sanitizeTerminalText(text.replace(/\p{Bidi_Control}/gu, ""))
|
|
12
|
+
.replace(/\s+/gu, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runConfirmation, runMenu, runTask, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { SubagentSettings } from "./settings.js";
|
|
4
|
+
import { JOB_STATES, type JobSummary } from "./types.js";
|
|
5
|
+
|
|
6
|
+
interface SubagentsMenuState {
|
|
7
|
+
settings: SubagentSettings;
|
|
8
|
+
settingsPath: string;
|
|
9
|
+
jobs: JobSummary[];
|
|
10
|
+
omitted: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SubagentsMenuOptions {
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
isCurrent(): boolean;
|
|
16
|
+
getState(): SubagentsMenuState;
|
|
17
|
+
cancelJob(jobId: string, signal: AbortSignal): Promise<void>;
|
|
18
|
+
showSettings(signal: AbortSignal): Promise<"back" | "close" | "stale">;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: SubagentsMenuOptions): Promise<void> {
|
|
22
|
+
if (options.signal.aborted || !options.isCurrent()) return;
|
|
23
|
+
let selectedJobId: string | undefined;
|
|
24
|
+
const current = (signal: AbortSignal) => !signal.aborted && options.isCurrent();
|
|
25
|
+
const menu = defineMenu<
|
|
26
|
+
SubagentsMenuState,
|
|
27
|
+
"main" | "jobs" | "job" | "status" | "help",
|
|
28
|
+
"selectJob" | "cancelJob" | "settings"
|
|
29
|
+
>({
|
|
30
|
+
start: "main",
|
|
31
|
+
screens: {
|
|
32
|
+
main: ({ state }) => ({
|
|
33
|
+
kind: "actions",
|
|
34
|
+
title: "Subagents",
|
|
35
|
+
lines: [jobCounts(state)],
|
|
36
|
+
items: [
|
|
37
|
+
{ id: "jobs", label: "Jobs", to: "jobs" },
|
|
38
|
+
{ id: "settings", label: "Settings", action: "settings" },
|
|
39
|
+
{ id: "status", label: "Status", to: "status" },
|
|
40
|
+
{ id: "help", label: "Help", to: "help" },
|
|
41
|
+
{ id: "close", label: "Close", close: true },
|
|
42
|
+
],
|
|
43
|
+
hint: "close",
|
|
44
|
+
}),
|
|
45
|
+
jobs: ({ state }) => ({
|
|
46
|
+
kind: "choice",
|
|
47
|
+
title: "Subagent Jobs",
|
|
48
|
+
lines: [jobCounts(state), ...(state.jobs.length === 0 ? ["No jobs in this session."] : [])],
|
|
49
|
+
items: state.jobs.map((job) => ({
|
|
50
|
+
id: job.jobId,
|
|
51
|
+
label: sanitizeTerminalText(job.jobId),
|
|
52
|
+
description: sanitizeTerminalText(job.state),
|
|
53
|
+
})),
|
|
54
|
+
action: "selectJob",
|
|
55
|
+
hint: "back",
|
|
56
|
+
}),
|
|
57
|
+
job: ({ state }) => {
|
|
58
|
+
const job = state.jobs.find((candidate) => candidate.jobId === selectedJobId);
|
|
59
|
+
return {
|
|
60
|
+
kind: "actions",
|
|
61
|
+
title: "Subagent Job",
|
|
62
|
+
lines: job ? jobLines(job) : ["This job is no longer available."],
|
|
63
|
+
items: job && isActive(job) ? [{ id: "cancel", label: "Cancel job…", action: "cancelJob" }] : [],
|
|
64
|
+
hint: "back",
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
status: ({ state }) => ({
|
|
68
|
+
kind: "detail",
|
|
69
|
+
title: "Subagents Status",
|
|
70
|
+
lines: [
|
|
71
|
+
"Runtime: active for this session",
|
|
72
|
+
`Retained jobs: ${state.jobs.length}`,
|
|
73
|
+
`Active jobs: ${state.jobs.filter(isActive).length}`,
|
|
74
|
+
`Omitted jobs: ${state.omitted}`,
|
|
75
|
+
...JOB_STATES.map((status) => `${status}: ${state.jobs.filter((job) => job.state === status).length}`),
|
|
76
|
+
`Show widget: ${state.settings.showWidget}`,
|
|
77
|
+
`Show job ID: ${state.settings.showJobId}`,
|
|
78
|
+
`Show tools: ${state.settings.showTools}`,
|
|
79
|
+
`Show timing: ${state.settings.showTiming}`,
|
|
80
|
+
`User settings: ${sanitizeTerminalText(state.settingsPath)}`,
|
|
81
|
+
],
|
|
82
|
+
hint: "back",
|
|
83
|
+
}),
|
|
84
|
+
help: () => ({
|
|
85
|
+
kind: "detail",
|
|
86
|
+
title: "Subagents Help",
|
|
87
|
+
lines: [
|
|
88
|
+
"/subagents opens the job manager.",
|
|
89
|
+
"/subagents settings opens the same settings screen directly.",
|
|
90
|
+
"Jobs shows this session's job summaries. Only queued or running jobs can be cancelled, after confirmation.",
|
|
91
|
+
"The model uses subagent_spawn to start jobs, subagent_inspect to check them, and subagent_wait to wait for results.",
|
|
92
|
+
"subagent_send sends a message; subagent_cancel cancels a job.",
|
|
93
|
+
"Jobs are session-only. The runtime retains up to 32 terminal jobs for up to 24 hours.",
|
|
94
|
+
"Settings changes display preferences only. All four settings default to true.",
|
|
95
|
+
"Successful saves apply immediately. Use /reload after manual settings edits.",
|
|
96
|
+
"In RPC mode, Settings reports the user settings file path.",
|
|
97
|
+
],
|
|
98
|
+
hint: "back",
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
actions: {
|
|
102
|
+
selectJob: ({ itemId, signal }) => {
|
|
103
|
+
if (!current(signal)) return { kind: "close" };
|
|
104
|
+
if (!options.getState().jobs.some((job) => job.jobId === itemId)) return { kind: "stay" };
|
|
105
|
+
selectedJobId = itemId;
|
|
106
|
+
return { kind: "to", screen: "job" };
|
|
107
|
+
},
|
|
108
|
+
settings: async ({ signal }) => {
|
|
109
|
+
if (!current(signal)) return { kind: "close" };
|
|
110
|
+
const reason = await options.showSettings(signal);
|
|
111
|
+
return { kind: current(signal) && reason === "back" ? "stay" : "close" };
|
|
112
|
+
},
|
|
113
|
+
cancelJob: async ({ signal }) => {
|
|
114
|
+
if (!current(signal)) return { kind: "close" };
|
|
115
|
+
const jobId = selectedJobId;
|
|
116
|
+
const job = options.getState().jobs.find((candidate) => candidate.jobId === jobId);
|
|
117
|
+
if (!job || !isActive(job)) return { kind: "stay" };
|
|
118
|
+
const confirmation = await runConfirmation(ctx, {
|
|
119
|
+
title: "Cancel subagent job?",
|
|
120
|
+
message: `Cancel job ${sanitizeTerminalText(job.jobId)} (${sanitizeTerminalText(job.state)})?`,
|
|
121
|
+
confirmLabel: "Cancel job",
|
|
122
|
+
cancelLabel: "Keep job",
|
|
123
|
+
signal,
|
|
124
|
+
isCurrent: options.isCurrent,
|
|
125
|
+
onError: () => undefined,
|
|
126
|
+
});
|
|
127
|
+
if (!current(signal)) return { kind: "close" };
|
|
128
|
+
if (confirmation.kind === "closed") {
|
|
129
|
+
return { kind: confirmation.reason === "close" ? "close" : "stay" };
|
|
130
|
+
}
|
|
131
|
+
if (confirmation.kind === "error") throw confirmation.error;
|
|
132
|
+
if (confirmation.kind !== "confirmed") return { kind: "close" };
|
|
133
|
+
// The runtime can finish or remove a job while its confirmation is open.
|
|
134
|
+
const latest = options.getState().jobs.find((candidate) => candidate.jobId === jobId);
|
|
135
|
+
if (selectedJobId !== jobId || !latest || !isActive(latest)) return { kind: "stay" };
|
|
136
|
+
const result = await runTask(ctx, {
|
|
137
|
+
label: "Cancelling subagent job…",
|
|
138
|
+
signal,
|
|
139
|
+
isCurrent: options.isCurrent,
|
|
140
|
+
task: ({ signal: taskSignal }) => options.cancelJob(latest.jobId, taskSignal),
|
|
141
|
+
onError: () => undefined,
|
|
142
|
+
});
|
|
143
|
+
if (!current(signal)) return { kind: "close" };
|
|
144
|
+
if (result.kind === "error") throw result.error;
|
|
145
|
+
if (result.kind === "stale") return { kind: "close" };
|
|
146
|
+
if (result.kind === "cancelled") return { kind: "close" };
|
|
147
|
+
return { kind: "stay" };
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
await runMenu(ctx, menu, {
|
|
152
|
+
signal: options.signal,
|
|
153
|
+
isCurrent: options.isCurrent,
|
|
154
|
+
getState: options.getState,
|
|
155
|
+
onError: (_ctx, error) => {
|
|
156
|
+
if (!options.signal.aborted && options.isCurrent() && ctx.hasUI) {
|
|
157
|
+
ctx.ui.notify(
|
|
158
|
+
sanitizeTerminalText(`Subagents menu failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
159
|
+
"error",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isActive(job: JobSummary): boolean {
|
|
167
|
+
return job.state === "queued" || job.state === "running";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function jobCounts(state: SubagentsMenuState): string {
|
|
171
|
+
return `Jobs: ${state.jobs.length} shown, ${state.jobs.filter(isActive).length} active, ${state.omitted} omitted`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jobLines(job: JobSummary): string[] {
|
|
175
|
+
return [
|
|
176
|
+
`Job ID: ${sanitizeTerminalText(job.jobId)}`,
|
|
177
|
+
`State: ${sanitizeTerminalText(job.state)}`,
|
|
178
|
+
`Created: ${timestamp(job.createdAt)}`,
|
|
179
|
+
...(job.startedAt === undefined ? [] : [`Started: ${timestamp(job.startedAt)}`]),
|
|
180
|
+
...(job.finishedAt === undefined ? [] : [`Finished: ${timestamp(job.finishedAt)}`]),
|
|
181
|
+
...(job.timeout === undefined ? [] : [`Timeout: ${job.timeout} seconds`]),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function timestamp(value: number): string {
|
|
186
|
+
const date = new Date(value);
|
|
187
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : "unknown";
|
|
188
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
export const BROKER_MESSAGE_TYPE = "pi-subagents-message";
|
|
7
|
+
|
|
8
|
+
function createMessageBox(children: Component[], outputPad: number, background: (text: string) => string): Component {
|
|
9
|
+
return {
|
|
10
|
+
render(width) {
|
|
11
|
+
if (width <= 0) return [];
|
|
12
|
+
const maxPadding = Math.floor((width - 1) / 2);
|
|
13
|
+
const box = new Box(Math.min(Math.max(0, outputPad), maxPadding), 1, background);
|
|
14
|
+
for (const child of children) box.addChild(child);
|
|
15
|
+
return box.render(width);
|
|
16
|
+
},
|
|
17
|
+
invalidate() {
|
|
18
|
+
for (const child of children) child.invalidate();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerMessageRenderers(pi: ExtensionAPI): void {
|
|
24
|
+
for (const [messageType, summary] of [
|
|
25
|
+
[COMPLETION_MESSAGE_TYPE, "Subagent job completion"],
|
|
26
|
+
[BROKER_MESSAGE_TYPE, "Subagent message"],
|
|
27
|
+
] as const) {
|
|
28
|
+
pi.registerMessageRenderer(messageType, (message, options, theme) => {
|
|
29
|
+
const children: Component[] = [
|
|
30
|
+
new Text(theme.fg("customMessageLabel", theme.bold(`[${messageType}]`)), 0, 0),
|
|
31
|
+
new Spacer(1),
|
|
32
|
+
];
|
|
33
|
+
if (!options.expanded) {
|
|
34
|
+
children.push(
|
|
35
|
+
new Text(
|
|
36
|
+
theme.fg("customMessageText", `${summary} (`) +
|
|
37
|
+
theme.fg("dim", keyText("app.tools.expand")) +
|
|
38
|
+
theme.fg("customMessageText", " to expand)"),
|
|
39
|
+
0,
|
|
40
|
+
0,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
const content =
|
|
45
|
+
typeof message.content === "string"
|
|
46
|
+
? message.content
|
|
47
|
+
: message.content
|
|
48
|
+
.filter((part) => part.type === "text")
|
|
49
|
+
.map((part) => part.text)
|
|
50
|
+
.join("\n");
|
|
51
|
+
children.push(
|
|
52
|
+
new Markdown(sanitizeTerminalText(content), 0, 0, getMarkdownTheme(), {
|
|
53
|
+
color: (text) => theme.fg("customMessageText", text),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return createMessageBox(children, options.outputPad, (text) => theme.bg("customMessageBg", text));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { COMPLETION_MESSAGE_TYPE } from "./completion-renderer.js";
|
|
3
2
|
import { type BrokerSendAcknowledgement, type MessageBroker, sanitizeTerminalText } from "./message-broker.js";
|
|
3
|
+
import { COMPLETION_MESSAGE_TYPE } from "./message-renderers.js";
|
|
4
4
|
import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
|
|
5
5
|
import { runChild as defaultRunChild } from "./process.js";
|
|
6
6
|
import type { ExtensionAttachment } from "./resource-attachments.js";
|
|
@@ -28,6 +28,7 @@ interface InternalJob extends JobSummary {
|
|
|
28
28
|
terminal: Promise<void>;
|
|
29
29
|
resolveTerminal: () => void;
|
|
30
30
|
task?: Promise<void>;
|
|
31
|
+
taskText: string;
|
|
31
32
|
stopRequest?: StopRequest;
|
|
32
33
|
control?: ChildControl;
|
|
33
34
|
controlReady: Promise<ChildControl>;
|
|
@@ -48,6 +49,7 @@ export interface RuntimeDependencies {
|
|
|
48
49
|
|
|
49
50
|
export interface ActiveJobDisplay {
|
|
50
51
|
jobId: string;
|
|
52
|
+
task: string;
|
|
51
53
|
state: Extract<SubagentJobState, "queued" | "running">;
|
|
52
54
|
elapsedMs: number;
|
|
53
55
|
timeout?: number;
|
|
@@ -113,6 +115,7 @@ export class SubagentRuntime {
|
|
|
113
115
|
.sort((left, right) => left.createdAt - right.createdAt)
|
|
114
116
|
.map((job) => ({
|
|
115
117
|
jobId: job.jobId,
|
|
118
|
+
task: job.taskText,
|
|
116
119
|
state: job.state,
|
|
117
120
|
elapsedMs: Math.max(0, now - (job.startedAt ?? job.createdAt)),
|
|
118
121
|
...(job.timeout !== undefined ? { timeout: job.timeout } : {}),
|
|
@@ -155,6 +158,7 @@ export class SubagentRuntime {
|
|
|
155
158
|
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
|
|
156
159
|
controller,
|
|
157
160
|
tools: effectiveTools,
|
|
161
|
+
taskText: input.task,
|
|
158
162
|
terminal,
|
|
159
163
|
resolveTerminal,
|
|
160
164
|
controlReady,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { type ExtensionCommandContext, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
type SettingItem,
|
|
7
|
+
SettingsList,
|
|
8
|
+
Text,
|
|
9
|
+
type TuiMouseEvent,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { HorizontalRule, renderBoundedFrame, runCustomInteraction, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
|
|
13
|
+
import type { SubagentSettings } from "./settings.js";
|
|
14
|
+
|
|
15
|
+
interface SubagentSettingsUIOptions {
|
|
16
|
+
signal: AbortSignal;
|
|
17
|
+
isCurrent(): boolean;
|
|
18
|
+
get(): SubagentSettings;
|
|
19
|
+
save(key: keyof SubagentSettings, value: boolean, signal: AbortSignal): Promise<SubagentSettings>;
|
|
20
|
+
apply(settings: SubagentSettings): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CloseReason = "back" | "close" | "stale";
|
|
24
|
+
|
|
25
|
+
const SETTINGS: { key: keyof SubagentSettings; label: string; description: string }[] = [
|
|
26
|
+
{ key: "showWidget", label: "Show widget", description: "Show subagent jobs above the editor." },
|
|
27
|
+
{ key: "showJobId", label: "Show job ID", description: "Show job IDs in the widget." },
|
|
28
|
+
{ key: "showTools", label: "Show tools", description: "Show active tools in the widget." },
|
|
29
|
+
{ key: "showTiming", label: "Show timing", description: "Show job timing in the widget." },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export async function showSubagentSettings(
|
|
33
|
+
ctx: ExtensionCommandContext,
|
|
34
|
+
options: SubagentSettingsUIOptions,
|
|
35
|
+
): Promise<CloseReason> {
|
|
36
|
+
const result = await runCustomInteraction<CloseReason>(ctx, {
|
|
37
|
+
signal: options.signal,
|
|
38
|
+
isCurrent: options.isCurrent,
|
|
39
|
+
// The command owner reports interaction failures and non-TUI settings paths.
|
|
40
|
+
onError: () => {},
|
|
41
|
+
create({ tui, theme, signal, complete }) {
|
|
42
|
+
let queue = Promise.resolve();
|
|
43
|
+
const current = () => !signal.aborted && options.isCurrent();
|
|
44
|
+
const revisions = new Map<string, number>();
|
|
45
|
+
const pending = new Map<string, number>();
|
|
46
|
+
const mouseRows = new Map<number, number>();
|
|
47
|
+
let renderedWidth = 0;
|
|
48
|
+
let contentHeight = 0;
|
|
49
|
+
let mousePressed = false;
|
|
50
|
+
const reportFailure = (message: string) => {
|
|
51
|
+
if (!current()) return;
|
|
52
|
+
try {
|
|
53
|
+
ctx.ui.notify(sanitizeTerminalText(`Could not save pi-subagents.json: ${message}`), "error");
|
|
54
|
+
} catch {
|
|
55
|
+
// The host can dispose its notifier after the ownership check.
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
|
|
59
|
+
// Keep native SettingsList semantics, but use this screen's callback theme.
|
|
60
|
+
const listTheme = {
|
|
61
|
+
...getSettingsListTheme(),
|
|
62
|
+
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
|
63
|
+
value: (text: string, selected: boolean) => theme.fg(selected ? "accent" : "muted", text),
|
|
64
|
+
description: (text: string) => theme.fg("dim", text),
|
|
65
|
+
cursor: theme.fg("accent", "→ "),
|
|
66
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
67
|
+
};
|
|
68
|
+
const initial = options.get();
|
|
69
|
+
const items: SettingItem[] = SETTINGS.map(({ key, label, description }) => ({
|
|
70
|
+
id: key,
|
|
71
|
+
label,
|
|
72
|
+
description,
|
|
73
|
+
currentValue: String(initial[key]),
|
|
74
|
+
values: ["true", "false"],
|
|
75
|
+
}));
|
|
76
|
+
const list = new SettingsList(
|
|
77
|
+
items,
|
|
78
|
+
items.length,
|
|
79
|
+
listTheme,
|
|
80
|
+
(id, value) => {
|
|
81
|
+
if (!current()) return;
|
|
82
|
+
const key = id as keyof SubagentSettings;
|
|
83
|
+
const revision = (revisions.get(id) ?? 0) + 1;
|
|
84
|
+
revisions.set(id, revision);
|
|
85
|
+
pending.set(id, revision);
|
|
86
|
+
queue = queue
|
|
87
|
+
.then(async () => {
|
|
88
|
+
if (!current()) {
|
|
89
|
+
complete("stale");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const before = { ...options.get() };
|
|
93
|
+
let effective = before;
|
|
94
|
+
try {
|
|
95
|
+
const settings = await options.save(key, value === "true", signal);
|
|
96
|
+
// A successful rename is durable, even when closing aborted the save signal.
|
|
97
|
+
if (!options.isCurrent()) {
|
|
98
|
+
complete("stale");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
options.apply(settings);
|
|
102
|
+
effective = options.get();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!options.isCurrent()) {
|
|
105
|
+
complete("stale");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
109
|
+
try {
|
|
110
|
+
options.apply(before);
|
|
111
|
+
} catch (rollbackError) {
|
|
112
|
+
message += `; could not restore effective settings: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`;
|
|
113
|
+
}
|
|
114
|
+
reportFailure(message);
|
|
115
|
+
}
|
|
116
|
+
if (!current()) return;
|
|
117
|
+
if (pending.get(id) === revision) pending.delete(id);
|
|
118
|
+
// Saving rereads the full document, including unrelated manual edits. Keep newer
|
|
119
|
+
// optimistic choices, but refresh every row with no outstanding user change.
|
|
120
|
+
for (const { key: settingKey } of SETTINGS) {
|
|
121
|
+
if (!pending.has(settingKey)) list.updateValue(settingKey, String(effective[settingKey]));
|
|
122
|
+
}
|
|
123
|
+
tui.requestRender();
|
|
124
|
+
})
|
|
125
|
+
.catch((error: unknown) => {
|
|
126
|
+
// A failed action must not prevent later requests from entering the queue.
|
|
127
|
+
reportFailure(error instanceof Error ? error.message : String(error));
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
() => complete("back"),
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
render(width: number) {
|
|
134
|
+
if (!current()) complete("stale");
|
|
135
|
+
renderedWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
|
|
136
|
+
listTheme.cursor = theme.fg("accent", "→ ");
|
|
137
|
+
// The native footer hardcodes keys. Advertise only the hard-cancel key owned here;
|
|
138
|
+
// configured native navigation, change, and Back actions remain unchanged.
|
|
139
|
+
const content = list.render(Math.max(4, renderedWidth)).slice(0, -2);
|
|
140
|
+
contentHeight = content.length;
|
|
141
|
+
const focusedRow = content.findIndex((line) => stripVTControlCharacters(line).startsWith("→"));
|
|
142
|
+
mouseRows.clear();
|
|
143
|
+
const tokens = new Map<string, number>();
|
|
144
|
+
const layout = content.map((line, index) => {
|
|
145
|
+
if (!stripVTControlCharacters(line).trim()) return line;
|
|
146
|
+
const token = `content:${index}`;
|
|
147
|
+
tokens.set(token, index);
|
|
148
|
+
return token;
|
|
149
|
+
});
|
|
150
|
+
const terminalRows = Number.isFinite(tui.terminal.rows) ? Math.floor(tui.terminal.rows) : 24;
|
|
151
|
+
const frame = renderBoundedFrame({
|
|
152
|
+
width: Math.max(renderedWidth, `content:${content.length}`.length),
|
|
153
|
+
maxRows: Math.max(1, terminalRows - 3),
|
|
154
|
+
rule: rule.render(renderedWidth)[0] ?? "",
|
|
155
|
+
title: new Text(theme.fg("accent", theme.bold("Subagents Settings")), 0, 0).render(renderedWidth),
|
|
156
|
+
context: [theme.fg("muted", "Saved changes apply immediately.")],
|
|
157
|
+
content: layout,
|
|
158
|
+
hints: new Text(theme.fg("muted", "Ctrl+C close"), 0, 0).render(renderedWidth),
|
|
159
|
+
priorityRows: focusedRow < 0 ? [] : [focusedRow],
|
|
160
|
+
focusedRow,
|
|
161
|
+
});
|
|
162
|
+
return frame.map((line, row) => {
|
|
163
|
+
const nativeRow = tokens.get(line);
|
|
164
|
+
if (nativeRow !== undefined) mouseRows.set(row, nativeRow);
|
|
165
|
+
return truncateToWidth(nativeRow === undefined ? line : (content[nativeRow] ?? ""), renderedWidth, "");
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
invalidate() {
|
|
169
|
+
mouseRows.clear();
|
|
170
|
+
mousePressed = false;
|
|
171
|
+
list.invalidate();
|
|
172
|
+
},
|
|
173
|
+
handleInput(data: string) {
|
|
174
|
+
if (!current()) return complete("stale");
|
|
175
|
+
mouseRows.clear();
|
|
176
|
+
mousePressed = false;
|
|
177
|
+
if (matchesKey(data, Key.ctrl("c"))) complete("close");
|
|
178
|
+
else {
|
|
179
|
+
// SettingsList uses Pi's effective native keybindings for standard actions.
|
|
180
|
+
list.handleInput(data);
|
|
181
|
+
}
|
|
182
|
+
tui.requestRender();
|
|
183
|
+
},
|
|
184
|
+
handleMouse(event: TuiMouseEvent) {
|
|
185
|
+
if (!current()) {
|
|
186
|
+
complete("stale");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const nativeRow = mouseRows.get(event.y);
|
|
190
|
+
if (nativeRow === undefined || event.x < 0 || event.x >= renderedWidth) {
|
|
191
|
+
if (event.button === "left" && (event.type === "press" || event.type === "click")) mousePressed = false;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const nativeEvent = { ...event, y: nativeRow, width: renderedWidth, height: contentHeight };
|
|
195
|
+
if (event.type === "click" && event.button === "left" && !mousePressed) {
|
|
196
|
+
list.handleMouse({ ...nativeEvent, type: "press" });
|
|
197
|
+
}
|
|
198
|
+
const result = list.handleMouse(nativeEvent);
|
|
199
|
+
if (event.type === "press" && event.button === "left") mousePressed = result?.handled === true;
|
|
200
|
+
if (event.type === "click" || event.type === "wheel") mousePressed = false;
|
|
201
|
+
if (result?.handled && result.render !== false) tui.requestRender();
|
|
202
|
+
return result;
|
|
203
|
+
},
|
|
204
|
+
waitForPending() {
|
|
205
|
+
return queue;
|
|
206
|
+
},
|
|
207
|
+
dispose() {
|
|
208
|
+
mouseRows.clear();
|
|
209
|
+
mousePressed = false;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
if (result.kind === "error") throw result.error;
|
|
215
|
+
return result.kind === "completed" ? result.value : result.kind === "unsupported" ? "back" : "stale";
|
|
216
|
+
}
|