pi-sdk-web 0.5.14 → 0.5.16
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 +23 -2
- package/dist/static/app.js +49 -0
- package/dist/ui-context.js +127 -24
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -91,6 +91,9 @@ 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
|
+
// Dialog timeouts follow client presence (see WebUIContext): attached ->
|
|
95
|
+
// wait indefinitely (TUI parity), detached -> fallback guard so a closed
|
|
96
|
+
// tab cannot block the agent loop. handleConnection / ws close update it.
|
|
94
97
|
this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
|
|
95
98
|
}
|
|
96
99
|
// ------------------------------------------------------------------
|
|
@@ -239,6 +242,8 @@ export class PiWebServer {
|
|
|
239
242
|
catch {
|
|
240
243
|
// One broken client must not block delivery to the others
|
|
241
244
|
this.clients.delete(client);
|
|
245
|
+
if (this.clients.size === 0)
|
|
246
|
+
this.uiContext.setBrowserAttached(false);
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
}
|
|
@@ -301,8 +306,17 @@ export class PiWebServer {
|
|
|
301
306
|
// ------------------------------------------------------------------
|
|
302
307
|
handleConnection(ws) {
|
|
303
308
|
this.clients.add(ws);
|
|
304
|
-
|
|
305
|
-
|
|
309
|
+
// A browser is watching again: pending dialogs stop counting down (see
|
|
310
|
+
// WebUIContext.setBrowserAttached) - the user gets unlimited time.
|
|
311
|
+
this.uiContext.setBrowserAttached(true);
|
|
312
|
+
const onGone = () => {
|
|
313
|
+
this.clients.delete(ws);
|
|
314
|
+
// Last browser left: arm the fallback so dialogs cannot block the loop.
|
|
315
|
+
if (this.clients.size === 0)
|
|
316
|
+
this.uiContext.setBrowserAttached(false);
|
|
317
|
+
};
|
|
318
|
+
ws.on("close", onGone);
|
|
319
|
+
ws.on("error", onGone);
|
|
306
320
|
ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
|
|
307
321
|
// Initial state (state + history), mirroring the Python bridge
|
|
308
322
|
this.sendJson(ws, { type: "state", data: this.buildState() });
|
|
@@ -325,6 +339,13 @@ export class PiWebServer {
|
|
|
325
339
|
widgetPlacement: w.placement,
|
|
326
340
|
});
|
|
327
341
|
}
|
|
342
|
+
// Pending extension dialogs (select/confirm/input/editor): the request may
|
|
343
|
+
// have been broadcast while this browser was away (closed tab, reload), so
|
|
344
|
+
// re-send it - otherwise the dialog would sit here unanswered and block the
|
|
345
|
+
// agent loop. The id is preserved so the response resolves the same dialog.
|
|
346
|
+
for (const request of this.uiContext.getPendingDialogs()) {
|
|
347
|
+
this.sendJson(ws, { type: "extension_ui_request", ...request });
|
|
348
|
+
}
|
|
328
349
|
}
|
|
329
350
|
sendJson(ws, obj) {
|
|
330
351
|
if (ws.readyState === WebSocket.OPEN) {
|
package/dist/static/app.js
CHANGED
|
@@ -204,6 +204,8 @@ class PiWebClient {
|
|
|
204
204
|
this.modalList = document.getElementById('modal-list');
|
|
205
205
|
this.modalClose = document.getElementById('modal-close');
|
|
206
206
|
this.modalMode = null; // 'model' | 'thinking'
|
|
207
|
+
this.dialogCountdownTimer = null; // extension dialog countdown (TUI parity)
|
|
208
|
+
this.dialogCountdownBase = '';
|
|
207
209
|
this.hasConnectedBefore = false;
|
|
208
210
|
this.commandMenuIndex = -1;
|
|
209
211
|
this.extStatus = {};
|
|
@@ -375,6 +377,9 @@ class PiWebClient {
|
|
|
375
377
|
case 'ext_status':
|
|
376
378
|
this.applyExtStatusSnapshot(data.data);
|
|
377
379
|
break;
|
|
380
|
+
case 'dialog_dismissed':
|
|
381
|
+
this.dismissExtensionDialog(data);
|
|
382
|
+
break;
|
|
378
383
|
case 'extension_ui_request':
|
|
379
384
|
this.handleExtensionUIRequest(data);
|
|
380
385
|
break;
|
|
@@ -1791,6 +1796,7 @@ class PiWebClient {
|
|
|
1791
1796
|
|
|
1792
1797
|
openModal(title, mode) {
|
|
1793
1798
|
this.modalMode = mode;
|
|
1799
|
+
this.stopDialogCountdown();
|
|
1794
1800
|
this.modalTitle.textContent = title;
|
|
1795
1801
|
this.modalSearch.value = '';
|
|
1796
1802
|
this.modalList.innerHTML = '';
|
|
@@ -1798,6 +1804,29 @@ class PiWebClient {
|
|
|
1798
1804
|
this.modalSearch.focus();
|
|
1799
1805
|
}
|
|
1800
1806
|
|
|
1807
|
+
/** Countdown for extension dialogs that carry a deadline (an extension's own
|
|
1808
|
+
* opts.timeout). TUI shows the same "(Ns)" ticker; without a deadline the
|
|
1809
|
+
* dialog waits indefinitely while a browser is attached. */
|
|
1810
|
+
startDialogCountdown(req) {
|
|
1811
|
+
this.stopDialogCountdown();
|
|
1812
|
+
if (!req || typeof req.deadline !== 'number') return;
|
|
1813
|
+
this.dialogCountdownBase = this.modalTitle.textContent || '';
|
|
1814
|
+
const tick = () => {
|
|
1815
|
+
const remain = Math.max(0, Math.ceil((req.deadline - Date.now()) / 1000));
|
|
1816
|
+
this.modalTitle.textContent = `${this.dialogCountdownBase} (${remain}s)`;
|
|
1817
|
+
if (remain <= 0) this.stopDialogCountdown();
|
|
1818
|
+
};
|
|
1819
|
+
tick();
|
|
1820
|
+
this.dialogCountdownTimer = setInterval(tick, 1000);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
stopDialogCountdown() {
|
|
1824
|
+
if (this.dialogCountdownTimer) {
|
|
1825
|
+
clearInterval(this.dialogCountdownTimer);
|
|
1826
|
+
this.dialogCountdownTimer = null;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1801
1830
|
closeModal() {
|
|
1802
1831
|
// If an extension dialog (select/confirm/input/editor) was open, notify
|
|
1803
1832
|
// the server that it was cancelled - otherwise the extension's promise
|
|
@@ -1811,6 +1840,7 @@ class PiWebClient {
|
|
|
1811
1840
|
this.modalSearch.style.display = 'block';
|
|
1812
1841
|
this.modalMode = null;
|
|
1813
1842
|
this.currentExtRequest = null;
|
|
1843
|
+
this.stopDialogCountdown();
|
|
1814
1844
|
this.inputEl.focus();
|
|
1815
1845
|
}
|
|
1816
1846
|
|
|
@@ -1917,6 +1947,7 @@ class PiWebClient {
|
|
|
1917
1947
|
openExtensionSelect(req) {
|
|
1918
1948
|
this.openModal(req.title || 'Select', 'extension-select');
|
|
1919
1949
|
this.currentExtRequest = req;
|
|
1950
|
+
this.startDialogCountdown(req);
|
|
1920
1951
|
this.modalSearch.style.display = 'block';
|
|
1921
1952
|
const items = (req.options || []).map((opt) => ({ name: opt, desc: '', value: opt }));
|
|
1922
1953
|
this.renderModalItems(items, (item) => {
|
|
@@ -1927,6 +1958,7 @@ class PiWebClient {
|
|
|
1927
1958
|
openExtensionConfirm(req) {
|
|
1928
1959
|
this.openModal(req.title || 'Confirm', 'extension-confirm');
|
|
1929
1960
|
this.currentExtRequest = req;
|
|
1961
|
+
this.startDialogCountdown(req);
|
|
1930
1962
|
this.modalSearch.style.display = 'none';
|
|
1931
1963
|
this.modalList.innerHTML = `
|
|
1932
1964
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1947,6 +1979,7 @@ class PiWebClient {
|
|
|
1947
1979
|
openExtensionInput(req) {
|
|
1948
1980
|
this.openModal(req.title || 'Input', 'extension-input');
|
|
1949
1981
|
this.currentExtRequest = req;
|
|
1982
|
+
this.startDialogCountdown(req);
|
|
1950
1983
|
this.modalSearch.style.display = 'none';
|
|
1951
1984
|
this.modalList.innerHTML = `
|
|
1952
1985
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1976,6 +2009,7 @@ class PiWebClient {
|
|
|
1976
2009
|
openExtensionEditor(req) {
|
|
1977
2010
|
this.openModal(req.title || 'Editor', 'extension-editor');
|
|
1978
2011
|
this.currentExtRequest = req;
|
|
2012
|
+
this.startDialogCountdown(req);
|
|
1979
2013
|
this.modalSearch.style.display = 'none';
|
|
1980
2014
|
this.modalList.innerHTML = `
|
|
1981
2015
|
<div class="modal-message">${this.escapeHtml(req.title || '')}</div>
|
|
@@ -1996,6 +2030,21 @@ class PiWebClient {
|
|
|
1996
2030
|
});
|
|
1997
2031
|
}
|
|
1998
2032
|
|
|
2033
|
+
/** Server-side dismissal (timeout / abort / session switch): the dialog was
|
|
2034
|
+
* already settled server-side, so close the modal and say why - do NOT send
|
|
2035
|
+
* an extension_ui_response (there is nothing left to answer). */
|
|
2036
|
+
dismissExtensionDialog({ id, reason } = {}) {
|
|
2037
|
+
if (!id || !this.currentExtRequest || this.currentExtRequest.id !== id) return;
|
|
2038
|
+
const title = this.currentExtRequest.title || 'Dialog';
|
|
2039
|
+
const why =
|
|
2040
|
+
reason === 'aborted' ? 'operation aborted'
|
|
2041
|
+
: reason === 'session-switch' ? 'session switched'
|
|
2042
|
+
: 'no answer received in time';
|
|
2043
|
+
this.currentExtRequest = null; // closeModal() must not answer for us
|
|
2044
|
+
this.closeModal();
|
|
2045
|
+
this.appendNotifyLine(`${title} — auto-dismissed (${why})`, 'warning');
|
|
2046
|
+
}
|
|
2047
|
+
|
|
1999
2048
|
openExtensionNotify(req) {
|
|
2000
2049
|
// Command output notifications (title starts with "/", e.g. /ctx-status) keep
|
|
2001
2050
|
// the modal. Other extension notifies are appended to the chat stream as
|
package/dist/ui-context.js
CHANGED
|
@@ -2,6 +2,25 @@ 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
|
+
* Fallback dialog timeout (pi-web specific guard).
|
|
7
|
+
*
|
|
8
|
+
* TUI and RPC wait for an answer indefinitely - the user is at the terminal,
|
|
9
|
+
* or the RPC client owns the timeout. With a browser attached pi-web does the
|
|
10
|
+
* same: a dialog waits forever, so an answer is never taken away from the user
|
|
11
|
+
* just because they were busy elsewhere.
|
|
12
|
+
*
|
|
13
|
+
* The one case that needs a guard is a browser that is NOT attached (tab
|
|
14
|
+
* closed mid-turn): nobody can answer, and an extension awaiting the dialog
|
|
15
|
+
* would block the agent loop forever. Then - and only then - the dialog
|
|
16
|
+
* settles after this grace period with its default value (confirm -> false,
|
|
17
|
+
* others -> undefined). Reconnecting cancels the fallback, so a user who comes
|
|
18
|
+
* back gets as much time as they need.
|
|
19
|
+
*
|
|
20
|
+
* Extensions that pass their own `timeout` (ms) keep it exactly (that timer is
|
|
21
|
+
* their decision, never cancelled here).
|
|
22
|
+
*/
|
|
23
|
+
const DIALOG_FALLBACK_TIMEOUT_MS = 10 * 60_000;
|
|
5
24
|
/**
|
|
6
25
|
* Load a Pi theme (dark.json/light.json colors) so extensions calling
|
|
7
26
|
* ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
|
|
@@ -69,12 +88,16 @@ export class WebUIContext {
|
|
|
69
88
|
/** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
|
|
70
89
|
* replayed to late-connecting browsers like setStatus snapshots. */
|
|
71
90
|
widgetMap = new Map();
|
|
91
|
+
fallbackTimeoutMs;
|
|
92
|
+
/** Whether a browser is attached right now (updated by the server). */
|
|
93
|
+
browserAttached = false;
|
|
72
94
|
/** Current widget snapshot (key -> lines) for new connections. */
|
|
73
95
|
getWidgetSnapshot() {
|
|
74
96
|
return Object.fromEntries(this.widgetMap);
|
|
75
97
|
}
|
|
76
|
-
constructor(sink) {
|
|
98
|
+
constructor(sink, options = {}) {
|
|
77
99
|
this.sink = sink;
|
|
100
|
+
this.fallbackTimeoutMs = options.fallbackTimeoutMs ?? DIALOG_FALLBACK_TIMEOUT_MS;
|
|
78
101
|
// Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
|
|
79
102
|
// spread) when building the extension ctx - class prototype members
|
|
80
103
|
// (methods AND the theme getter) would be LOST by that spread (only own
|
|
@@ -106,23 +129,26 @@ export class WebUIContext {
|
|
|
106
129
|
}
|
|
107
130
|
/** Drop state tied to the previous session (dialogs + status snapshots). */
|
|
108
131
|
clearSessionState() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
clearTimeout(pending.timer);
|
|
132
|
+
// Copy first: settling deletes from the map while we iterate.
|
|
133
|
+
for (const [id, pending] of [...this.pending.entries()]) {
|
|
112
134
|
pending.resolve(undefined);
|
|
135
|
+
// The browser may still show the dialog; close it there too.
|
|
136
|
+
this.dismissDialog(id, "session-switch");
|
|
113
137
|
}
|
|
114
138
|
this.pending.clear();
|
|
115
139
|
this.statusMap.clear();
|
|
116
140
|
this.widgetMap.clear();
|
|
117
141
|
}
|
|
142
|
+
/** Pending dialogs (wire payloads) for replay to a newly connected browser. */
|
|
143
|
+
getPendingDialogs() {
|
|
144
|
+
return [...this.pending.values()].map((entry) => entry.request);
|
|
145
|
+
}
|
|
118
146
|
/** Handle a browser `extension_ui_response` message. */
|
|
119
147
|
respond(id, response) {
|
|
120
148
|
const pending = this.pending.get(id);
|
|
121
149
|
if (!pending)
|
|
122
150
|
return false;
|
|
123
|
-
|
|
124
|
-
if (pending.timer)
|
|
125
|
-
clearTimeout(pending.timer);
|
|
151
|
+
// Cleanup happens inside settle() (timer / abort listener / map entry).
|
|
126
152
|
if (response.cancelled) {
|
|
127
153
|
pending.resolve(undefined);
|
|
128
154
|
}
|
|
@@ -134,37 +160,114 @@ export class WebUIContext {
|
|
|
134
160
|
}
|
|
135
161
|
return true;
|
|
136
162
|
}
|
|
137
|
-
|
|
163
|
+
/** Tell the browser to close a dialog that already settled server-side. */
|
|
164
|
+
dismissDialog(id, reason) {
|
|
165
|
+
this.sink({ type: "dialog_dismissed", id, reason });
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Browser presence changed (WS connect/disconnect). Only dialogs without an
|
|
169
|
+
* extension timeout are affected: while a browser is attached they wait
|
|
170
|
+
* forever (TUI parity - never take an answer away from the user), while no
|
|
171
|
+
* browser is attached a fallback timer keeps a closed tab from blocking the
|
|
172
|
+
* agent loop. Reconnecting cancels the fallback.
|
|
173
|
+
*/
|
|
174
|
+
setBrowserAttached(attached) {
|
|
175
|
+
if (this.browserAttached === attached)
|
|
176
|
+
return;
|
|
177
|
+
this.browserAttached = attached;
|
|
178
|
+
for (const [id, entry] of [...this.pending.entries()]) {
|
|
179
|
+
if (entry.extensionTimeout !== undefined)
|
|
180
|
+
continue; // extension's own timer
|
|
181
|
+
if (attached) {
|
|
182
|
+
if (entry.fallbackTimer && entry.timer) {
|
|
183
|
+
clearTimeout(entry.timer);
|
|
184
|
+
entry.timer = undefined;
|
|
185
|
+
entry.fallbackTimer = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (!entry.timer) {
|
|
189
|
+
this.armFallbackTimer(id, entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Arm the no-browser fallback (settles with the dialog default). */
|
|
194
|
+
armFallbackTimer(id, entry) {
|
|
195
|
+
entry.fallbackTimer = true;
|
|
196
|
+
entry.timer = setTimeout(() => {
|
|
197
|
+
entry.resolve(undefined);
|
|
198
|
+
this.dismissDialog(id, "timeout");
|
|
199
|
+
}, this.fallbackTimeoutMs);
|
|
200
|
+
}
|
|
201
|
+
createDialog(request, opts, defaultValue, parse) {
|
|
138
202
|
const id = crypto.randomUUID();
|
|
203
|
+
// Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
|
|
204
|
+
if (opts?.signal?.aborted)
|
|
205
|
+
return Promise.resolve(defaultValue);
|
|
139
206
|
return new Promise((resolve) => {
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
207
|
+
let settled = false;
|
|
208
|
+
const cleanup = () => {
|
|
209
|
+
const entry = this.pending.get(id);
|
|
210
|
+
if (!entry)
|
|
211
|
+
return;
|
|
212
|
+
if (entry.timer)
|
|
213
|
+
clearTimeout(entry.timer);
|
|
214
|
+
if (entry.signal && entry.onAbort)
|
|
215
|
+
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
216
|
+
this.pending.delete(id);
|
|
217
|
+
};
|
|
218
|
+
const settle = (value, useDefault) => {
|
|
219
|
+
if (settled)
|
|
220
|
+
return;
|
|
221
|
+
settled = true;
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve(useDefault ? defaultValue : parse(value));
|
|
224
|
+
};
|
|
225
|
+
const onAbort = () => {
|
|
226
|
+
settle(undefined, true);
|
|
227
|
+
this.dismissDialog(id, "aborted");
|
|
228
|
+
};
|
|
229
|
+
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
230
|
+
const extensionTimeout = typeof opts?.timeout === "number" && opts.timeout > 0 ? opts.timeout : undefined;
|
|
231
|
+
const deadline = extensionTimeout !== undefined ? Date.now() + extensionTimeout : undefined;
|
|
232
|
+
const entry = {
|
|
233
|
+
resolve: (value) => settle(value, false),
|
|
234
|
+
// `deadline` lets the browser show the same countdown TUI does.
|
|
235
|
+
request: deadline !== undefined ? { ...request, id, deadline } : { ...request, id },
|
|
236
|
+
deadline,
|
|
237
|
+
extensionTimeout,
|
|
238
|
+
fallbackTimer: false,
|
|
239
|
+
signal: opts?.signal,
|
|
240
|
+
onAbort,
|
|
241
|
+
};
|
|
242
|
+
this.pending.set(id, entry);
|
|
243
|
+
if (extensionTimeout !== undefined) {
|
|
244
|
+
// The extension asked for this deadline - honour it exactly (TUI
|
|
245
|
+
// shows the same countdown and auto-dismisses on expiry).
|
|
246
|
+
entry.timer = setTimeout(() => {
|
|
247
|
+
settle(undefined, true);
|
|
248
|
+
this.dismissDialog(id, "timeout");
|
|
249
|
+
}, extensionTimeout);
|
|
250
|
+
}
|
|
251
|
+
else if (!this.browserAttached) {
|
|
252
|
+
this.armFallbackTimer(id, entry);
|
|
253
|
+
}
|
|
254
|
+
this.sink({ type: "extension_ui_request", id, ...request, ...(deadline !== undefined ? { deadline } : {}) });
|
|
152
255
|
});
|
|
153
256
|
}
|
|
154
257
|
// ------------------------------------------------------------------
|
|
155
258
|
// Dialogs (browser resolves via extension_ui_response)
|
|
156
259
|
// ------------------------------------------------------------------
|
|
157
260
|
select(title, options, opts) {
|
|
158
|
-
return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
261
|
+
return this.createDialog({ method: "select", title, options, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
159
262
|
}
|
|
160
263
|
confirm(title, message, opts) {
|
|
161
|
-
return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, false, (v) => v === true);
|
|
264
|
+
return this.createDialog({ method: "confirm", title, message, timeout: opts?.timeout }, opts, false, (v) => v === true);
|
|
162
265
|
}
|
|
163
266
|
input(title, placeholder, opts) {
|
|
164
|
-
return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
267
|
+
return this.createDialog({ method: "input", title, placeholder, timeout: opts?.timeout }, opts, undefined, (v) => (typeof v === "string" ? v : undefined));
|
|
165
268
|
}
|
|
166
269
|
editor(title, prefill) {
|
|
167
|
-
return this.createDialog({ method: "editor", title, prefill }, undefined, (v) => typeof v === "string" ? v : undefined);
|
|
270
|
+
return this.createDialog({ method: "editor", title, prefill }, undefined, undefined, (v) => typeof v === "string" ? v : undefined);
|
|
168
271
|
}
|
|
169
272
|
// ------------------------------------------------------------------
|
|
170
273
|
// Fire-and-forget UI events (broadcast to browser)
|