@firstpick/pi-package-webui 0.1.1 → 0.1.2

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
@@ -12,19 +12,28 @@ const DEFAULT_HOST = "127.0.0.1";
12
12
  const DEFAULT_PORT = 31415;
13
13
  const START_TIMEOUT_MS = 12_000;
14
14
 
15
- type StartWebuiOptions = {
15
+ type WebuiAddress = {
16
16
  host: string;
17
17
  port: number;
18
+ };
19
+
20
+ type StartWebuiOptions = WebuiAddress & {
18
21
  open: boolean;
19
22
  noSession: boolean;
20
23
  name?: string;
21
24
  piArgs: string[];
22
25
  };
23
26
 
27
+ type WebuiStatusOptions = WebuiAddress & {
28
+ detailed: boolean;
29
+ };
30
+
24
31
  type ExistingWebui = {
25
32
  webuiVersion?: string;
26
33
  webuiPid?: number;
27
34
  piPid?: number;
35
+ network?: any;
36
+ tabs?: any[];
28
37
  };
29
38
 
30
39
  type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
@@ -129,7 +138,45 @@ function parseStartWebuiArgs(args: string): StartWebuiOptions {
129
138
  return options;
130
139
  }
131
140
 
132
- function urlFor(options: StartWebuiOptions): string {
141
+ function parseWebuiStatusArgs(args: string): WebuiStatusOptions {
142
+ const options: WebuiStatusOptions = {
143
+ host: DEFAULT_HOST,
144
+ port: DEFAULT_PORT,
145
+ detailed: false,
146
+ };
147
+ const tokens = tokenizeArgs(args || "");
148
+
149
+ for (let i = 0; i < tokens.length; i++) {
150
+ const token = tokens[i];
151
+ if (["detailed", "detail", "details", "--detailed"].includes(token.toLowerCase())) {
152
+ options.detailed = true;
153
+ continue;
154
+ }
155
+ if (token === "--host") {
156
+ options.host = takeValue(tokens, i, token);
157
+ i++;
158
+ continue;
159
+ }
160
+ if (token === "--port") {
161
+ const port = Number.parseInt(takeValue(tokens, i, token), 10);
162
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("--port must be between 1 and 65535");
163
+ options.port = port;
164
+ i++;
165
+ continue;
166
+ }
167
+ if (/^\d+$/.test(token)) {
168
+ const port = Number.parseInt(token, 10);
169
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("port must be between 1 and 65535");
170
+ options.port = port;
171
+ continue;
172
+ }
173
+ throw new Error(`Unknown option: ${token}`);
174
+ }
175
+
176
+ return options;
177
+ }
178
+
179
+ function urlFor(options: WebuiAddress): string {
133
180
  const host = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
134
181
  return `http://${host}:${options.port}/`;
135
182
  }
@@ -361,15 +408,245 @@ async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandConte
361
408
  return waitForWebuiUrl(child);
362
409
  }
363
410
 
411
+ type WebuiStatusFetchResult = {
412
+ online: boolean;
413
+ url: string;
414
+ endpointSupported: boolean;
415
+ data?: any;
416
+ error?: string;
417
+ };
418
+
419
+ async function fetchWebuiStatus(options: WebuiStatusOptions): Promise<WebuiStatusFetchResult> {
420
+ const url = urlFor(options);
421
+ const baseUrl = url.replace(/\/$/, "");
422
+ const query = options.detailed ? "?detailed=1&events=40" : "";
423
+ const statusResult = await fetchJsonWithTimeout(`${baseUrl}/api/webui-status${query}`, {}, options.detailed ? 7_000 : 1_500);
424
+ if (statusResult?.ok && statusResult.body?.ok === true) {
425
+ return { online: true, url, endpointSupported: true, data: statusResult.body.data };
426
+ }
427
+
428
+ const healthResult = await fetchJsonWithTimeout(`${baseUrl}/api/health`, {}, 1_500);
429
+ if (healthResult?.ok && healthResult.body?.ok === true) {
430
+ return {
431
+ online: true,
432
+ url,
433
+ endpointSupported: false,
434
+ data: {
435
+ ...healthResult.body,
436
+ online: true,
437
+ pageUrl: healthResult.body.network?.localUrl || url,
438
+ port: options.port,
439
+ },
440
+ error: statusResult?.body?.error,
441
+ };
442
+ }
443
+
444
+ return {
445
+ online: false,
446
+ url,
447
+ endpointSupported: false,
448
+ error: statusResult?.body?.error || healthResult?.body?.error || "No Pi Web UI responded at this URL",
449
+ };
450
+ }
451
+
452
+ function yesNo(value: unknown): string {
453
+ return value ? "yes" : "no";
454
+ }
455
+
456
+ function modelLabel(model: any): string {
457
+ if (!model) return "unknown";
458
+ return [model.provider, model.id].filter(Boolean).join("/") || "unknown";
459
+ }
460
+
461
+ function sessionLabel(state: any): string {
462
+ return state?.sessionName || state?.sessionId || "unknown";
463
+ }
464
+
465
+ function displayPath(value: unknown): string {
466
+ const text = String(value || "").trim();
467
+ if (!text) return "unknown";
468
+ const normalized = text.replace(/\\/g, "/");
469
+ const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
470
+ return home && normalized.toLowerCase().startsWith(home.toLowerCase()) ? `~${normalized.slice(home.length)}` || "~" : normalized;
471
+ }
472
+
473
+ function compactSessionFile(value: unknown): string {
474
+ const shown = displayPath(value);
475
+ if (shown === "unknown") return "in-memory/unknown";
476
+ const parts = shown.split("/");
477
+ if (parts.length <= 4) return shown;
478
+ return `${parts.slice(0, 3).join("/")}/…/${parts.at(-1)}`;
479
+ }
480
+
481
+ function formatStatusTime(value: unknown): string {
482
+ const date = new Date(String(value || ""));
483
+ if (Number.isNaN(date.getTime())) return String(value || "unknown");
484
+ return date.toLocaleString();
485
+ }
486
+
487
+ function formatEventTime(value: unknown): string {
488
+ const date = new Date(String(value || ""));
489
+ if (Number.isNaN(date.getTime())) return String(value || "time?");
490
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
491
+ }
492
+
493
+ function detailLine(label: string, value: unknown, indent = " "): string {
494
+ return `${indent}${label.padEnd(10)} ${String(value ?? "unknown")}`;
495
+ }
496
+
497
+ function formatStats(stats: any): string {
498
+ if (!stats || typeof stats !== "object") return "unavailable";
499
+ const parts = [];
500
+ if (stats.userMessages !== undefined) parts.push(`${stats.userMessages} user`);
501
+ if (stats.assistantMessages !== undefined) parts.push(`${stats.assistantMessages} assistant`);
502
+ if (stats.toolCalls !== undefined) parts.push(`${stats.toolCalls} tool calls`);
503
+ if (stats.toolResults !== undefined) parts.push(`${stats.toolResults} tool results`);
504
+ if (stats.totalTokens !== undefined) parts.push(`${stats.totalTokens} tokens`);
505
+ if (stats.costUsd !== undefined) parts.push(`$${stats.costUsd}`);
506
+ return parts.length ? parts.join(" · ") : "available";
507
+ }
508
+
509
+ function formatProviders(models: any): string {
510
+ const providers = Array.isArray(models?.providers) ? models.providers : [];
511
+ const providerText = providers.length ? providers.join(", ") : "unknown";
512
+ return models?.count ? `${models.count} models · ${providerText}` : providerText;
513
+ }
514
+
515
+ function eventDetails(event: any): string[] {
516
+ const details = [];
517
+ if (event.command) details.push(event.command);
518
+ if (event.updateType) details.push(`update ${event.updateType}`);
519
+ if (event.pid) details.push(`pid ${event.pid}`);
520
+ if (event.code !== undefined || event.signal !== undefined) details.push(`exit ${event.code ?? event.signal}`);
521
+ if (event.error) details.push(`error: ${event.error}`);
522
+ if (event.text) details.push(event.text);
523
+ return details;
524
+ }
525
+
526
+ function eventGroupKey(event: any): string {
527
+ return JSON.stringify([event.tabTitle || "webui", event.type || "event", ...eventDetails(event)]);
528
+ }
529
+
530
+ function formatEvent(event: any, count = 1): string {
531
+ const details = eventDetails(event);
532
+ const repeat = count > 1 ? ` ×${count}` : "";
533
+ return ` ${formatEventTime(event.timestamp)} ${event.tabTitle || "webui"} · ${event.type || "event"}${repeat}${details.length ? ` · ${details.join(" · ")}` : ""}`;
534
+ }
535
+
536
+ function formatEventGroups(events: any[]): string[] {
537
+ const groups: { event: any; count: number }[] = [];
538
+ for (const event of events) {
539
+ const previous = groups.at(-1);
540
+ if (previous && eventGroupKey(previous.event) === eventGroupKey(event)) {
541
+ previous.count += 1;
542
+ } else {
543
+ groups.push({ event, count: 1 });
544
+ }
545
+ }
546
+ return groups.map((group) => formatEvent(group.event, group.count));
547
+ }
548
+
549
+ function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: boolean): string {
550
+ if (!result.online) {
551
+ return [
552
+ "Pi Web UI status",
553
+ detailLine("URL", result.url),
554
+ detailLine("Online", "no"),
555
+ detailLine("Network", "unknown"),
556
+ detailLine("Error", result.error || "offline"),
557
+ "",
558
+ "Start it with: /webui-start",
559
+ ].join("\n");
560
+ }
561
+
562
+ const data = result.data || {};
563
+ const network = data.network || {};
564
+ const tabs = Array.isArray(data.tabs) ? data.tabs : [];
565
+ const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
566
+ const pageUrl = data.pageUrl || network.localUrl || result.url;
567
+ const networkLabel = network.open ? `open to LAN${network.opening ? " (opening)" : ""}` : network.opening ? "opening" : "local only";
568
+
569
+ if (!requestedDetailed) {
570
+ const lines = [
571
+ "Pi Web UI status",
572
+ "",
573
+ detailLine("URL", pageUrl),
574
+ detailLine("Online", "yes"),
575
+ detailLine("Network", networkLabel),
576
+ detailLine("Tabs", tabs.length || "?"),
577
+ ];
578
+ if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
579
+ if (data.webuiPid) lines.push(detailLine("Web UI PID", data.webuiPid));
580
+ return lines.join("\n");
581
+ }
582
+
583
+ const lines = [
584
+ "Pi Web UI — detailed status",
585
+ "",
586
+ "Summary",
587
+ detailLine("URL", pageUrl),
588
+ detailLine("Online", "yes"),
589
+ detailLine("Network", networkLabel),
590
+ detailLine("Bind", `${data.boundHost || network.host || "unknown"}:${data.port || network.port || "?"}`),
591
+ detailLine("Version", data.webuiVersion || "unknown"),
592
+ detailLine("PIDs", `webui ${data.webuiPid || "unknown"} · pi ${data.piPid || "unknown"}`),
593
+ detailLine("Started", formatStatusTime(data.startedAt)),
594
+ detailLine("Root cwd", displayPath(data.cwd)),
595
+ ];
596
+
597
+ if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
598
+
599
+ if (!result.endpointSupported) {
600
+ lines.push("", "Detailed endpoint unavailable on the running server. Restart it with /webui-start to enable full details.");
601
+ return lines.join("\n");
602
+ }
603
+
604
+ lines.push("", `Tabs (${tabs.length})`);
605
+ if (!tabs.length) lines.push(" none");
606
+ for (const [index, tab] of tabs.entries()) {
607
+ const state = tab.state || {};
608
+ const status = tab.running ? "● running" : "○ stopped";
609
+ const activity = state.isStreaming ? "streaming" : state.isCompacting ? "compacting" : "idle";
610
+ lines.push(
611
+ "",
612
+ ` ${index + 1}. ${tab.title || tab.id || "tab"} ${status}`,
613
+ detailLine("Process", `pid ${tab.pid || "unknown"} · clients ${tab.clientCount ?? 0} · started ${formatStatusTime(tab.startedAt)}`, " "),
614
+ detailLine("Workspace", displayPath(tab.workspace?.cwd || tab.cwd), " "),
615
+ detailLine("Session", sessionLabel(state), " "),
616
+ detailLine("File", compactSessionFile(state.sessionFile), " "),
617
+ detailLine("Model", `${modelLabel(state.model)} · thinking ${state.thinkingLevel || "unknown"}`, " "),
618
+ detailLine("Activity", `${activity} · messages ${state.messageCount ?? "?"} · queue ${state.pendingMessageCount ?? 0}`, " "),
619
+ detailLine("Providers", formatProviders(tab.models), " "),
620
+ detailLine("Stats", formatStats(tab.stats), " "),
621
+ );
622
+ if (tab.stateError) lines.push(detailLine("State err", tab.stateError, " "));
623
+ if (tab.models?.error) lines.push(detailLine("Model err", tab.models.error, " "));
624
+ if (tab.statsError) lines.push(detailLine("Stats err", tab.statsError, " "));
625
+ if (tab.workspaceError) lines.push(detailLine("Work err", tab.workspaceError, " "));
626
+ }
627
+
628
+ const events = Array.isArray(data.events) ? data.events.slice(-20) : [];
629
+ lines.push("", `Recent events (latest ${events.length}; repeated adjacent events are grouped)`);
630
+ lines.push(...(events.length ? formatEventGroups(events) : [" none"]));
631
+ return lines.join("\n");
632
+ }
633
+
364
634
  function usage(): string {
365
635
  return [
366
- "Usage: /start-webui [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
636
+ "Usage: /webui-start [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
367
637
  "Starts the Pi Web UI companion server for the current cwd, prints the localhost URL, and opens it in your default browser.",
368
638
  ].join("\n");
369
639
  }
