@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/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: process.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: async (args, ctx) => {
652
- let options: StartWebuiOptions;
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
- const url = urlFor(options);
661
- ctx.ui.setStatus("pi-webui", "starting webui");
662
- try {
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.2",
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",