@jwilger/pi-development-system 0.50.0 → 0.52.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/package.json +2 -2
- package/skills/strict-lints/SKILL.md +2 -1
- package/src/core/spawn-overrides.ts +2 -2
- package/src/core/test-runner.ts +8 -2
- package/src/review/digest.ts +7 -1
- package/src/subagents/VENDORED.md +1 -1
- package/src/subagents/orch/mailbox.ts +11 -7
- package/src/subagents/orch/manager.ts +7 -1
- package/src/subagents/orch/runtime.ts +38 -33
- package/src/subagents/orch/tools.ts +12 -10
- package/src/subagents/prefs/agent-import.ts +15 -15
- package/src/subagents/prefs/config.ts +85 -185
- package/src/subagents/prefs/models.ts +6 -10
- package/src/subagents/prefs/settings.ts +38 -38
- package/src/subagents/types.ts +22 -19
- package/src/subagents/ui/dialog.ts +99 -84
- package/src/subagents/ui/import-picker.ts +28 -11
- package/src/subagents/ui/model-picker.ts +26 -17
- package/src/subagents/ui/settings-ui.ts +16 -34
- package/src/subagents/ui/status-ui.ts +140 -51
- package/src/subagents/ui/ui.ts +162 -265
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
1
|
+
// biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: vendored upstream settings-file loader with no tests of its own; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
2
|
+
// biome-ignore-all lint/suspicious/noShadow: vendored upstream settings-file loader with no tests of its own; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
closeSync,
|
|
@@ -23,11 +24,7 @@ export type WidgetMode = (typeof WIDGET_MODES)[number];
|
|
|
23
24
|
const TOOL_FILTERING_MODES = ["allowed", "all-except-blocked", "all"] as const;
|
|
24
25
|
export type ToolFilteringMode = (typeof TOOL_FILTERING_MODES)[number];
|
|
25
26
|
|
|
26
|
-
const MODEL_SELECTION_MODES = [
|
|
27
|
-
"pick-first-available",
|
|
28
|
-
"pick-first-scoped",
|
|
29
|
-
"use-current",
|
|
30
|
-
] as const;
|
|
27
|
+
const MODEL_SELECTION_MODES = ["pick-first-available", "pick-first-scoped", "use-current"] as const;
|
|
31
28
|
export type ModelSelectionMode = (typeof MODEL_SELECTION_MODES)[number];
|
|
32
29
|
|
|
33
30
|
export interface ManagerSettings {
|
|
@@ -55,7 +52,15 @@ export const DEFAULT_MANAGER_SETTINGS: ManagerSettings = {
|
|
|
55
52
|
};
|
|
56
53
|
|
|
57
54
|
const KEYS = [
|
|
58
|
-
"maxLevels",
|
|
55
|
+
"maxLevels",
|
|
56
|
+
"maxConcurrent",
|
|
57
|
+
"maxThreads",
|
|
58
|
+
"modelSelection",
|
|
59
|
+
"subagentMode",
|
|
60
|
+
"toolFiltering",
|
|
61
|
+
"widgetMode",
|
|
62
|
+
"nerdFontIcons",
|
|
63
|
+
"finalRecap",
|
|
59
64
|
] as const;
|
|
60
65
|
const MAX_LEVELS = 32;
|
|
61
66
|
|
|
@@ -65,9 +70,7 @@ function errorCode(error: unknown): string | undefined {
|
|
|
65
70
|
: undefined;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
function lstatIfPresent(
|
|
69
|
-
path: string,
|
|
70
|
-
): ReturnType<typeof lstatSync> | undefined {
|
|
73
|
+
function lstatIfPresent(path: string): ReturnType<typeof lstatSync> | undefined {
|
|
71
74
|
try {
|
|
72
75
|
return lstatSync(path);
|
|
73
76
|
} catch (error) {
|
|
@@ -76,9 +79,7 @@ function lstatIfPresent(
|
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
function assertNotSymlinkPath(
|
|
80
|
-
path: string,
|
|
81
|
-
): ReturnType<typeof lstatSync> | undefined {
|
|
82
|
+
function assertNotSymlinkPath(path: string): ReturnType<typeof lstatSync> | undefined {
|
|
82
83
|
const stat = lstatIfPresent(path);
|
|
83
84
|
if (stat?.isSymbolicLink()) throw new Error(`Unsafe symlink path: ${path}`);
|
|
84
85
|
return stat;
|
|
@@ -107,8 +108,8 @@ function parseSettings(content: string): Partial<ManagerSettings> {
|
|
|
107
108
|
try {
|
|
108
109
|
parsed = JSON.parse(content);
|
|
109
110
|
} catch (error) {
|
|
110
|
-
const
|
|
111
|
-
throw new Error(`Invalid JSON: ${
|
|
111
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
112
|
+
throw new Error(`Invalid JSON: ${reason}`, { cause: error });
|
|
112
113
|
}
|
|
113
114
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
114
115
|
throw new Error("Settings must be a JSON object");
|
|
@@ -123,7 +124,9 @@ function parseSettings(content: string): Partial<ManagerSettings> {
|
|
|
123
124
|
if (typeof record.scopedModelFiltering !== "boolean")
|
|
124
125
|
throw new Error("scopedModelFiltering must be a boolean");
|
|
125
126
|
if (!Object.hasOwn(record, "modelSelection"))
|
|
126
|
-
layer.modelSelection = record.scopedModelFiltering
|
|
127
|
+
layer.modelSelection = record.scopedModelFiltering
|
|
128
|
+
? "pick-first-scoped"
|
|
129
|
+
: "pick-first-available";
|
|
127
130
|
}
|
|
128
131
|
for (const key of KEYS) {
|
|
129
132
|
if (!Object.hasOwn(record, key)) continue;
|
|
@@ -186,8 +189,7 @@ function readLayerContent(entry: SettingsLayer): string | undefined {
|
|
|
186
189
|
}
|
|
187
190
|
const stat = assertNotSymlinkPath(entry.filePath);
|
|
188
191
|
if (!stat) return undefined;
|
|
189
|
-
if (!stat.isFile())
|
|
190
|
-
throw new Error(`Settings must be a regular file: ${entry.filePath}`);
|
|
192
|
+
if (!stat.isFile()) throw new Error(`Settings must be a regular file: ${entry.filePath}`);
|
|
191
193
|
return readFileSync(entry.filePath, "utf8");
|
|
192
194
|
}
|
|
193
195
|
|
|
@@ -214,9 +216,13 @@ export function loadManagerSaveScope(options: {
|
|
|
214
216
|
if (content !== undefined) {
|
|
215
217
|
const state: unknown = JSON.parse(content);
|
|
216
218
|
if (
|
|
217
|
-
state &&
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
state &&
|
|
220
|
+
typeof state === "object" &&
|
|
221
|
+
!Array.isArray(state) &&
|
|
222
|
+
"saveScope" in state &&
|
|
223
|
+
state.saveScope === "user"
|
|
224
|
+
)
|
|
225
|
+
return "user";
|
|
220
226
|
}
|
|
221
227
|
} catch {
|
|
222
228
|
// Missing, invalid or unsafe UI state must not prevent opening settings.
|
|
@@ -243,8 +249,7 @@ export function loadManagerSettings(options: {
|
|
|
243
249
|
const diagnostics: string[] = [];
|
|
244
250
|
const settings: ManagerSettings = { ...DEFAULT_MANAGER_SETTINGS };
|
|
245
251
|
const layers = [layer(options.agentDir, ["subagent-manager"])];
|
|
246
|
-
if (options.includeProject)
|
|
247
|
-
layers.push(layer(options.cwd, [".pi", "agent", "subagent-manager"]));
|
|
252
|
+
if (options.includeProject) layers.push(layer(options.cwd, [".pi", "agent", "subagent-manager"]));
|
|
248
253
|
for (const entry of layers) {
|
|
249
254
|
try {
|
|
250
255
|
const parsed = readLayer(entry);
|
|
@@ -257,13 +262,6 @@ export function loadManagerSettings(options: {
|
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
function serializedSettings(settings: ManagerSettings): string {
|
|
260
|
-
if (
|
|
261
|
-
settings === null ||
|
|
262
|
-
typeof settings !== "object" ||
|
|
263
|
-
Array.isArray(settings)
|
|
264
|
-
) {
|
|
265
|
-
throw new Error("Settings must be a JSON object");
|
|
266
|
-
}
|
|
267
265
|
for (const key of Object.keys(settings)) {
|
|
268
266
|
if (!isSupportedKey(key)) throw new Error(`Unknown settings key: ${key}`);
|
|
269
267
|
}
|
|
@@ -298,17 +296,20 @@ function ensureOwnedDirectory(directory: string): void {
|
|
|
298
296
|
}
|
|
299
297
|
|
|
300
298
|
function ensureScopeDirectories(directories: string[]): void {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
299
|
+
const [root, ...rest] = directories;
|
|
300
|
+
if (root === undefined) return;
|
|
301
|
+
assertExistingDirectory(root);
|
|
302
|
+
let parent = root;
|
|
303
|
+
for (const directory of rest) {
|
|
304
|
+
assertExistingDirectory(parent);
|
|
305
|
+
ensureOwnedDirectory(directory);
|
|
306
|
+
parent = directory;
|
|
305
307
|
}
|
|
306
308
|
}
|
|
307
309
|
|
|
308
310
|
function assertRegularFileOrAbsent(filePath: string): void {
|
|
309
311
|
const stat = assertNotSymlinkPath(filePath);
|
|
310
|
-
if (stat && !stat.isFile())
|
|
311
|
-
throw new Error(`Settings must be a regular file: ${filePath}`);
|
|
312
|
+
if (stat && !stat.isFile()) throw new Error(`Settings must be a regular file: ${filePath}`);
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
function exclusiveCreateFlags(): number {
|
|
@@ -333,8 +334,7 @@ function writeAtomically(entry: SettingsLayer, content: string): void {
|
|
|
333
334
|
for (const directory of entry.directories) assertExistingDirectory(directory);
|
|
334
335
|
assertRegularFileOrAbsent(filePath);
|
|
335
336
|
const tempStat = assertNotSymlinkPath(temporary);
|
|
336
|
-
if (!tempStat?.isFile())
|
|
337
|
-
throw new Error(`Settings must be a regular file: ${temporary}`);
|
|
337
|
+
if (!tempStat?.isFile()) throw new Error(`Settings must be a regular file: ${temporary}`);
|
|
338
338
|
renameSync(temporary, filePath);
|
|
339
339
|
} finally {
|
|
340
340
|
if (created) {
|
package/src/subagents/types.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
2
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
|
|
@@ -11,22 +10,24 @@ export interface ResolvedAgentSettings {
|
|
|
11
10
|
export interface AgentType {
|
|
12
11
|
name: string;
|
|
13
12
|
description: string;
|
|
14
|
-
models?: string[];
|
|
13
|
+
models?: string[] | undefined;
|
|
15
14
|
/** @deprecated Use ordered models instead. */
|
|
16
|
-
model?: string;
|
|
15
|
+
model?: string | undefined;
|
|
17
16
|
/** Advisory display names. Not provider/model pins and never used for selection. */
|
|
18
|
-
modelSuggestions?: string[];
|
|
19
|
-
thinkingLevel?: ThinkingLevel;
|
|
17
|
+
modelSuggestions?: string[] | undefined;
|
|
18
|
+
thinkingLevel?: ThinkingLevel | undefined;
|
|
20
19
|
/** devsys: per-spawn pin set by the coordinator via agent_spawn; beats preferences and inheritance. */
|
|
21
|
-
spawnOverrides?:
|
|
20
|
+
spawnOverrides?:
|
|
21
|
+
| { model?: string | undefined; thinkingLevel?: ThinkingLevel | undefined }
|
|
22
|
+
| undefined;
|
|
22
23
|
/** Pi semantic token for the type pill background and task path foreground. */
|
|
23
|
-
color?: string | undefined;
|
|
24
|
+
color?: string | undefined | undefined;
|
|
24
25
|
/** Optional literal Nerd Font glyph; displayed only when the labs setting is enabled. */
|
|
25
|
-
icon?: string | undefined;
|
|
26
|
-
tools?: { allow?: string[]; block?: string[] };
|
|
26
|
+
icon?: string | undefined | undefined;
|
|
27
|
+
tools?: { allow?: string[] | undefined; block?: string[] | undefined } | undefined;
|
|
27
28
|
systemPrompt: string;
|
|
28
|
-
filePath?: string;
|
|
29
|
-
source?: "bundled" | "user" | "project";
|
|
29
|
+
filePath?: string | undefined;
|
|
30
|
+
source?: "bundled" | "user" | "project" | undefined;
|
|
30
31
|
/**
|
|
31
32
|
* How this definition is customized relative to its base.
|
|
32
33
|
* - fork: a full `<name>.md` copy (owns the system prompt).
|
|
@@ -34,15 +35,17 @@ export interface AgentType {
|
|
|
34
35
|
* (no Markdown body; unset fields follow the base).
|
|
35
36
|
* Absent for pure bundled definitions and brand-new drafts.
|
|
36
37
|
*/
|
|
37
|
-
customization?:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
customization?:
|
|
39
|
+
| {
|
|
40
|
+
kind: "fork" | "override";
|
|
41
|
+
scope: "user" | "project";
|
|
42
|
+
filePath: string;
|
|
43
|
+
}
|
|
44
|
+
| undefined;
|
|
42
45
|
/** Source of the base definition under the customization, if any. */
|
|
43
|
-
baseSource?: "bundled" | "user" | "project";
|
|
46
|
+
baseSource?: "bundled" | "user" | "project" | undefined;
|
|
44
47
|
/** File path of the base definition under the customization, if any. */
|
|
45
|
-
baseFilePath?: string;
|
|
48
|
+
baseFilePath?: string | undefined;
|
|
46
49
|
}
|
|
47
50
|
type ThreadState = "starting" | "running" | "paused" | "completed" | "failed" | "stopped";
|
|
48
51
|
export interface ThreadView {
|
|
@@ -69,7 +72,7 @@ export interface ThreadView {
|
|
|
69
72
|
/** Cumulative output tokens. Live views include the in-progress message. */
|
|
70
73
|
outputTokens?: number;
|
|
71
74
|
/** devsys: per-spawn model/thinkingLevel pin requested through agent_spawn, when any. */
|
|
72
|
-
pinned?: { model?: string; thinkingLevel?: ThinkingLevel };
|
|
75
|
+
pinned?: { model?: string | undefined; thinkingLevel?: ThinkingLevel | undefined };
|
|
73
76
|
}
|
|
74
77
|
type DriverEvent =
|
|
75
78
|
| { kind: "activity" | "error"; text: string }
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// biome-ignore-all lint/complexity/noVoid: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
2
|
+
// biome-ignore-all lint/correctness/noVoidTypeReturn: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
3
|
+
// biome-ignore-all lint/suspicious/noControlCharactersInRegex: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
4
|
+
// biome-ignore-all lint/suspicious/noEmptyBlockStatements: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
5
|
+
// biome-ignore-all lint/suspicious/noUnnecessaryConditions: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
6
|
+
// biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
|
|
2
7
|
import type {
|
|
3
8
|
ExtensionContext,
|
|
4
9
|
ExtensionUIContext,
|
|
@@ -6,6 +11,7 @@ import type {
|
|
|
6
11
|
Theme,
|
|
7
12
|
} from "@earendil-works/pi-coding-agent";
|
|
8
13
|
import {
|
|
14
|
+
type Component,
|
|
9
15
|
CURSOR_MARKER,
|
|
10
16
|
Editor,
|
|
11
17
|
getKeybindings,
|
|
@@ -13,11 +19,10 @@ import {
|
|
|
13
19
|
Key,
|
|
14
20
|
matchesKey,
|
|
15
21
|
stripTerminalSequences,
|
|
22
|
+
type TUI,
|
|
16
23
|
truncateToWidth,
|
|
17
24
|
visibleWidth,
|
|
18
25
|
wrapTextWithAnsi,
|
|
19
|
-
type Component,
|
|
20
|
-
type TUI,
|
|
21
26
|
} from "@earendil-works/pi-tui";
|
|
22
27
|
|
|
23
28
|
export interface DialogHost {
|
|
@@ -31,10 +36,10 @@ export interface DialogRow {
|
|
|
31
36
|
id: string;
|
|
32
37
|
label: string;
|
|
33
38
|
/** Trusted formatter receives sanitized text; generated ANSI styling is kept when clipping. */
|
|
34
|
-
renderLabel?: (label: string, theme: Theme) => string;
|
|
35
|
-
value?: string;
|
|
36
|
-
valueColor?: Parameters<Theme["fg"]>[0];
|
|
37
|
-
help?: string;
|
|
39
|
+
renderLabel?: ((label: string, theme: Theme) => string) | undefined;
|
|
40
|
+
value?: string | undefined;
|
|
41
|
+
valueColor?: Parameters<Theme["fg"]>[0] | undefined;
|
|
42
|
+
help?: string | undefined;
|
|
38
43
|
}
|
|
39
44
|
export const DIALOG_OPTIONS = {
|
|
40
45
|
overlay: true,
|
|
@@ -69,18 +74,14 @@ export function frameDialog(
|
|
|
69
74
|
const clipped = truncateToWidth(text, Math.max(0, columns), "", true);
|
|
70
75
|
return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
|
|
71
76
|
};
|
|
72
|
-
if (width < 4 || height < 4)
|
|
73
|
-
return [fit(theme.fg("muted", dialogText(title)), width)];
|
|
77
|
+
if (width < 4 || height < 4) return [fit(theme.fg("muted", dialogText(title)), width)];
|
|
74
78
|
const inner = width - 2;
|
|
75
79
|
const line = (text: string) =>
|
|
76
80
|
theme.fg("border", "│") + fit(text, inner) + theme.fg("border", "│");
|
|
77
81
|
const heading = truncateToWidth(` ${dialogText(title)} `, inner, "");
|
|
78
82
|
const top = theme.fg(
|
|
79
83
|
"border",
|
|
80
|
-
"
|
|
81
|
-
heading +
|
|
82
|
-
"─".repeat(Math.max(0, inner - visibleWidth(heading))) +
|
|
83
|
-
"╮",
|
|
84
|
+
`╭${heading}${"─".repeat(Math.max(0, inner - visibleWidth(heading)))}╮`,
|
|
84
85
|
);
|
|
85
86
|
// Pad to the full frame so centered overlays keep the same bounds between views.
|
|
86
87
|
const budget = height - 4;
|
|
@@ -90,9 +91,9 @@ export function frameDialog(
|
|
|
90
91
|
return [
|
|
91
92
|
top,
|
|
92
93
|
...content,
|
|
93
|
-
theme.fg("border", "
|
|
94
|
+
theme.fg("border", `├${"─".repeat(inner)}┤`),
|
|
94
95
|
line(theme.fg("dim", dialogText(footer))),
|
|
95
|
-
theme.fg("border", "
|
|
96
|
+
theme.fg("border", `╰${"─".repeat(inner)}╯`),
|
|
96
97
|
];
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -100,13 +101,24 @@ export function frameDialog(
|
|
|
100
101
|
class DialogEditor {
|
|
101
102
|
private editor: Editor;
|
|
102
103
|
private initialText: string;
|
|
104
|
+
private host: DialogEditorHost;
|
|
105
|
+
private theme: Theme;
|
|
106
|
+
readonly title: string;
|
|
107
|
+
readonly prefill: string;
|
|
108
|
+
private done: (value: string | undefined) => void;
|
|
109
|
+
|
|
103
110
|
constructor(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
host: DialogEditorHost,
|
|
112
|
+
theme: Theme,
|
|
113
|
+
title: string,
|
|
114
|
+
prefill: string,
|
|
115
|
+
done: (value: string | undefined) => void,
|
|
109
116
|
) {
|
|
117
|
+
this.host = host;
|
|
118
|
+
this.theme = theme;
|
|
119
|
+
this.title = title;
|
|
120
|
+
this.prefill = prefill;
|
|
121
|
+
this.done = done;
|
|
110
122
|
this.editor = new Editor(
|
|
111
123
|
host as ConstructorParameters<typeof Editor>[0],
|
|
112
124
|
{
|
|
@@ -173,8 +185,7 @@ export function dialogEditor(
|
|
|
173
185
|
prefill: string,
|
|
174
186
|
): Promise<string | undefined> {
|
|
175
187
|
return ctx.ui.custom(
|
|
176
|
-
(host, theme, _keys, done) =>
|
|
177
|
-
new DialogEditor(host, theme, title, prefill, done),
|
|
188
|
+
(host, theme, _keys, done) => new DialogEditor(host, theme, title, prefill, done),
|
|
178
189
|
DIALOG_OPTIONS,
|
|
179
190
|
);
|
|
180
191
|
}
|
|
@@ -183,16 +194,31 @@ export function dialogEditor(
|
|
|
183
194
|
class DialogMenu {
|
|
184
195
|
private selected = 0;
|
|
185
196
|
private viewport = 1;
|
|
197
|
+
private host: DialogHost;
|
|
198
|
+
private theme: Theme;
|
|
199
|
+
readonly title: string;
|
|
200
|
+
readonly rows: DialogRow[];
|
|
201
|
+
private done: (id: string | undefined) => void;
|
|
202
|
+
readonly footer: string;
|
|
203
|
+
private saveId: string | undefined;
|
|
204
|
+
|
|
186
205
|
constructor(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
host: DialogHost,
|
|
207
|
+
theme: Theme,
|
|
208
|
+
title: string,
|
|
209
|
+
rows: DialogRow[],
|
|
210
|
+
done: (id: string | undefined) => void,
|
|
192
211
|
selectedId?: string,
|
|
193
|
-
|
|
194
|
-
|
|
212
|
+
footer: string = "↑↓ navigate · Enter select · Esc close",
|
|
213
|
+
saveId?: string,
|
|
195
214
|
) {
|
|
215
|
+
this.host = host;
|
|
216
|
+
this.theme = theme;
|
|
217
|
+
this.title = title;
|
|
218
|
+
this.rows = rows;
|
|
219
|
+
this.done = done;
|
|
220
|
+
this.footer = footer;
|
|
221
|
+
this.saveId = saveId;
|
|
196
222
|
const index = rows.findIndex((row) => row.id === selectedId);
|
|
197
223
|
if (index >= 0) this.selected = index;
|
|
198
224
|
}
|
|
@@ -202,23 +228,16 @@ class DialogMenu {
|
|
|
202
228
|
handleInput(data: string): void {
|
|
203
229
|
const keys = getKeybindings();
|
|
204
230
|
if (keys.matches(data, "tui.select.cancel")) return this.done(undefined);
|
|
205
|
-
if (keys.matches(data, "tui.select.confirm"))
|
|
206
|
-
|
|
207
|
-
if (this.saveId && matchesKey(data, Key.ctrl("s")))
|
|
208
|
-
return this.done(this.saveId);
|
|
231
|
+
if (keys.matches(data, "tui.select.confirm")) return this.done(this.getSelectedId());
|
|
232
|
+
if (this.saveId && matchesKey(data, Key.ctrl("s"))) return this.done(this.saveId);
|
|
209
233
|
let offset = 0;
|
|
210
234
|
if (keys.matches(data, "tui.select.up")) offset = -1;
|
|
211
|
-
else if (keys.matches(data, "tui.select.down") || matchesKey(data, Key.tab))
|
|
212
|
-
offset = 1;
|
|
235
|
+
else if (keys.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) offset = 1;
|
|
213
236
|
else if (matchesKey(data, Key.pageUp)) offset = -this.viewport;
|
|
214
237
|
else if (matchesKey(data, Key.pageDown)) offset = this.viewport;
|
|
215
238
|
else if (matchesKey(data, Key.home)) this.selected = 0;
|
|
216
|
-
else if (matchesKey(data, Key.end))
|
|
217
|
-
|
|
218
|
-
this.selected = Math.max(
|
|
219
|
-
0,
|
|
220
|
-
Math.min(this.rows.length - 1, this.selected + offset),
|
|
221
|
-
);
|
|
239
|
+
else if (matchesKey(data, Key.end)) this.selected = Math.max(0, this.rows.length - 1);
|
|
240
|
+
this.selected = Math.max(0, Math.min(this.rows.length - 1, this.selected + offset));
|
|
222
241
|
this.host.requestRender();
|
|
223
242
|
}
|
|
224
243
|
invalidate(): void {}
|
|
@@ -232,20 +251,17 @@ class DialogMenu {
|
|
|
232
251
|
this.viewport = Math.max(1, height - 6 - help.length);
|
|
233
252
|
const start = Math.max(
|
|
234
253
|
0,
|
|
235
|
-
Math.min(
|
|
236
|
-
this.selected - this.viewport + 1,
|
|
237
|
-
this.rows.length - this.viewport,
|
|
238
|
-
),
|
|
254
|
+
Math.min(this.selected - this.viewport + 1, this.rows.length - this.viewport),
|
|
239
255
|
);
|
|
240
256
|
const labelWidth = Math.min(28, Math.max(8, Math.floor(inner * 0.35)));
|
|
241
257
|
const body = [
|
|
242
258
|
this.theme.fg(
|
|
243
259
|
"dim",
|
|
244
|
-
` ${this.rows.length ? this.selected + 1 : 0}/${this.rows.length} · Field / Value`,
|
|
260
|
+
` ${this.rows.length > 0 ? this.selected + 1 : 0}/${this.rows.length} · Field / Value`,
|
|
245
261
|
),
|
|
246
262
|
...this.rows.slice(start, start + this.viewport).map((row, i) => {
|
|
247
263
|
const selected = start + i === this.selected;
|
|
248
|
-
const color = (text: string) => selected ? this.theme.fg("accent", text) : text;
|
|
264
|
+
const color = (text: string) => (selected ? this.theme.fg("accent", text) : text);
|
|
249
265
|
const plainLabel = dialogText(row.label);
|
|
250
266
|
const label = truncateToWidth(
|
|
251
267
|
row.renderLabel ? row.renderLabel(plainLabel, this.theme) : plainLabel,
|
|
@@ -255,22 +271,17 @@ class DialogMenu {
|
|
|
255
271
|
);
|
|
256
272
|
const padding = " ".repeat(Math.max(0, labelWidth - visibleWidth(label)));
|
|
257
273
|
const value = dialogText(row.value ?? "");
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
274
|
+
return (
|
|
275
|
+
color(`${selected ? "›" : " "} `) +
|
|
276
|
+
(row.renderLabel ? label : color(label)) +
|
|
277
|
+
color(`${padding} │ `) +
|
|
278
|
+
(row.valueColor ? this.theme.fg(row.valueColor, value) : color(value))
|
|
279
|
+
);
|
|
262
280
|
}),
|
|
263
281
|
"",
|
|
264
282
|
...help.map((line) => this.theme.fg("muted", ` ${line}`)),
|
|
265
283
|
];
|
|
266
|
-
return frameDialog(
|
|
267
|
-
this.theme,
|
|
268
|
-
width,
|
|
269
|
-
height,
|
|
270
|
-
this.title,
|
|
271
|
-
body,
|
|
272
|
-
this.footer,
|
|
273
|
-
);
|
|
284
|
+
return frameDialog(this.theme, width, height, this.title, body, this.footer);
|
|
274
285
|
}
|
|
275
286
|
}
|
|
276
287
|
|
|
@@ -287,7 +298,11 @@ export async function dialogMenu(
|
|
|
287
298
|
ctx: ExtensionContext,
|
|
288
299
|
title: string,
|
|
289
300
|
rows: DialogRow[],
|
|
290
|
-
options: {
|
|
301
|
+
options: {
|
|
302
|
+
selectedId?: string | undefined;
|
|
303
|
+
footer?: string | undefined;
|
|
304
|
+
saveId?: string | undefined;
|
|
305
|
+
} = {},
|
|
291
306
|
): Promise<string | undefined> {
|
|
292
307
|
return ctx.ui.custom(
|
|
293
308
|
(host, theme, _keys, done) =>
|
|
@@ -371,9 +386,7 @@ const dialogScopes = new WeakSet<ExtensionContext>();
|
|
|
371
386
|
|
|
372
387
|
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
373
388
|
return (
|
|
374
|
-
typeof value === "object" &&
|
|
375
|
-
value !== null &&
|
|
376
|
-
typeof (value as Promise<T>).then === "function"
|
|
389
|
+
typeof value === "object" && value !== null && typeof (value as Promise<T>).then === "function"
|
|
377
390
|
);
|
|
378
391
|
}
|
|
379
392
|
|
|
@@ -395,14 +408,25 @@ export class DialogSession {
|
|
|
395
408
|
private disposed = false;
|
|
396
409
|
private generation = 0;
|
|
397
410
|
private pending: PendingView | undefined;
|
|
398
|
-
private outerDone: ((result?:
|
|
411
|
+
private outerDone: ((result?: undefined) => void) | undefined;
|
|
412
|
+
|
|
413
|
+
private host: TUI;
|
|
414
|
+
|
|
415
|
+
private theme: Theme;
|
|
416
|
+
|
|
417
|
+
private keys: KeybindingsManager;
|
|
399
418
|
|
|
400
419
|
constructor(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
done: (result?:
|
|
420
|
+
host: TUI,
|
|
421
|
+
theme: Theme,
|
|
422
|
+
keys: KeybindingsManager,
|
|
423
|
+
done: (result?: undefined) => void,
|
|
405
424
|
) {
|
|
425
|
+
this.host = host;
|
|
426
|
+
|
|
427
|
+
this.theme = theme;
|
|
428
|
+
|
|
429
|
+
this.keys = keys;
|
|
406
430
|
this.outerDone = done;
|
|
407
431
|
}
|
|
408
432
|
|
|
@@ -442,16 +466,14 @@ export class DialogSession {
|
|
|
442
466
|
const resolveOnce = (value: T) => {
|
|
443
467
|
if (settled) return;
|
|
444
468
|
settled = true;
|
|
445
|
-
if (this.pending?.generation === generation)
|
|
446
|
-
this.pending.settled = true;
|
|
469
|
+
if (this.pending?.generation === generation) this.pending.settled = true;
|
|
447
470
|
resolve(value);
|
|
448
471
|
};
|
|
449
472
|
const rejectOnce = (error: unknown) => {
|
|
450
473
|
if (settled) return;
|
|
451
474
|
settled = true;
|
|
452
475
|
this.inputLocked = true;
|
|
453
|
-
if (this.pending?.generation === generation)
|
|
454
|
-
this.pending.settled = true;
|
|
476
|
+
if (this.pending?.generation === generation) this.pending.settled = true;
|
|
455
477
|
reject(error);
|
|
456
478
|
};
|
|
457
479
|
this.pending = {
|
|
@@ -480,8 +502,7 @@ export class DialogSession {
|
|
|
480
502
|
void produced.then(
|
|
481
503
|
(component) => adopt(component),
|
|
482
504
|
(error: unknown) => {
|
|
483
|
-
if (settled || this.closed || generation !== this.generation)
|
|
484
|
-
return;
|
|
505
|
+
if (settled || this.closed || generation !== this.generation) return;
|
|
485
506
|
rejectOnce(error);
|
|
486
507
|
},
|
|
487
508
|
);
|
|
@@ -540,8 +561,7 @@ export class DialogSession {
|
|
|
540
561
|
return;
|
|
541
562
|
}
|
|
542
563
|
this.replaceChild(component);
|
|
543
|
-
this.inputLocked =
|
|
544
|
-
this.pending?.generation === generation ? this.pending.settled : true;
|
|
564
|
+
this.inputLocked = this.pending?.generation === generation ? this.pending.settled : true;
|
|
545
565
|
this.host.requestRender();
|
|
546
566
|
}
|
|
547
567
|
|
|
@@ -556,7 +576,7 @@ export class DialogSession {
|
|
|
556
576
|
|
|
557
577
|
private forwardFocus(): void {
|
|
558
578
|
const child = this.child;
|
|
559
|
-
if (!child
|
|
579
|
+
if (!(child && "focused" in child)) return;
|
|
560
580
|
// Read the setter's latest value; the first view can mount before showOverlay focuses us.
|
|
561
581
|
child.focused = this.focused;
|
|
562
582
|
}
|
|
@@ -569,18 +589,13 @@ export class DialogSession {
|
|
|
569
589
|
}
|
|
570
590
|
}
|
|
571
591
|
|
|
572
|
-
export function scopeDialogContext<T extends ExtensionContext>(
|
|
573
|
-
ctx: T,
|
|
574
|
-
session: DialogSession,
|
|
575
|
-
): T {
|
|
592
|
+
export function scopeDialogContext<T extends ExtensionContext>(ctx: T, session: DialogSession): T {
|
|
576
593
|
const source = ctx.ui;
|
|
577
594
|
const originalCustom = source.custom.bind(source);
|
|
578
595
|
const ui = Object.create(source) as ExtensionUIContext;
|
|
579
596
|
ui.custom = ((factory, options) => {
|
|
580
597
|
if (options?.overlay) return session.mount(factory);
|
|
581
|
-
return originalCustom(factory, options).finally(() =>
|
|
582
|
-
session.restoreFocus(),
|
|
583
|
-
);
|
|
598
|
+
return originalCustom(factory, options).finally(() => session.restoreFocus());
|
|
584
599
|
}) as ExtensionUIContext["custom"];
|
|
585
600
|
// Pi exposes ctx.ui as a getter; assignment cannot shadow an inherited accessor.
|
|
586
601
|
// Define an own property while keeping the remaining context getters live.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
1
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import {
|
|
4
3
|
CURSOR_MARKER,
|
|
@@ -10,10 +9,10 @@ import {
|
|
|
10
9
|
} from "@earendil-works/pi-tui";
|
|
11
10
|
import {
|
|
12
11
|
DIALOG_OPTIONS,
|
|
12
|
+
type DialogHost,
|
|
13
13
|
dialogHeight,
|
|
14
14
|
dialogText,
|
|
15
15
|
frameDialog,
|
|
16
|
-
type DialogHost,
|
|
17
16
|
} from "./dialog.ts";
|
|
18
17
|
|
|
19
18
|
export interface ImportPickerItem {
|
|
@@ -76,12 +75,28 @@ class ImportPicker {
|
|
|
76
75
|
private viewport = 1;
|
|
77
76
|
private readonly checked = new Set<string>();
|
|
78
77
|
|
|
78
|
+
private host: DialogHost;
|
|
79
|
+
|
|
80
|
+
private theme: Theme;
|
|
81
|
+
|
|
82
|
+
private readonly items: readonly ImportPickerItem[];
|
|
83
|
+
|
|
84
|
+
private done: (ids: string[] | undefined) => void;
|
|
85
|
+
|
|
79
86
|
constructor(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
) {
|
|
87
|
+
host: DialogHost,
|
|
88
|
+
theme: Theme,
|
|
89
|
+
items: readonly ImportPickerItem[],
|
|
90
|
+
done: (ids: string[] | undefined) => void,
|
|
91
|
+
) {
|
|
92
|
+
this.host = host;
|
|
93
|
+
|
|
94
|
+
this.theme = theme;
|
|
95
|
+
|
|
96
|
+
this.items = items;
|
|
97
|
+
|
|
98
|
+
this.done = done;
|
|
99
|
+
}
|
|
85
100
|
|
|
86
101
|
/** Selected ids in source order. Empty until the user toggles a row. */
|
|
87
102
|
getSelectedIds(): string[] {
|
|
@@ -99,7 +114,9 @@ class ImportPicker {
|
|
|
99
114
|
return this.cursor;
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
invalidate(): void {
|
|
117
|
+
invalidate(): void {
|
|
118
|
+
// Nothing is cached, so there is nothing to invalidate.
|
|
119
|
+
}
|
|
103
120
|
|
|
104
121
|
handleInput(data: string): void {
|
|
105
122
|
const keys = getKeybindings();
|
|
@@ -155,8 +172,8 @@ class ImportPicker {
|
|
|
155
172
|
if (listCount > 0) body.push(this.theme.fg("muted", " No external agents to import"));
|
|
156
173
|
} else {
|
|
157
174
|
const rows = this.items.slice(start, start + listCount);
|
|
158
|
-
for (
|
|
159
|
-
body.push(this.row(
|
|
175
|
+
for (const [offset, row] of rows.entries())
|
|
176
|
+
body.push(this.row(row, start + offset === this.cursor));
|
|
160
177
|
}
|
|
161
178
|
if (gap) body.push("");
|
|
162
179
|
for (const line of detail.slice(0, detailCount)) body.push(this.theme.fg("muted", ` ${line}`));
|
|
@@ -164,7 +181,7 @@ class ImportPicker {
|
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
private countLine(start: number, shown: number): string {
|
|
167
|
-
const position = this.items.length ? this.cursor + 1 : 0;
|
|
184
|
+
const position = this.items.length > 0 ? this.cursor + 1 : 0;
|
|
168
185
|
return this.theme.fg(
|
|
169
186
|
"dim",
|
|
170
187
|
` ${this.scrollHint(start, shown)}${this.checked.size} selected · ${position}/${this.items.length}`,
|