370
640
 
641
+ function statusUsage(): string {
642
+ return [
643
+ "Usage: /webui-status [detailed] [port] [--port N] [--host HOST]",
644
+ "Shows the Pi Web UI URL, online state, and local-network exposure. Add 'detailed' for tabs, sessions, models/providers, and recent events.",
645
+ ].join("\n");
646
+ }
647
+
371
648
  export default function (pi: ExtensionAPI) {
372
- pi.registerCommand("start-webui", {
649
+ pi.registerCommand("webui-start", {
373
650
  description: "Start the local Pi browser Web UI and open it",
374
651
  handler: async (args, ctx) => {
375
652
  let options: StartWebuiOptions;
@@ -400,4 +677,27 @@ export default function (pi: ExtensionAPI) {
400
677
  }
401
678
  },
402
679
  });
680
+
681
+ pi.registerCommand("webui-status", {
682
+ description: "Show Pi Web UI URL, online state, network exposure, and optional detailed runtime info",
683
+ handler: async (args, ctx) => {
684
+ let options: WebuiStatusOptions;
685
+ try {
686
+ options = parseWebuiStatusArgs(args);
687
+ } catch (error) {
688
+ ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
689
+ return;
690
+ }
691
+
692
+ ctx.ui.setStatus("pi-webui", "checking webui status…");
693
+ try {
694
+ const result = await fetchWebuiStatus(options);
695
+ ctx.ui.notify(formatWebuiStatus(result, options.detailed), result.online ? "info" : "warning");
696
+ } catch (error) {
697
+ ctx.ui.notify(`Failed to check Pi Web UI status:\n${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
698
+ } finally {
699
+ ctx.ui.setStatus("pi-webui", "");
700
+ }
701
+ },
702
+ });
403
703
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.1",
4
- "description": "Pi Web UI companion package with a local browser UI CLI and /start-webui command.",
3
+ "version": "0.1.2",
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",
7
7
  "keywords": [
@@ -21,6 +21,10 @@
21
21
  "bin": {
22
22
  "pi-webui": "./bin/pi-webui.mjs"
23
23
  },
24
+ "scripts": {
25
+ "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs",
26
+ "test": "node tests/mobile-static.test.mjs"
27
+ },
24
28
  "dependencies": {
25
29
  "@earendil-works/pi-coding-agent": "^0.78.0"
26
30
  },
@@ -28,6 +32,7 @@
28
32
  "index.ts",
29
33
  "bin",
30
34
  "public",
35
+ "tests",
31
36
  "README.md",
32
37
  "LICENSE"
33
38
  ],