@jwilger/pi-development-system 0.10.5 → 0.11.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/agents/architect.md +66 -0
- package/agents/coder.md +57 -0
- package/agents/researcher.md +60 -0
- package/agents/reviewer.md +53 -0
- package/agents/tasker.md +58 -0
- package/agents/writer.md +39 -0
- package/extensions/development-system.ts +2 -0
- package/package.json +5 -3
- package/src/core/test-runner.ts +1 -1
- package/src/gates/red-first-guard.ts +4 -1
- package/src/subagents/VENDORED.md +38 -0
- package/src/subagents/index.ts +544 -0
- package/src/subagents/orch/inherited-tools.ts +85 -0
- package/src/subagents/orch/mailbox.ts +262 -0
- package/src/subagents/orch/manager.ts +765 -0
- package/src/subagents/orch/paths.ts +74 -0
- package/src/subagents/orch/prompt.ts +25 -0
- package/src/subagents/orch/runtime.ts +784 -0
- package/src/subagents/orch/tools.ts +166 -0
- package/src/subagents/orch/transcript.ts +187 -0
- package/src/subagents/prefs/agent-import.ts +246 -0
- package/src/subagents/prefs/config.ts +1029 -0
- package/src/subagents/prefs/import-discovery.ts +644 -0
- package/src/subagents/prefs/import-instructions.ts +157 -0
- package/src/subagents/prefs/models.ts +78 -0
- package/src/subagents/prefs/settings.ts +371 -0
- package/src/subagents/types.ts +200 -0
- package/src/subagents/ui/agent-navigation-editor.ts +210 -0
- package/src/subagents/ui/dialog.ts +651 -0
- package/src/subagents/ui/import-picker.ts +232 -0
- package/src/subagents/ui/live-agent-view.ts +572 -0
- package/src/subagents/ui/model-picker.ts +452 -0
- package/src/subagents/ui/settings-ui.ts +329 -0
- package/src/subagents/ui/status-ui.ts +545 -0
- package/src/subagents/ui/thread-tree.ts +135 -0
- package/src/subagents/ui/ui.ts +1507 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ExtensionUIContext,
|
|
5
|
+
KeybindingsManager,
|
|
6
|
+
Theme,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
CURSOR_MARKER,
|
|
10
|
+
Editor,
|
|
11
|
+
getKeybindings,
|
|
12
|
+
Input,
|
|
13
|
+
Key,
|
|
14
|
+
matchesKey,
|
|
15
|
+
stripTerminalSequences,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
wrapTextWithAnsi,
|
|
19
|
+
type Component,
|
|
20
|
+
type TUI,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
22
|
+
|
|
23
|
+
export interface DialogHost {
|
|
24
|
+
requestRender(force?: boolean): void;
|
|
25
|
+
terminal?: { rows: number };
|
|
26
|
+
}
|
|
27
|
+
export interface DialogEditorHost extends DialogHost {
|
|
28
|
+
terminal: { rows: number };
|
|
29
|
+
}
|
|
30
|
+
export interface DialogRow {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
/** 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;
|
|
38
|
+
}
|
|
39
|
+
export const DIALOG_OPTIONS = {
|
|
40
|
+
overlay: true,
|
|
41
|
+
overlayOptions: {
|
|
42
|
+
anchor: "center",
|
|
43
|
+
width: "90%",
|
|
44
|
+
maxHeight: "90%",
|
|
45
|
+
margin: 1,
|
|
46
|
+
},
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export function dialogText(text: string): string {
|
|
50
|
+
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Match Pi's overlay height calculation; maxHeight alone would clip the bottom border. */
|
|
54
|
+
export function dialogHeight(host: DialogHost): number {
|
|
55
|
+
const rows = host.terminal?.rows ?? 24;
|
|
56
|
+
return Math.max(1, Math.min(rows - 2, Math.floor(rows * 0.9)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function frameDialog(
|
|
60
|
+
theme: Theme,
|
|
61
|
+
width: number,
|
|
62
|
+
height: number,
|
|
63
|
+
title: string,
|
|
64
|
+
body: string[],
|
|
65
|
+
footer: string,
|
|
66
|
+
): string[] {
|
|
67
|
+
if (width <= 0 || height <= 0) return [];
|
|
68
|
+
const fit = (text: string, columns: number) => {
|
|
69
|
+
const clipped = truncateToWidth(text, Math.max(0, columns), "", true);
|
|
70
|
+
return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
|
|
71
|
+
};
|
|
72
|
+
if (width < 4 || height < 4)
|
|
73
|
+
return [fit(theme.fg("muted", dialogText(title)), width)];
|
|
74
|
+
const inner = width - 2;
|
|
75
|
+
const line = (text: string) =>
|
|
76
|
+
theme.fg("border", "│") + fit(text, inner) + theme.fg("border", "│");
|
|
77
|
+
const heading = truncateToWidth(` ${dialogText(title)} `, inner, "");
|
|
78
|
+
const top = theme.fg(
|
|
79
|
+
"border",
|
|
80
|
+
"╭" +
|
|
81
|
+
heading +
|
|
82
|
+
"─".repeat(Math.max(0, inner - visibleWidth(heading))) +
|
|
83
|
+
"╮",
|
|
84
|
+
);
|
|
85
|
+
// Pad to the full frame so centered overlays keep the same bounds between views.
|
|
86
|
+
const budget = height - 4;
|
|
87
|
+
const padded = body.slice(0, budget);
|
|
88
|
+
while (padded.length < budget) padded.push("");
|
|
89
|
+
const content = padded.map(line);
|
|
90
|
+
return [
|
|
91
|
+
top,
|
|
92
|
+
...content,
|
|
93
|
+
theme.fg("border", "├" + "─".repeat(inner) + "┤"),
|
|
94
|
+
line(theme.fg("dim", dialogText(footer))),
|
|
95
|
+
theme.fg("border", "╰" + "─".repeat(inner) + "╯"),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A multiline editor inside the same bounded shell as the surrounding form. */
|
|
100
|
+
export class DialogEditor {
|
|
101
|
+
private editor: Editor;
|
|
102
|
+
private initialText: string;
|
|
103
|
+
constructor(
|
|
104
|
+
private host: DialogEditorHost,
|
|
105
|
+
private theme: Theme,
|
|
106
|
+
readonly title: string,
|
|
107
|
+
readonly prefill: string,
|
|
108
|
+
private done: (value: string | undefined) => void,
|
|
109
|
+
) {
|
|
110
|
+
this.editor = new Editor(
|
|
111
|
+
host as ConstructorParameters<typeof Editor>[0],
|
|
112
|
+
{
|
|
113
|
+
borderColor: (text) => theme.fg("border", text),
|
|
114
|
+
selectList: {
|
|
115
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
116
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
117
|
+
description: (text) => theme.fg("muted", text),
|
|
118
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
119
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{ paddingX: 0 },
|
|
123
|
+
);
|
|
124
|
+
this.editor.setText(
|
|
125
|
+
stripTerminalSequences(prefill)
|
|
126
|
+
.replace(/\r\n?/g, "\n")
|
|
127
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""),
|
|
128
|
+
);
|
|
129
|
+
this.initialText = this.editor.getText();
|
|
130
|
+
this.editor.disableSubmit = true;
|
|
131
|
+
}
|
|
132
|
+
getEditor(): Editor {
|
|
133
|
+
return this.editor;
|
|
134
|
+
}
|
|
135
|
+
get focused(): boolean {
|
|
136
|
+
return this.editor.focused;
|
|
137
|
+
}
|
|
138
|
+
set focused(value: boolean) {
|
|
139
|
+
this.editor.focused = value;
|
|
140
|
+
}
|
|
141
|
+
handleInput(data: string): void {
|
|
142
|
+
if (matchesKey(data, Key.escape)) return this.done(undefined);
|
|
143
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
144
|
+
const value = this.editor.getText();
|
|
145
|
+
return this.done(value === this.initialText ? this.prefill : value);
|
|
146
|
+
}
|
|
147
|
+
this.editor.handleInput(data);
|
|
148
|
+
this.host.requestRender();
|
|
149
|
+
}
|
|
150
|
+
invalidate(): void {
|
|
151
|
+
this.editor.invalidate();
|
|
152
|
+
}
|
|
153
|
+
render(width: number): string[] {
|
|
154
|
+
const height = dialogHeight(this.host);
|
|
155
|
+
const rows = this.editor.render(Math.max(1, width - 4)).slice(1, -1);
|
|
156
|
+
const budget = Math.max(0, height - 4);
|
|
157
|
+
const cursor = rows.findIndex((line) => line.includes(CURSOR_MARKER));
|
|
158
|
+
const start = cursor >= budget ? cursor - budget + 1 : 0;
|
|
159
|
+
return frameDialog(
|
|
160
|
+
this.theme,
|
|
161
|
+
width,
|
|
162
|
+
height,
|
|
163
|
+
this.title,
|
|
164
|
+
rows.slice(start, start + budget).map((line) => ` ${line}`),
|
|
165
|
+
"Enter newline · Ctrl+S apply · Esc cancel",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function dialogEditor(
|
|
171
|
+
ctx: ExtensionContext,
|
|
172
|
+
title: string,
|
|
173
|
+
prefill: string,
|
|
174
|
+
): Promise<string | undefined> {
|
|
175
|
+
return ctx.ui.custom(
|
|
176
|
+
(host, theme, _keys, done) =>
|
|
177
|
+
new DialogEditor(host, theme, title, prefill, done),
|
|
178
|
+
DIALOG_OPTIONS,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Bounded, two-column menu shared by settings and agent definitions. */
|
|
183
|
+
export class DialogMenu {
|
|
184
|
+
private selected = 0;
|
|
185
|
+
private viewport = 1;
|
|
186
|
+
constructor(
|
|
187
|
+
private host: DialogHost,
|
|
188
|
+
private theme: Theme,
|
|
189
|
+
readonly title: string,
|
|
190
|
+
readonly rows: DialogRow[],
|
|
191
|
+
private done: (id: string | undefined) => void,
|
|
192
|
+
selectedId?: string,
|
|
193
|
+
readonly footer = "↑↓ navigate · Enter select · Esc close",
|
|
194
|
+
private saveId?: string,
|
|
195
|
+
) {
|
|
196
|
+
const index = rows.findIndex((row) => row.id === selectedId);
|
|
197
|
+
if (index >= 0) this.selected = index;
|
|
198
|
+
}
|
|
199
|
+
getSelectedId(): string | undefined {
|
|
200
|
+
return this.rows[this.selected]?.id;
|
|
201
|
+
}
|
|
202
|
+
handleInput(data: string): void {
|
|
203
|
+
const keys = getKeybindings();
|
|
204
|
+
if (keys.matches(data, "tui.select.cancel")) return this.done(undefined);
|
|
205
|
+
if (keys.matches(data, "tui.select.confirm"))
|
|
206
|
+
return this.done(this.getSelectedId());
|
|
207
|
+
if (this.saveId && matchesKey(data, Key.ctrl("s")))
|
|
208
|
+
return this.done(this.saveId);
|
|
209
|
+
let offset = 0;
|
|
210
|
+
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;
|
|
213
|
+
else if (matchesKey(data, Key.pageUp)) offset = -this.viewport;
|
|
214
|
+
else if (matchesKey(data, Key.pageDown)) offset = this.viewport;
|
|
215
|
+
else if (matchesKey(data, Key.home)) this.selected = 0;
|
|
216
|
+
else if (matchesKey(data, Key.end))
|
|
217
|
+
this.selected = Math.max(0, this.rows.length - 1);
|
|
218
|
+
this.selected = Math.max(
|
|
219
|
+
0,
|
|
220
|
+
Math.min(this.rows.length - 1, this.selected + offset),
|
|
221
|
+
);
|
|
222
|
+
this.host.requestRender();
|
|
223
|
+
}
|
|
224
|
+
invalidate(): void {}
|
|
225
|
+
render(width: number): string[] {
|
|
226
|
+
const height = dialogHeight(this.host);
|
|
227
|
+
const inner = Math.max(0, width - 4);
|
|
228
|
+
const help = (this.rows[this.selected]?.help ?? "")
|
|
229
|
+
.split(/\r\n?|\n/)
|
|
230
|
+
.flatMap((line) => wrapTextWithAnsi(dialogText(line), Math.max(1, inner - 1)))
|
|
231
|
+
.slice(0, Math.max(1, height - 7));
|
|
232
|
+
this.viewport = Math.max(1, height - 6 - help.length);
|
|
233
|
+
const start = Math.max(
|
|
234
|
+
0,
|
|
235
|
+
Math.min(
|
|
236
|
+
this.selected - this.viewport + 1,
|
|
237
|
+
this.rows.length - this.viewport,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
const labelWidth = Math.min(28, Math.max(8, Math.floor(inner * 0.35)));
|
|
241
|
+
const body = [
|
|
242
|
+
this.theme.fg(
|
|
243
|
+
"dim",
|
|
244
|
+
` ${this.rows.length ? this.selected + 1 : 0}/${this.rows.length} · Field / Value`,
|
|
245
|
+
),
|
|
246
|
+
...this.rows.slice(start, start + this.viewport).map((row, i) => {
|
|
247
|
+
const selected = start + i === this.selected;
|
|
248
|
+
const color = (text: string) => selected ? this.theme.fg("accent", text) : text;
|
|
249
|
+
const plainLabel = dialogText(row.label);
|
|
250
|
+
const label = truncateToWidth(
|
|
251
|
+
row.renderLabel ? row.renderLabel(plainLabel, this.theme) : plainLabel,
|
|
252
|
+
labelWidth,
|
|
253
|
+
"",
|
|
254
|
+
true,
|
|
255
|
+
);
|
|
256
|
+
const padding = " ".repeat(Math.max(0, labelWidth - visibleWidth(label)));
|
|
257
|
+
const value = dialogText(row.value ?? "");
|
|
258
|
+
return color(`${selected ? "›" : " "} `)
|
|
259
|
+
+ (row.renderLabel ? label : color(label))
|
|
260
|
+
+ color(`${padding} │ `)
|
|
261
|
+
+ (row.valueColor ? this.theme.fg(row.valueColor, value) : color(value));
|
|
262
|
+
}),
|
|
263
|
+
"",
|
|
264
|
+
...help.map((line) => this.theme.fg("muted", ` ${line}`)),
|
|
265
|
+
];
|
|
266
|
+
return frameDialog(
|
|
267
|
+
this.theme,
|
|
268
|
+
width,
|
|
269
|
+
height,
|
|
270
|
+
this.title,
|
|
271
|
+
body,
|
|
272
|
+
this.footer,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function canOpenDialog(ctx: ExtensionContext): boolean {
|
|
278
|
+
if (!ctx.hasUI) return false;
|
|
279
|
+
if (ctx.mode && ctx.mode !== "tui") {
|
|
280
|
+
ctx.ui.notify("Agents dialogs require interactive TUI mode.", "warning");
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function dialogMenu(
|
|
287
|
+
ctx: ExtensionContext,
|
|
288
|
+
title: string,
|
|
289
|
+
rows: DialogRow[],
|
|
290
|
+
options: { selectedId?: string; footer?: string; saveId?: string } = {},
|
|
291
|
+
): Promise<string | undefined> {
|
|
292
|
+
return ctx.ui.custom(
|
|
293
|
+
(host, theme, _keys, done) =>
|
|
294
|
+
new DialogMenu(
|
|
295
|
+
host,
|
|
296
|
+
theme,
|
|
297
|
+
title,
|
|
298
|
+
rows,
|
|
299
|
+
done,
|
|
300
|
+
options.selectedId,
|
|
301
|
+
options.footer,
|
|
302
|
+
options.saveId,
|
|
303
|
+
),
|
|
304
|
+
DIALOG_OPTIONS,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function dialogInput(
|
|
309
|
+
ctx: ExtensionContext,
|
|
310
|
+
title: string,
|
|
311
|
+
initial: string,
|
|
312
|
+
help: string,
|
|
313
|
+
): Promise<string | undefined> {
|
|
314
|
+
return ctx.ui.custom<string | undefined>((host, theme, _keys, done) => {
|
|
315
|
+
const input = new Input();
|
|
316
|
+
input.setValue(dialogText(initial));
|
|
317
|
+
input.onSubmit = done;
|
|
318
|
+
input.onEscape = () => done(undefined);
|
|
319
|
+
return {
|
|
320
|
+
get focused() {
|
|
321
|
+
return input.focused;
|
|
322
|
+
},
|
|
323
|
+
set focused(value: boolean) {
|
|
324
|
+
input.focused = value;
|
|
325
|
+
},
|
|
326
|
+
handleInput: (data: string) => {
|
|
327
|
+
input.handleInput(data);
|
|
328
|
+
host.requestRender();
|
|
329
|
+
},
|
|
330
|
+
invalidate: () => input.invalidate(),
|
|
331
|
+
render: (width: number) =>
|
|
332
|
+
frameDialog(
|
|
333
|
+
theme,
|
|
334
|
+
width,
|
|
335
|
+
dialogHeight(host),
|
|
336
|
+
title,
|
|
337
|
+
[
|
|
338
|
+
"",
|
|
339
|
+
...input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
|
|
340
|
+
"",
|
|
341
|
+
...help
|
|
342
|
+
.split(/\r\n?|\n/)
|
|
343
|
+
.flatMap((line) => wrapTextWithAnsi(dialogText(line), Math.max(1, width - 5)))
|
|
344
|
+
.map((line) => theme.fg("muted", ` ${line}`)),
|
|
345
|
+
],
|
|
346
|
+
"Enter apply · Esc cancel",
|
|
347
|
+
),
|
|
348
|
+
};
|
|
349
|
+
}, DIALOG_OPTIONS);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
type DialogView = Component & {
|
|
353
|
+
dispose?(): void;
|
|
354
|
+
focused?: boolean;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
type DialogFactory<T> = (
|
|
358
|
+
host: TUI,
|
|
359
|
+
theme: Theme,
|
|
360
|
+
keys: KeybindingsManager,
|
|
361
|
+
done: (result: T) => void,
|
|
362
|
+
) => DialogView | Promise<DialogView>;
|
|
363
|
+
|
|
364
|
+
interface PendingView {
|
|
365
|
+
generation: number;
|
|
366
|
+
settled: boolean;
|
|
367
|
+
resolve: (value: unknown) => void;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const dialogScopes = new WeakSet<ExtensionContext>();
|
|
371
|
+
|
|
372
|
+
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
373
|
+
return (
|
|
374
|
+
typeof value === "object" &&
|
|
375
|
+
value !== null &&
|
|
376
|
+
typeof (value as Promise<T>).then === "function"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function safeDispose(component: DialogView | undefined): void {
|
|
381
|
+
if (!component?.dispose) return;
|
|
382
|
+
try {
|
|
383
|
+
component.dispose();
|
|
384
|
+
} catch {
|
|
385
|
+
// A view that fails to dispose must not reject the session promise.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** One overlay for a multi-step dialog. Child views swap without closing it. */
|
|
390
|
+
export class DialogSession {
|
|
391
|
+
private child: DialogView | undefined;
|
|
392
|
+
private focusedState = false;
|
|
393
|
+
private inputLocked = true;
|
|
394
|
+
private closed = false;
|
|
395
|
+
private disposed = false;
|
|
396
|
+
private generation = 0;
|
|
397
|
+
private pending: PendingView | undefined;
|
|
398
|
+
private outerDone: ((result?: void) => void) | undefined;
|
|
399
|
+
|
|
400
|
+
constructor(
|
|
401
|
+
private host: TUI,
|
|
402
|
+
private theme: Theme,
|
|
403
|
+
private keys: KeybindingsManager,
|
|
404
|
+
done: (result?: void) => void,
|
|
405
|
+
) {
|
|
406
|
+
this.outerDone = done;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Current or last held child. Tests drive the active editor through this. */
|
|
410
|
+
getComponent(): DialogView | undefined {
|
|
411
|
+
return this.child;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
restoreFocus(): void {
|
|
415
|
+
if (this.closed) return;
|
|
416
|
+
// Non-overlay custom views restore Pi's normal editor when they finish.
|
|
417
|
+
this.host.setFocus(this);
|
|
418
|
+
this.host.requestRender();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
get focused(): boolean {
|
|
422
|
+
return this.focusedState;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
set focused(value: boolean) {
|
|
426
|
+
this.focusedState = value;
|
|
427
|
+
this.forwardFocus();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Mount an overlay factory into this session. The returned promise settles
|
|
432
|
+
* from the per-view done callback and does not complete the outer overlay.
|
|
433
|
+
*/
|
|
434
|
+
mount<T>(factory: DialogFactory<T>): Promise<T> {
|
|
435
|
+
if (this.closed) return Promise.resolve(undefined as T);
|
|
436
|
+
const generation = ++this.generation;
|
|
437
|
+
this.inputLocked = true;
|
|
438
|
+
this.resolvePending();
|
|
439
|
+
|
|
440
|
+
return new Promise<T>((resolve, reject) => {
|
|
441
|
+
let settled = false;
|
|
442
|
+
const resolveOnce = (value: T) => {
|
|
443
|
+
if (settled) return;
|
|
444
|
+
settled = true;
|
|
445
|
+
if (this.pending?.generation === generation)
|
|
446
|
+
this.pending.settled = true;
|
|
447
|
+
resolve(value);
|
|
448
|
+
};
|
|
449
|
+
const rejectOnce = (error: unknown) => {
|
|
450
|
+
if (settled) return;
|
|
451
|
+
settled = true;
|
|
452
|
+
this.inputLocked = true;
|
|
453
|
+
if (this.pending?.generation === generation)
|
|
454
|
+
this.pending.settled = true;
|
|
455
|
+
reject(error);
|
|
456
|
+
};
|
|
457
|
+
this.pending = {
|
|
458
|
+
generation,
|
|
459
|
+
settled: false,
|
|
460
|
+
resolve: (value) => resolveOnce(value as T),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const finish = (value: T) => {
|
|
464
|
+
if (this.closed || generation !== this.generation) return;
|
|
465
|
+
this.inputLocked = true;
|
|
466
|
+
resolveOnce(value);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const adopt = (component: DialogView) => {
|
|
470
|
+
try {
|
|
471
|
+
this.install(generation, component);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
rejectOnce(error);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const produced = factory(this.host, this.theme, this.keys, finish);
|
|
479
|
+
if (isPromise(produced)) {
|
|
480
|
+
void produced.then(
|
|
481
|
+
(component) => adopt(component),
|
|
482
|
+
(error: unknown) => {
|
|
483
|
+
if (settled || this.closed || generation !== this.generation)
|
|
484
|
+
return;
|
|
485
|
+
rejectOnce(error);
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
} else {
|
|
489
|
+
adopt(produced);
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
// done() may already have resolved; don't reject a settled view or leave a throw unhandled.
|
|
493
|
+
rejectOnce(error);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
handleInput(data: string): void {
|
|
499
|
+
if (this.closed || this.inputLocked) return;
|
|
500
|
+
this.child?.handleInput?.(data);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
invalidate(): void {
|
|
504
|
+
this.child?.invalidate();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
render(width: number): string[] {
|
|
508
|
+
return this.child?.render(width) ?? [];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
dispose(): void {
|
|
512
|
+
if (this.disposed) return;
|
|
513
|
+
this.disposed = true;
|
|
514
|
+
this.closed = true;
|
|
515
|
+
this.inputLocked = true;
|
|
516
|
+
this.resolvePending();
|
|
517
|
+
const child = this.child;
|
|
518
|
+
this.child = undefined;
|
|
519
|
+
safeDispose(child);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Complete the outer overlay once. Safe to call after the host already closed. */
|
|
523
|
+
close(): void {
|
|
524
|
+
if (this.closed) return;
|
|
525
|
+
this.closed = true;
|
|
526
|
+
this.inputLocked = true;
|
|
527
|
+
this.resolvePending();
|
|
528
|
+
const done = this.outerDone;
|
|
529
|
+
this.outerDone = undefined;
|
|
530
|
+
try {
|
|
531
|
+
done?.();
|
|
532
|
+
} finally {
|
|
533
|
+
this.dispose();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private install(generation: number, component: DialogView): void {
|
|
538
|
+
if (this.closed || generation !== this.generation) {
|
|
539
|
+
safeDispose(component);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.replaceChild(component);
|
|
543
|
+
this.inputLocked =
|
|
544
|
+
this.pending?.generation === generation ? this.pending.settled : true;
|
|
545
|
+
this.host.requestRender();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private replaceChild(next: DialogView): void {
|
|
549
|
+
const previous = this.child;
|
|
550
|
+
this.child = next;
|
|
551
|
+
this.forwardFocus();
|
|
552
|
+
if (!previous || previous === next) return;
|
|
553
|
+
if ("focused" in previous) previous.focused = false;
|
|
554
|
+
safeDispose(previous);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private forwardFocus(): void {
|
|
558
|
+
const child = this.child;
|
|
559
|
+
if (!child || !("focused" in child)) return;
|
|
560
|
+
// Read the setter's latest value; the first view can mount before showOverlay focuses us.
|
|
561
|
+
child.focused = this.focused;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private resolvePending(): void {
|
|
565
|
+
const pending = this.pending;
|
|
566
|
+
if (!pending || pending.settled) return;
|
|
567
|
+
pending.settled = true;
|
|
568
|
+
pending.resolve(undefined);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function scopeDialogContext<T extends ExtensionContext>(
|
|
573
|
+
ctx: T,
|
|
574
|
+
session: DialogSession,
|
|
575
|
+
): T {
|
|
576
|
+
const source = ctx.ui;
|
|
577
|
+
const originalCustom = source.custom.bind(source);
|
|
578
|
+
const ui = Object.create(source) as ExtensionUIContext;
|
|
579
|
+
ui.custom = ((factory, options) => {
|
|
580
|
+
if (options?.overlay) return session.mount(factory);
|
|
581
|
+
return originalCustom(factory, options).finally(() =>
|
|
582
|
+
session.restoreFocus(),
|
|
583
|
+
);
|
|
584
|
+
}) as ExtensionUIContext["custom"];
|
|
585
|
+
// Pi exposes ctx.ui as a getter; assignment cannot shadow an inherited accessor.
|
|
586
|
+
// Define an own property while keeping the remaining context getters live.
|
|
587
|
+
return Object.create(ctx, {
|
|
588
|
+
ui: { value: ui, writable: true, enumerable: true, configurable: true },
|
|
589
|
+
}) as T;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Run a dialog workflow inside one ctx.ui.custom overlay.
|
|
594
|
+
* Nested calls on the scoped context reuse that overlay instead of opening another.
|
|
595
|
+
*/
|
|
596
|
+
export async function withDialogSession<T extends ExtensionContext>(
|
|
597
|
+
ctx: T,
|
|
598
|
+
run: (ctx: T) => Promise<void>,
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
if (dialogScopes.has(ctx)) {
|
|
601
|
+
await run(ctx);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let failure: unknown;
|
|
606
|
+
let failed = false;
|
|
607
|
+
let session: DialogSession | undefined;
|
|
608
|
+
try {
|
|
609
|
+
await ctx.ui.custom<void>((host, theme, keys, done) => {
|
|
610
|
+
session = new DialogSession(host, theme, keys, done);
|
|
611
|
+
const scoped = scopeDialogContext(ctx, session);
|
|
612
|
+
dialogScopes.add(scoped);
|
|
613
|
+
// Pi mounts the returned component in a Promise continuation. Defer the
|
|
614
|
+
// workflow so even an immediate return/throw closes after that mount.
|
|
615
|
+
const task = Promise.resolve().then(() => run(scoped));
|
|
616
|
+
void task.then(
|
|
617
|
+
() => {
|
|
618
|
+
try {
|
|
619
|
+
session?.close();
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (!failed) {
|
|
622
|
+
failed = true;
|
|
623
|
+
failure = error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
(error: unknown) => {
|
|
628
|
+
failed = true;
|
|
629
|
+
failure = error;
|
|
630
|
+
try {
|
|
631
|
+
session?.close();
|
|
632
|
+
} catch (closeError) {
|
|
633
|
+
failure ??= closeError;
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
);
|
|
637
|
+
return session;
|
|
638
|
+
}, DIALOG_OPTIONS);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
try {
|
|
641
|
+
session?.close();
|
|
642
|
+
} catch {
|
|
643
|
+
// The original custom rejection is the one that must propagate.
|
|
644
|
+
}
|
|
645
|
+
if (!failed) {
|
|
646
|
+
failed = true;
|
|
647
|
+
failure = error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (failed) throw failure;
|
|
651
|
+
}
|