@firstpick/pi-package-webui 0.1.2 → 0.1.4
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/README.md +18 -9
- package/bin/pi-webui.mjs +1039 -33
- package/index.ts +136 -29
- package/package.json +3 -2
- package/public/app.js +3025 -137
- package/public/index.html +43 -3
- package/public/service-worker.js +13 -1
- package/public/styles.css +1031 -131
- package/tests/mobile-static.test.mjs +260 -2
package/index.ts
CHANGED
|
@@ -34,6 +34,17 @@ type ExistingWebui = {
|
|
|
34
34
|
piPid?: number;
|
|
35
35
|
network?: any;
|
|
36
36
|
tabs?: any[];
|
|
37
|
+
restorableTabs?: any[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type RestorableWebuiTab = {
|
|
41
|
+
id?: string;
|
|
42
|
+
index?: number;
|
|
43
|
+
title?: string;
|
|
44
|
+
titleSource?: string;
|
|
45
|
+
conversationStarted?: boolean;
|
|
46
|
+
cwd?: string;
|
|
47
|
+
sessionFile?: string;
|
|
37
48
|
};
|
|
38
49
|
|
|
39
50
|
type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
@@ -202,6 +213,88 @@ async function probeExistingWebui(url: string): Promise<ExistingWebui | null> {
|
|
|
202
213
|
return body;
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
function boundedString(value: unknown, maxLength: number): string | undefined {
|
|
217
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
218
|
+
return text ? text.slice(0, maxLength) : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function restorableTabsFromStatus(tabs: unknown, options: StartWebuiOptions): RestorableWebuiTab[] {
|
|
222
|
+
if (!Array.isArray(tabs)) return [];
|
|
223
|
+
|
|
224
|
+
const restored: RestorableWebuiTab[] = [];
|
|
225
|
+
const seenIds = new Set<string>();
|
|
226
|
+
for (const item of tabs) {
|
|
227
|
+
if (!item || typeof item !== "object") continue;
|
|
228
|
+
const tab = item as any;
|
|
229
|
+
const state = tab.state && typeof tab.state === "object" ? tab.state : {};
|
|
230
|
+
const id = boundedString(tab.id, 128);
|
|
231
|
+
const safeId = id && /^[A-Za-z0-9._:-]+$/.test(id) && !seenIds.has(id) ? id : undefined;
|
|
232
|
+
if (safeId) seenIds.add(safeId);
|
|
233
|
+
|
|
234
|
+
const restoreTab: RestorableWebuiTab = {
|
|
235
|
+
id: safeId,
|
|
236
|
+
title: boundedString(tab.title, 160),
|
|
237
|
+
titleSource: boundedString(tab.titleSource, 32),
|
|
238
|
+
cwd: boundedString(tab.cwd || tab.workspace?.cwd, 4096),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (tab.conversationStarted === true) restoreTab.conversationStarted = true;
|
|
242
|
+
if (Number.isInteger(tab.index) && tab.index > 0) restoreTab.index = tab.index;
|
|
243
|
+
if (!options.noSession) restoreTab.sessionFile = boundedString(state.sessionFile || tab.sessionFile, 4096);
|
|
244
|
+
|
|
245
|
+
restored.push(restoreTab);
|
|
246
|
+
if (restored.length >= 30) break;
|
|
247
|
+
}
|
|
248
|
+
return restored;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function restorableTabKeys(tab: RestorableWebuiTab): string[] {
|
|
252
|
+
const keys: string[] = [];
|
|
253
|
+
if (tab.id) keys.push(`id:${tab.id}`);
|
|
254
|
+
if (tab.sessionFile) keys.push(`session:${tab.sessionFile}`);
|
|
255
|
+
const fallback = [tab.index || "", tab.cwd || "", tab.title || ""].join("\0");
|
|
256
|
+
if (fallback.replace(/\0/g, "")) keys.push(`tab:${fallback}`);
|
|
257
|
+
return keys;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function mergeRestorableTabDescriptor(current: RestorableWebuiTab, next: RestorableWebuiTab): RestorableWebuiTab {
|
|
261
|
+
const merged: RestorableWebuiTab = { ...current };
|
|
262
|
+
for (const [key, value] of Object.entries(next) as [keyof RestorableWebuiTab, RestorableWebuiTab[keyof RestorableWebuiTab]][]) {
|
|
263
|
+
if (value !== undefined && value !== "") (merged as any)[key] = value;
|
|
264
|
+
}
|
|
265
|
+
return merged;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mergeRestorableTabsFromStatusSources(sources: unknown[], options: StartWebuiOptions): RestorableWebuiTab[] {
|
|
269
|
+
const merged: RestorableWebuiTab[] = [];
|
|
270
|
+
const keyToIndex = new Map<string, number>();
|
|
271
|
+
|
|
272
|
+
for (const source of sources) {
|
|
273
|
+
for (const tab of restorableTabsFromStatus(source, options)) {
|
|
274
|
+
const keys = restorableTabKeys(tab);
|
|
275
|
+
const existingIndex = keys.map((key) => keyToIndex.get(key)).find((index): index is number => index !== undefined);
|
|
276
|
+
if (existingIndex === undefined) {
|
|
277
|
+
if (merged.length >= 30) continue;
|
|
278
|
+
const index = merged.length;
|
|
279
|
+
merged.push(tab);
|
|
280
|
+
for (const key of keys) keyToIndex.set(key, index);
|
|
281
|
+
} else {
|
|
282
|
+
merged[existingIndex] = mergeRestorableTabDescriptor(merged[existingIndex], tab);
|
|
283
|
+
for (const key of restorableTabKeys(merged[existingIndex])) keyToIndex.set(key, existingIndex);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return merged.slice(0, 30);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function fetchRestorableTabs(url: string, existing: ExistingWebui, options: StartWebuiOptions): Promise<RestorableWebuiTab[]> {
|
|
292
|
+
const baseUrl = url.replace(/\/$/, "");
|
|
293
|
+
const detailed = await fetchJsonWithTimeout(`${baseUrl}/api/webui-status?detailed=1&events=0`, {}, 7_000);
|
|
294
|
+
const statusData = detailed?.ok && detailed.body?.ok === true ? detailed.body.data : undefined;
|
|
295
|
+
return mergeRestorableTabsFromStatusSources([statusData?.restorableTabs, statusData?.tabs, existing.restorableTabs, existing.tabs], options);
|
|
296
|
+
}
|
|
297
|
+
|
|
205
298
|
function sleep(ms: number): Promise<void> {
|
|
206
299
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
207
300
|
}
|
|
@@ -391,15 +484,18 @@ function waitForWebuiUrl(child: WebuiChild): Promise<string> {
|
|
|
391
484
|
});
|
|
392
485
|
}
|
|
393
486
|
|
|
394
|
-
async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext): Promise<string> {
|
|
487
|
+
async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext, restoreTabs: RestorableWebuiTab[] = []): Promise<string> {
|
|
395
488
|
const args = [webuiBin, "--host", options.host, "--port", String(options.port), "--cwd", ctx.cwd];
|
|
396
489
|
if (options.noSession) args.push("--no-session");
|
|
397
490
|
if (options.name) args.push("--name", options.name);
|
|
398
491
|
if (options.piArgs.length > 0) args.push("--", ...options.piArgs);
|
|
399
492
|
|
|
493
|
+
const env = { ...process.env };
|
|
494
|
+
if (restoreTabs.length > 0) env.PI_WEBUI_RESTORE_TABS = JSON.stringify(restoreTabs);
|
|
495
|
+
|
|
400
496
|
const child = spawn(process.execPath, args, {
|
|
401
497
|
cwd: ctx.cwd,
|
|
402
|
-
env
|
|
498
|
+
env,
|
|
403
499
|
detached: true,
|
|
404
500
|
stdio: ["ignore", "pipe", "pipe"],
|
|
405
501
|
windowsHide: true,
|
|
@@ -646,36 +742,47 @@ function statusUsage(): string {
|
|
|
646
742
|
}
|
|
647
743
|
|
|
648
744
|
export default function (pi: ExtensionAPI) {
|
|
745
|
+
const startWebuiHandler = async (args: string, ctx: ExtensionCommandContext) => {
|
|
746
|
+
let options: StartWebuiOptions;
|
|
747
|
+
try {
|
|
748
|
+
options = parseStartWebuiArgs(args);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const url = urlFor(options);
|
|
755
|
+
ctx.ui.setStatus("pi-webui", "starting webui…");
|
|
756
|
+
try {
|
|
757
|
+
const existing = await probeExistingWebui(url);
|
|
758
|
+
let restoreTabs: RestorableWebuiTab[] = [];
|
|
759
|
+
if (existing) {
|
|
760
|
+
ctx.ui.setStatus("pi-webui", "capturing existing webui tabs…");
|
|
761
|
+
restoreTabs = await fetchRestorableTabs(url, existing, options);
|
|
762
|
+
ctx.ui.setStatus("pi-webui", "restarting existing webui…");
|
|
763
|
+
await stopExistingWebui(url, options, existing);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const startedUrl = await startWebui(options, ctx, restoreTabs);
|
|
767
|
+
if (options.open) openDefaultBrowser(startedUrl);
|
|
768
|
+
const restoredTabsMessage = existing && restoreTabs.length > 0 ? `\nRestored ${restoreTabs.length} Web UI tab${restoreTabs.length === 1 ? "" : "s"}.` : "";
|
|
769
|
+
ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}${restoredTabsMessage}`, "info");
|
|
770
|
+
ctx.ui.setStatus("pi-webui", startedUrl);
|
|
771
|
+
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
772
|
+
} catch (error) {
|
|
773
|
+
ctx.ui.setStatus("pi-webui", "");
|
|
774
|
+
ctx.ui.notify(`Failed to start Pi Web UI:\n${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
649
778
|
pi.registerCommand("webui-start", {
|
|
650
779
|
description: "Start the local Pi browser Web UI and open it",
|
|
651
|
-
handler:
|
|
652
|
-
|
|
653
|
-
try {
|
|
654
|
-
options = parseStartWebuiArgs(args);
|
|
655
|
-
} catch (error) {
|
|
656
|
-
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
780
|
+
handler: startWebuiHandler,
|
|
781
|
+
});
|
|
659
782
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
const existing = await probeExistingWebui(url);
|
|
664
|
-
if (existing) {
|
|
665
|
-
ctx.ui.setStatus("pi-webui", "restarting existing webui…");
|
|
666
|
-
await stopExistingWebui(url, options, existing);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const startedUrl = await startWebui(options, ctx);
|
|
670
|
-
if (options.open) openDefaultBrowser(startedUrl);
|
|
671
|
-
ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}`, "info");
|
|
672
|
-
ctx.ui.setStatus("pi-webui", startedUrl);
|
|
673
|
-
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
674
|
-
} catch (error) {
|
|
675
|
-
ctx.ui.setStatus("pi-webui", "");
|
|
676
|
-
ctx.ui.notify(`Failed to start Pi Web UI:\n${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
|
|
677
|
-
}
|
|
678
|
-
},
|
|
783
|
+
pi.registerCommand("start-webui", {
|
|
784
|
+
description: "Alias for /webui-start",
|
|
785
|
+
handler: startWebuiHandler,
|
|
679
786
|
});
|
|
680
787
|
|
|
681
788
|
pi.registerCommand("webui-status", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"test": "node tests/mobile-static.test.mjs"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "^0.78.0"
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
30
|
+
"@firstpick/pi-themes-bundle": "^0.1.0"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"index.ts",
|