pi-sdk-web 0.5.15 → 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 +17 -8
- package/dist/static/app.js +31 -0
- package/dist/ui-context.js +74 -33
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -91,12 +91,10 @@ 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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
hasClients: () => this.clients.size > 0,
|
|
99
|
-
});
|
|
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.
|
|
97
|
+
this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
|
|
100
98
|
}
|
|
101
99
|
// ------------------------------------------------------------------
|
|
102
100
|
// Lifecycle
|
|
@@ -244,6 +242,8 @@ export class PiWebServer {
|
|
|
244
242
|
catch {
|
|
245
243
|
// One broken client must not block delivery to the others
|
|
246
244
|
this.clients.delete(client);
|
|
245
|
+
if (this.clients.size === 0)
|
|
246
|
+
this.uiContext.setBrowserAttached(false);
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
}
|
|
@@ -306,8 +306,17 @@ export class PiWebServer {
|
|
|
306
306
|
// ------------------------------------------------------------------
|
|
307
307
|
handleConnection(ws) {
|
|
308
308
|
this.clients.add(ws);
|
|
309
|
-
|
|
310
|
-
|
|
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);
|
|
311
320
|
ws.on("message", (data) => this.handleClientMessage(ws, String(data)));
|
|
312
321
|
// Initial state (state + history), mirroring the Python bridge
|
|
313
322
|
this.sendJson(ws, { type: "state", data: this.buildState() });
|
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 = {};
|
|
@@ -1794,6 +1796,7 @@ class PiWebClient {
|
|
|
1794
1796
|
|
|
1795
1797
|
openModal(title, mode) {
|
|
1796
1798
|
this.modalMode = mode;
|
|
1799
|
+
this.stopDialogCountdown();
|
|
1797
1800
|
this.modalTitle.textContent = title;
|
|
1798
1801
|
this.modalSearch.value = '';
|
|
1799
1802
|
this.modalList.innerHTML = '';
|
|
@@ -1801,6 +1804,29 @@ class PiWebClient {
|
|
|
1801
1804
|
this.modalSearch.focus();
|
|
1802
1805
|
}
|
|
1803
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
|
+
|
|
1804
1830
|
closeModal() {
|
|
1805
1831
|
// If an extension dialog (select/confirm/input/editor) was open, notify
|
|
1806
1832
|
// the server that it was cancelled - otherwise the extension's promise
|
|
@@ -1814,6 +1840,7 @@ class PiWebClient {
|
|
|
1814
1840
|
this.modalSearch.style.display = 'block';
|
|
1815
1841
|
this.modalMode = null;
|
|
1816
1842
|
this.currentExtRequest = null;
|
|
1843
|
+
this.stopDialogCountdown();
|
|
1817
1844
|
this.inputEl.focus();
|
|
1818
1845
|
}
|
|
1819
1846
|
|
|
@@ -1920,6 +1947,7 @@ class PiWebClient {
|
|
|
1920
1947
|
openExtensionSelect(req) {
|
|
1921
1948
|
this.openModal(req.title || 'Select', 'extension-select');
|
|
1922
1949
|
this.currentExtRequest = req;
|
|
1950
|
+
this.startDialogCountdown(req);
|
|
1923
1951
|
this.modalSearch.style.display = 'block';
|
|
1924
1952
|
const items = (req.options || []).map((opt) => ({ name: opt, desc: '', value: opt }));
|
|
1925
1953
|
this.renderModalItems(items, (item) => {
|
|
@@ -1930,6 +1958,7 @@ class PiWebClient {
|
|
|
1930
1958
|
openExtensionConfirm(req) {
|
|
1931
1959
|
this.openModal(req.title || 'Confirm', 'extension-confirm');
|
|
1932
1960
|
this.currentExtRequest = req;
|
|
1961
|
+
this.startDialogCountdown(req);
|
|
1933
1962
|
this.modalSearch.style.display = 'none';
|
|
1934
1963
|
this.modalList.innerHTML = `
|
|
1935
1964
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1950,6 +1979,7 @@ class PiWebClient {
|
|
|
1950
1979
|
openExtensionInput(req) {
|
|
1951
1980
|
this.openModal(req.title || 'Input', 'extension-input');
|
|
1952
1981
|
this.currentExtRequest = req;
|
|
1982
|
+
this.startDialogCountdown(req);
|
|
1953
1983
|
this.modalSearch.style.display = 'none';
|
|
1954
1984
|
this.modalList.innerHTML = `
|
|
1955
1985
|
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
@@ -1979,6 +2009,7 @@ class PiWebClient {
|
|
|
1979
2009
|
openExtensionEditor(req) {
|
|
1980
2010
|
this.openModal(req.title || 'Editor', 'extension-editor');
|
|
1981
2011
|
this.currentExtRequest = req;
|
|
2012
|
+
this.startDialogCountdown(req);
|
|
1982
2013
|
this.modalSearch.style.display = 'none';
|
|
1983
2014
|
this.modalList.innerHTML = `
|
|
1984
2015
|
<div class="modal-message">${this.escapeHtml(req.title || '')}</div>
|
package/dist/ui-context.js
CHANGED
|
@@ -3,21 +3,24 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Fallback dialog timeout (pi-web specific guard).
|
|
7
7
|
*
|
|
8
|
-
* TUI and RPC
|
|
9
|
-
* the RPC client owns the timeout.
|
|
10
|
-
* dialog
|
|
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.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
16
19
|
*
|
|
17
|
-
* Extensions that pass their own `timeout` (ms) keep it
|
|
20
|
+
* Extensions that pass their own `timeout` (ms) keep it exactly (that timer is
|
|
21
|
+
* their decision, never cancelled here).
|
|
18
22
|
*/
|
|
19
|
-
const
|
|
20
|
-
const DIALOG_TIMEOUT_NO_CLIENT_MS = 60_000;
|
|
23
|
+
const DIALOG_FALLBACK_TIMEOUT_MS = 10 * 60_000;
|
|
21
24
|
/**
|
|
22
25
|
* Load a Pi theme (dark.json/light.json colors) so extensions calling
|
|
23
26
|
* ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes — identical
|
|
@@ -85,18 +88,16 @@ export class WebUIContext {
|
|
|
85
88
|
/** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
|
|
86
89
|
* replayed to late-connecting browsers like setStatus snapshots. */
|
|
87
90
|
widgetMap = new Map();
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
fallbackTimeoutMs;
|
|
92
|
+
/** Whether a browser is attached right now (updated by the server). */
|
|
93
|
+
browserAttached = false;
|
|
91
94
|
/** Current widget snapshot (key -> lines) for new connections. */
|
|
92
95
|
getWidgetSnapshot() {
|
|
93
96
|
return Object.fromEntries(this.widgetMap);
|
|
94
97
|
}
|
|
95
98
|
constructor(sink, options = {}) {
|
|
96
99
|
this.sink = sink;
|
|
97
|
-
this.
|
|
98
|
-
this.dialogTimeoutMs = options.dialogTimeoutMs ?? DIALOG_TIMEOUT_MS;
|
|
99
|
-
this.dialogTimeoutNoClientMs = options.dialogTimeoutNoClientMs ?? DIALOG_TIMEOUT_NO_CLIENT_MS;
|
|
100
|
+
this.fallbackTimeoutMs = options.fallbackTimeoutMs ?? DIALOG_FALLBACK_TIMEOUT_MS;
|
|
100
101
|
// Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
|
|
101
102
|
// spread) when building the extension ctx - class prototype members
|
|
102
103
|
// (methods AND the theme getter) would be LOST by that spread (only own
|
|
@@ -163,6 +164,40 @@ export class WebUIContext {
|
|
|
163
164
|
dismissDialog(id, reason) {
|
|
164
165
|
this.sink({ type: "dialog_dismissed", id, reason });
|
|
165
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
|
+
}
|
|
166
201
|
createDialog(request, opts, defaultValue, parse) {
|
|
167
202
|
const id = crypto.randomUUID();
|
|
168
203
|
// Same as Pi's RPC/TUI: an already-aborted dialog settles immediately.
|
|
@@ -187,30 +222,36 @@ export class WebUIContext {
|
|
|
187
222
|
cleanup();
|
|
188
223
|
resolve(useDefault ? defaultValue : parse(value));
|
|
189
224
|
};
|
|
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
225
|
const onAbort = () => {
|
|
202
226
|
settle(undefined, true);
|
|
203
227
|
this.dismissDialog(id, "aborted");
|
|
204
228
|
};
|
|
205
229
|
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
206
|
-
|
|
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 = {
|
|
207
233
|
resolve: (value) => settle(value, false),
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
210
239
|
signal: opts?.signal,
|
|
211
240
|
onAbort,
|
|
212
|
-
}
|
|
213
|
-
this.
|
|
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 } : {}) });
|
|
214
255
|
});
|
|
215
256
|
}
|
|
216
257
|
// ------------------------------------------------------------------
|