pi-sdk-web 0.5.13 → 0.5.15
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/dist/server.js +39 -12
- package/dist/static/app.js +18 -0
- package/dist/ui-context.js +82 -20
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -91,7 +91,12 @@ export class PiWebServer {
|
|
|
91
91
|
this.runtime = runtime;
|
|
92
92
|
this.port = options.port ?? DEFAULT_PORT;
|
|
93
93
|
this.staticDir = options.staticDir ?? STATIC_DIR;
|
|
94
|
-
this.uiContext = new WebUIContext((obj) => this.broadcast(obj)
|
|
94
|
+
this.uiContext = new WebUIContext((obj) => this.broadcast(obj), {
|
|
95
|
+
// Dialog timeout tier: with a browser attached the user may step away,
|
|
96
|
+
// so dialogs wait long; with none attached they settle after a short
|
|
97
|
+
// grace period instead of blocking the agent loop forever.
|
|
98
|
+
hasClients: () => this.clients.size > 0,
|
|
99
|
+
});
|
|
95
100
|
}
|
|
96
101
|
// ------------------------------------------------------------------
|
|
97
102
|
// Lifecycle
|
|
@@ -325,6 +330,13 @@ export class PiWebServer {
|
|
|
325
330
|
widgetPlacement: w.placement,
|
|
326
331
|
});
|
|
327
332
|
}
|
|
333
|
+
// Pending extension dialogs (select/confirm/input/editor): the request may
|
|
334
|
+
// have been broadcast while this browser was away (closed tab, reload), so
|
|
335
|
+
// re-send it - otherwise the dialog would sit here unanswered and block the
|
|
336
|
+
// agent loop. The id is preserved so the response resolves the same dialog.
|
|
337
|
+
for (const request of this.uiContext.getPendingDialogs()) {
|
|
338
|
+
this.sendJson(ws, { type: "extension_ui_request", ...request });
|
|
339
|
+
}
|
|
328
340
|
}
|
|
329
341
|
sendJson(ws, obj) {
|
|
330
342
|
if (ws.readyState === WebSocket.OPEN) {
|
|
@@ -882,22 +894,37 @@ export class PiWebServer {
|
|
|
882
894
|
}
|
|
883
895
|
};
|
|
884
896
|
const unsubscribe = this.session.subscribe(listener);
|
|
885
|
-
// hasUI
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
// (
|
|
897
|
+
// hasUI per extension: extensions whose command handler draws a TUI
|
|
898
|
+
// panel via ctx.ui.custom() when hasUI=true need hasUI:false so they
|
|
899
|
+
// use their text fallback instead (pi-web can't render custom()).
|
|
900
|
+
// magic-context /ctx-status -> appendEntry (custom entry -> modal);
|
|
901
|
+
// aft-pi /aft-status (its only command) -> ui.notify(text).
|
|
902
|
+
// Everything else gets hasUI:true so notify-based output works.
|
|
890
903
|
const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
|
|
891
|
-
const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
|
|
904
|
+
const customOnly = /pi-magic-context|magic-context|aft-pi/.test(cmdPath);
|
|
905
|
+
// While a command runs, tag extension notify() broadcasts with the
|
|
906
|
+
// command name as title so the browser renders them as a titled,
|
|
907
|
+
// Markdown modal (e.g. /ctx-status, /aft-status) - the command's status
|
|
908
|
+
// report is a dialog, not a transient stream line. Restores the design
|
|
909
|
+
// in 68b56f1 (recorded in pi-web-design.md §14.9); 41112f5 had dropped
|
|
910
|
+
// the wrapping, so command notifies fell back to persistent status
|
|
911
|
+
// lines while custom-entry commands (/ctx-status) kept the modal.
|
|
912
|
+
const ui = this.uiContext;
|
|
913
|
+
const originalSink = ui.sink;
|
|
892
914
|
try {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
915
|
+
ui.sink = (obj) => {
|
|
916
|
+
const req = obj;
|
|
917
|
+
if (req && req.method === "notify") {
|
|
918
|
+
originalSink({ ...obj, title: `/${name}` });
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
originalSink(obj);
|
|
922
|
+
}
|
|
923
|
+
};
|
|
898
924
|
await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
|
|
899
925
|
}
|
|
900
926
|
finally {
|
|
927
|
+
ui.sink = originalSink;
|
|
901
928
|
unsubscribe();
|
|
902
929
|
}
|
|
903
930
|
// Show command output as a modal (TUI-like). The session entry remains
|
package/dist/static/app.js
CHANGED
|
@@ -375,6 +375,9 @@ class PiWebClient {
|
|
|
375
375
|
case 'ext_status':
|
|
376
376
|
this.applyExtStatusSnapshot(data.data);
|
|
377
377
|
break;
|
|
378
|
+
case 'dialog_dismissed':
|
|
379
|
+
this.dismissExtensionDialog(data);
|
|
380
|
+
break;
|
|
378
381
|
case 'extension_ui_request':
|
|
379
382
|
this.handleExtensionUIRequest(data);
|
|
380
383
|
break;
|
|
@@ -1996,6 +1999,21 @@ class PiWebClient {
|
|
|
1996
1999
|
});
|
|
1997
2000
|
}
|
|
1998
2001
|
|
|
2002
|
+
/** Server-side dismissal (timeout / abort / session switch): the dialog was
|
|
2003
|
+
* already settled server-side, so close the modal and say why - do NOT send
|
|
2004
|
+
* an extension_ui_response (there is nothing left to answer). */
|
|
2005
|
+
dismissExtensionDialog({ id, reason } = {}) {
|
|
2006
|
+
if (!id || !this.currentExtRequest || this.currentExtRequest.id !== id) return;
|
|
2007
|
+
const title = this.currentExtRequest.title || 'Dialog';
|
|
2008
|
+
const why =
|
|
2009
|
+
reason === 'aborted' ? 'operation aborted'
|
|
2010
|
+
: reason === 'session-switch' ? 'session switched'
|
|
2011
|
+
: 'no answer received in time';
|
|
2012
|
+
this.currentExtRequest = null; // closeModal() must not answer for us
|
|
2013
|
+
this.closeModal();
|
|
2014
|
+
this.appendNotifyLine(`${title} — auto-dismissed (${why})`, 'warning');
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1999
2017
|
openExtensionNotify(req) {
|
|
2000
2018
|
// Command output notifications (title starts with "/", e.g. /ctx-status) keep
|
|
2001
2019
|
// the modal. Other extension notifies are appended to the chat stream as
|
package/dist/ui-context.js
CHANGED
|
@@ -2,6 +2,22 @@ import { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
/**
|
|
6
|
+
* Default dialog timeouts (pi-web specific guard).
|
|
7
|
+
*
|
|
8
|
+
* TUI and RPC block until the client answers - the user is at the terminal, or
|
|
9
|
+
* the RPC client owns the timeout. A browser tab can be closed mid-turn, so a
|
|
10
|
+
* dialog nobody can see would block the agent loop forever. Two tiers:
|
|
11
|
+
*
|
|
12
|
+
* - browser attached: the user may have stepped away; wait long, then settle
|
|
13
|
+
* with the dialog's default value (confirm -> false, others -> undefined);
|
|
14
|
+
* - no browser attached: a closed tab must not stall the loop, but a page
|
|
15
|
+
* reload/reconnect takes a moment - short grace period, not instant.
|
|
16
|
+
*
|
|
17
|
+
* Extensions that pass their own `timeout` (ms) keep it.
|
|
18
|
+
*/
|
|
19
|
+
const DIALOG_TIMEOUT_MS = 10 * 60_000;
|
|
20
|
+
const DIALOG_TIMEOUT_NO_CLIENT_MS = 60_000;
|
|
5
21
|
/**
|
|
6
22
|
* Load a Pi theme (dark.json/light.json colors) so extensions calling
|
|
7
23
|
* ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
|
|
@@ -69,12 +85,18 @@ export class WebUIContext {
|
|
|
69
85
|
/** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
|
|
70
86
|
* replayed to late-connecting browsers like setStatus snapshots. */
|
|
71
87
|
widgetMap = new Map();
|
|
88
|
+
hasClients;
|
|
89
|
+
dialogTimeoutMs;
|
|
90
|
+
dialogTimeoutNoClientMs;
|
|
72
91
|
/** Current widget snapshot (key -> lines) for new connections. */
|
|
73
92
|
getWidgetSnapshot() {
|
|
74
93
|
return Object.fromEntries(this.widgetMap);
|
|
75
94
|
}
|
|
76
|
-
constructor(sink) {
|
|
95
|
+
constructor(sink, options = {}) {
|
|
77
96
|
this.sink = sink;
|
|
97
|
+
this.hasClients = options.hasClients ?? (() => false);
|
|
98
|
+
this.dialogTimeoutMs = options.dialogTimeoutMs ?? DIALOG_TIMEOUT_MS;
|
|
99
|
+
this.dialogTimeoutNoClientMs = options.dialogTimeoutNoClientMs ?? DIALOG_TIMEOUT_NO_CLIENT_MS;
|
|
78
100
|
// Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
|
|
79
101
|
// spread) when building the extension ctx - class prototype members
|
|
80
102
|
// (methods AND the theme getter) would be LOST by that spread (only own
|
|
@@ -106,23 +128,26 @@ export class WebUIContext {
|
|
|
106
128
|
}
|
|
107
129
|
/** Drop state tied to the previous session (dialogs + status snapshots). */
|
|
108
130
|
clearSessionState() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
clearTimeout(pending.timer);
|
|
131
|
+
// Copy first: settling deletes from the map while we iterate.
|
|
132
|
+
for (const [id, pending] of [...this.pending.entries()]) {
|
|
112
133
|
pending.resolve(undefined);
|
|
134
|
+
// The browser may still show the dialog; close it there too.
|
|
135
|
+
this.dismissDialog(id, "session-switch");
|
|
113
136
|
}
|
|
114
137
|
this.pending.clear();
|
|
115
138
|
this.statusMap.clear();
|
|
116
139
|
this.widgetMap.clear();
|
|
117
140
|
}
|
|
141
|
+
/** Pending dialogs (wire payloads) for replay to a newly connected browser. */
|
|
142
|
+
getPendingDialogs() {
|
|
143
|
+
return [...this.pending.values()].map((entry) => entry.request);
|
|
144
|
+
}
|
|
118
145
|
/** Handle a browser `extension_ui_response` message. */
|
|
119
146
|
respond(id, response) {
|
|
120
147
|
const pending = this.pending.get(id);
|
|
121
148
|
if (!pending)
|
|
122
149
|
return false;
|
|
123
|
-
|
|
124
|
-
if (pending.timer)
|
|
125
|
-
clearTimeout(pending.timer);
|
|
150
|
+
// Cleanup happens inside settle() (timer / abort listener / map entry).
|
|
126
151
|
if (response.cancelled) {
|
|
127
152
|
pending.resolve(undefined);
|
|
128
153
|
}
|
|
@@ -134,19 +159,56 @@ export class WebUIContext {
|
|
|
134
159
|
}
|
|
135
160
|
return true;
|
|
136
161
|
}
|
|
137
|
-
|
|
162
|
+
/** Tell the browser to close a dialog that already settled server-side. */
|
|
163
|
+
dismissDialog(id, reason) {
|
|
164
|
+
this.sink({ type: "dialog_dismissed", id, reason });
|
|
165
|
+
}
|
|
166
|
+
createDialog(request, opts, defaultValue, parse) {
|
|
138
167
|
const id = crypto.randomUUID();
|
|
168
|
+
// Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
|
|
169
|
+
if (opts?.signal?.aborted)
|
|
170
|
+
return Promise.resolve(defaultValue);
|
|
139
171
|
return new Promise((resolve) => {
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
let settled = false;
|
|
173
|
+
const cleanup = () => {
|
|
174
|
+
const entry = this.pending.get(id);
|
|
175
|
+
if (!entry)
|
|
176
|
+
return;
|
|
177
|
+
if (entry.timer)
|
|
178
|
+
clearTimeout(entry.timer);
|
|
179
|
+
if (entry.signal && entry.onAbort)
|
|
180
|
+
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
181
|
+
this.pending.delete(id);
|
|
182
|
+
};
|
|
183
|
+
const settle = (value, useDefault) => {
|
|
184
|
+
if (settled)
|
|
185
|
+
return;
|
|
186
|
+
settled = true;
|
|
187
|
+
cleanup();
|
|
188
|
+
resolve(useDefault ? defaultValue : parse(value));
|
|
189
|
+
};
|
|
190
|
+
// Extension timeout (ms) wins; otherwise the web guard applies so a
|
|
191
|
+
// closed browser cannot block the agent loop forever.
|
|
192
|
+
const timeoutMs = typeof opts?.timeout === "number"
|
|
193
|
+
? opts.timeout
|
|
194
|
+
: this.hasClients()
|
|
195
|
+
? this.dialogTimeoutMs
|
|
196
|
+
: this.dialogTimeoutNoClientMs;
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
settle(undefined, true);
|
|
199
|
+
this.dismissDialog(id, "timeout");
|
|
200
|
+
}, timeoutMs);
|
|
201
|
+
const onAbort = () => {
|
|
202
|
+
settle(undefined, true);
|
|
203
|
+
this.dismissDialog(id, "aborted");
|
|
204
|
+
};
|
|
205
|
+
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
147
206
|
this.pending.set(id, {
|
|
148
|
-
resolve: (value) =>
|
|
207
|
+
resolve: (value) => settle(value, false),
|
|
208
|
+
request: { ...request, id },
|
|
149
209
|
timer,
|
|
210
|
+
signal: opts?.signal,
|
|
211
|
+
onAbort,
|
|
150
212
|
});
|
|
151
213
|
this.sink({ type: "extension_ui_request", id, ...request });
|
|
152
214
|
});
|
|
@@ -155,16 +217,16 @@ export class WebUIContext {
|
|
|
155
217
|
// Dialogs (browser resolves via extension_ui_response)
|
|
156
218
|
// ------------------------------------------------------------------
|
|
157
219
|
select(title, options, opts) {
|
|
158
|
-
return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
220
|
+
return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
159
221
|
}
|
|
160
222
|
confirm(title, message, opts) {
|
|
161
|
-
return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, false, (v) => v === true);
|
|
223
|
+
return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, opts, false, (v) => v === true);
|
|
162
224
|
}
|
|
163
225
|
input(title, placeholder, opts) {
|
|
164
|
-
return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
226
|
+
return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
165
227
|
}
|
|
166
228
|
editor(title, prefill) {
|
|
167
|
-
return this.createDialog({ method: "editor", title, prefill }, undefined, (v) => typeof v === "string" ? v : undefined);
|
|
229
|
+
return this.createDialog({ method: "editor", title, prefill }, undefined, undefined, (v) => typeof v === "string" ? v : undefined);
|
|
168
230
|
}
|
|
169
231
|
// ------------------------------------------------------------------
|
|
170
232
|
// Fire-and-forget UI events (broadcast to browser)
|