@firstpick/pi-package-webui 0.1.1 → 0.1.3
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 +44 -17
- package/bin/pi-webui.mjs +1483 -35
- package/index.ts +430 -23
- package/package.json +9 -3
- package/public/app.js +3067 -176
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +60 -24
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +58 -0
- package/public/styles.css +1348 -125
- package/tests/mobile-static.test.mjs +370 -0
package/index.ts
CHANGED
|
@@ -12,19 +12,39 @@ 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
|
|
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[];
|
|
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;
|
|
28
48
|
};
|
|
29
49
|
|
|
30
50
|
type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
@@ -129,7 +149,45 @@ function parseStartWebuiArgs(args: string): StartWebuiOptions {
|
|
|
129
149
|
return options;
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
function
|
|
152
|
+
function parseWebuiStatusArgs(args: string): WebuiStatusOptions {
|
|
153
|
+
const options: WebuiStatusOptions = {
|
|
154
|
+
host: DEFAULT_HOST,
|
|
155
|
+
port: DEFAULT_PORT,
|
|
156
|
+
detailed: false,
|
|
157
|
+
};
|
|
158
|
+
const tokens = tokenizeArgs(args || "");
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
161
|
+
const token = tokens[i];
|
|
162
|
+
if (["detailed", "detail", "details", "--detailed"].includes(token.toLowerCase())) {
|
|
163
|
+
options.detailed = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (token === "--host") {
|
|
167
|
+
options.host = takeValue(tokens, i, token);
|
|
168
|
+
i++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (token === "--port") {
|
|
172
|
+
const port = Number.parseInt(takeValue(tokens, i, token), 10);
|
|
173
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("--port must be between 1 and 65535");
|
|
174
|
+
options.port = port;
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (/^\d+$/.test(token)) {
|
|
179
|
+
const port = Number.parseInt(token, 10);
|
|
180
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("port must be between 1 and 65535");
|
|
181
|
+
options.port = port;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Unknown option: ${token}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return options;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function urlFor(options: WebuiAddress): string {
|
|
133
191
|
const host = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
|
|
134
192
|
return `http://${host}:${options.port}/`;
|
|
135
193
|
}
|
|
@@ -155,6 +213,88 @@ async function probeExistingWebui(url: string): Promise<ExistingWebui | null> {
|
|
|
155
213
|
return body;
|
|
156
214
|
}
|
|
157
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
|
+
|
|
158
298
|
function sleep(ms: number): Promise<void> {
|
|
159
299
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
160
300
|
}
|
|
@@ -344,15 +484,18 @@ function waitForWebuiUrl(child: WebuiChild): Promise<string> {
|
|
|
344
484
|
});
|
|
345
485
|
}
|
|
346
486
|
|
|
347
|
-
async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext): Promise<string> {
|
|
487
|
+
async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext, restoreTabs: RestorableWebuiTab[] = []): Promise<string> {
|
|
348
488
|
const args = [webuiBin, "--host", options.host, "--port", String(options.port), "--cwd", ctx.cwd];
|
|
349
489
|
if (options.noSession) args.push("--no-session");
|
|
350
490
|
if (options.name) args.push("--name", options.name);
|
|
351
491
|
if (options.piArgs.length > 0) args.push("--", ...options.piArgs);
|
|
352
492
|
|
|
493
|
+
const env = { ...process.env };
|
|
494
|
+
if (restoreTabs.length > 0) env.PI_WEBUI_RESTORE_TABS = JSON.stringify(restoreTabs);
|
|
495
|
+
|
|
353
496
|
const child = spawn(process.execPath, args, {
|
|
354
497
|
cwd: ctx.cwd,
|
|
355
|
-
env
|
|
498
|
+
env,
|
|
356
499
|
detached: true,
|
|
357
500
|
stdio: ["ignore", "pipe", "pipe"],
|
|
358
501
|
windowsHide: true,
|
|
@@ -361,42 +504,306 @@ async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandConte
|
|
|
361
504
|
return waitForWebuiUrl(child);
|
|
362
505
|
}
|
|
363
506
|
|
|
507
|
+
type WebuiStatusFetchResult = {
|
|
508
|
+
online: boolean;
|
|
509
|
+
url: string;
|
|
510
|
+
endpointSupported: boolean;
|
|
511
|
+
data?: any;
|
|
512
|
+
error?: string;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
async function fetchWebuiStatus(options: WebuiStatusOptions): Promise<WebuiStatusFetchResult> {
|
|
516
|
+
const url = urlFor(options);
|
|
517
|
+
const baseUrl = url.replace(/\/$/, "");
|
|
518
|
+
const query = options.detailed ? "?detailed=1&events=40" : "";
|
|
519
|
+
const statusResult = await fetchJsonWithTimeout(`${baseUrl}/api/webui-status${query}`, {}, options.detailed ? 7_000 : 1_500);
|
|
520
|
+
if (statusResult?.ok && statusResult.body?.ok === true) {
|
|
521
|
+
return { online: true, url, endpointSupported: true, data: statusResult.body.data };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const healthResult = await fetchJsonWithTimeout(`${baseUrl}/api/health`, {}, 1_500);
|
|
525
|
+
if (healthResult?.ok && healthResult.body?.ok === true) {
|
|
526
|
+
return {
|
|
527
|
+
online: true,
|
|
528
|
+
url,
|
|
529
|
+
endpointSupported: false,
|
|
530
|
+
data: {
|
|
531
|
+
...healthResult.body,
|
|
532
|
+
online: true,
|
|
533
|
+
pageUrl: healthResult.body.network?.localUrl || url,
|
|
534
|
+
port: options.port,
|
|
535
|
+
},
|
|
536
|
+
error: statusResult?.body?.error,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
online: false,
|
|
542
|
+
url,
|
|
543
|
+
endpointSupported: false,
|
|
544
|
+
error: statusResult?.body?.error || healthResult?.body?.error || "No Pi Web UI responded at this URL",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function yesNo(value: unknown): string {
|
|
549
|
+
return value ? "yes" : "no";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function modelLabel(model: any): string {
|
|
553
|
+
if (!model) return "unknown";
|
|
554
|
+
return [model.provider, model.id].filter(Boolean).join("/") || "unknown";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function sessionLabel(state: any): string {
|
|
558
|
+
return state?.sessionName || state?.sessionId || "unknown";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function displayPath(value: unknown): string {
|
|
562
|
+
const text = String(value || "").trim();
|
|
563
|
+
if (!text) return "unknown";
|
|
564
|
+
const normalized = text.replace(/\\/g, "/");
|
|
565
|
+
const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
|
|
566
|
+
return home && normalized.toLowerCase().startsWith(home.toLowerCase()) ? `~${normalized.slice(home.length)}` || "~" : normalized;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function compactSessionFile(value: unknown): string {
|
|
570
|
+
const shown = displayPath(value);
|
|
571
|
+
if (shown === "unknown") return "in-memory/unknown";
|
|
572
|
+
const parts = shown.split("/");
|
|
573
|
+
if (parts.length <= 4) return shown;
|
|
574
|
+
return `${parts.slice(0, 3).join("/")}/…/${parts.at(-1)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function formatStatusTime(value: unknown): string {
|
|
578
|
+
const date = new Date(String(value || ""));
|
|
579
|
+
if (Number.isNaN(date.getTime())) return String(value || "unknown");
|
|
580
|
+
return date.toLocaleString();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function formatEventTime(value: unknown): string {
|
|
584
|
+
const date = new Date(String(value || ""));
|
|
585
|
+
if (Number.isNaN(date.getTime())) return String(value || "time?");
|
|
586
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function detailLine(label: string, value: unknown, indent = " "): string {
|
|
590
|
+
return `${indent}${label.padEnd(10)} ${String(value ?? "unknown")}`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function formatStats(stats: any): string {
|
|
594
|
+
if (!stats || typeof stats !== "object") return "unavailable";
|
|
595
|
+
const parts = [];
|
|
596
|
+
if (stats.userMessages !== undefined) parts.push(`${stats.userMessages} user`);
|
|
597
|
+
if (stats.assistantMessages !== undefined) parts.push(`${stats.assistantMessages} assistant`);
|
|
598
|
+
if (stats.toolCalls !== undefined) parts.push(`${stats.toolCalls} tool calls`);
|
|
599
|
+
if (stats.toolResults !== undefined) parts.push(`${stats.toolResults} tool results`);
|
|
600
|
+
if (stats.totalTokens !== undefined) parts.push(`${stats.totalTokens} tokens`);
|
|
601
|
+
if (stats.costUsd !== undefined) parts.push(`$${stats.costUsd}`);
|
|
602
|
+
return parts.length ? parts.join(" · ") : "available";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function formatProviders(models: any): string {
|
|
606
|
+
const providers = Array.isArray(models?.providers) ? models.providers : [];
|
|
607
|
+
const providerText = providers.length ? providers.join(", ") : "unknown";
|
|
608
|
+
return models?.count ? `${models.count} models · ${providerText}` : providerText;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function eventDetails(event: any): string[] {
|
|
612
|
+
const details = [];
|
|
613
|
+
if (event.command) details.push(event.command);
|
|
614
|
+
if (event.updateType) details.push(`update ${event.updateType}`);
|
|
615
|
+
if (event.pid) details.push(`pid ${event.pid}`);
|
|
616
|
+
if (event.code !== undefined || event.signal !== undefined) details.push(`exit ${event.code ?? event.signal}`);
|
|
617
|
+
if (event.error) details.push(`error: ${event.error}`);
|
|
618
|
+
if (event.text) details.push(event.text);
|
|
619
|
+
return details;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function eventGroupKey(event: any): string {
|
|
623
|
+
return JSON.stringify([event.tabTitle || "webui", event.type || "event", ...eventDetails(event)]);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function formatEvent(event: any, count = 1): string {
|
|
627
|
+
const details = eventDetails(event);
|
|
628
|
+
const repeat = count > 1 ? ` ×${count}` : "";
|
|
629
|
+
return ` ${formatEventTime(event.timestamp)} ${event.tabTitle || "webui"} · ${event.type || "event"}${repeat}${details.length ? ` · ${details.join(" · ")}` : ""}`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function formatEventGroups(events: any[]): string[] {
|
|
633
|
+
const groups: { event: any; count: number }[] = [];
|
|
634
|
+
for (const event of events) {
|
|
635
|
+
const previous = groups.at(-1);
|
|
636
|
+
if (previous && eventGroupKey(previous.event) === eventGroupKey(event)) {
|
|
637
|
+
previous.count += 1;
|
|
638
|
+
} else {
|
|
639
|
+
groups.push({ event, count: 1 });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return groups.map((group) => formatEvent(group.event, group.count));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: boolean): string {
|
|
646
|
+
if (!result.online) {
|
|
647
|
+
return [
|
|
648
|
+
"Pi Web UI status",
|
|
649
|
+
detailLine("URL", result.url),
|
|
650
|
+
detailLine("Online", "no"),
|
|
651
|
+
detailLine("Network", "unknown"),
|
|
652
|
+
detailLine("Error", result.error || "offline"),
|
|
653
|
+
"",
|
|
654
|
+
"Start it with: /webui-start",
|
|
655
|
+
].join("\n");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const data = result.data || {};
|
|
659
|
+
const network = data.network || {};
|
|
660
|
+
const tabs = Array.isArray(data.tabs) ? data.tabs : [];
|
|
661
|
+
const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
|
|
662
|
+
const pageUrl = data.pageUrl || network.localUrl || result.url;
|
|
663
|
+
const networkLabel = network.open ? `open to LAN${network.opening ? " (opening)" : ""}` : network.opening ? "opening" : "local only";
|
|
664
|
+
|
|
665
|
+
if (!requestedDetailed) {
|
|
666
|
+
const lines = [
|
|
667
|
+
"Pi Web UI status",
|
|
668
|
+
"",
|
|
669
|
+
detailLine("URL", pageUrl),
|
|
670
|
+
detailLine("Online", "yes"),
|
|
671
|
+
detailLine("Network", networkLabel),
|
|
672
|
+
detailLine("Tabs", tabs.length || "?"),
|
|
673
|
+
];
|
|
674
|
+
if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
|
|
675
|
+
if (data.webuiPid) lines.push(detailLine("Web UI PID", data.webuiPid));
|
|
676
|
+
return lines.join("\n");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const lines = [
|
|
680
|
+
"Pi Web UI — detailed status",
|
|
681
|
+
"",
|
|
682
|
+
"Summary",
|
|
683
|
+
detailLine("URL", pageUrl),
|
|
684
|
+
detailLine("Online", "yes"),
|
|
685
|
+
detailLine("Network", networkLabel),
|
|
686
|
+
detailLine("Bind", `${data.boundHost || network.host || "unknown"}:${data.port || network.port || "?"}`),
|
|
687
|
+
detailLine("Version", data.webuiVersion || "unknown"),
|
|
688
|
+
detailLine("PIDs", `webui ${data.webuiPid || "unknown"} · pi ${data.piPid || "unknown"}`),
|
|
689
|
+
detailLine("Started", formatStatusTime(data.startedAt)),
|
|
690
|
+
detailLine("Root cwd", displayPath(data.cwd)),
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
|
|
694
|
+
|
|
695
|
+
if (!result.endpointSupported) {
|
|
696
|
+
lines.push("", "Detailed endpoint unavailable on the running server. Restart it with /webui-start to enable full details.");
|
|
697
|
+
return lines.join("\n");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
lines.push("", `Tabs (${tabs.length})`);
|
|
701
|
+
if (!tabs.length) lines.push(" none");
|
|
702
|
+
for (const [index, tab] of tabs.entries()) {
|
|
703
|
+
const state = tab.state || {};
|
|
704
|
+
const status = tab.running ? "● running" : "○ stopped";
|
|
705
|
+
const activity = state.isStreaming ? "streaming" : state.isCompacting ? "compacting" : "idle";
|
|
706
|
+
lines.push(
|
|
707
|
+
"",
|
|
708
|
+
` ${index + 1}. ${tab.title || tab.id || "tab"} ${status}`,
|
|
709
|
+
detailLine("Process", `pid ${tab.pid || "unknown"} · clients ${tab.clientCount ?? 0} · started ${formatStatusTime(tab.startedAt)}`, " "),
|
|
710
|
+
detailLine("Workspace", displayPath(tab.workspace?.cwd || tab.cwd), " "),
|
|
711
|
+
detailLine("Session", sessionLabel(state), " "),
|
|
712
|
+
detailLine("File", compactSessionFile(state.sessionFile), " "),
|
|
713
|
+
detailLine("Model", `${modelLabel(state.model)} · thinking ${state.thinkingLevel || "unknown"}`, " "),
|
|
714
|
+
detailLine("Activity", `${activity} · messages ${state.messageCount ?? "?"} · queue ${state.pendingMessageCount ?? 0}`, " "),
|
|
715
|
+
detailLine("Providers", formatProviders(tab.models), " "),
|
|
716
|
+
detailLine("Stats", formatStats(tab.stats), " "),
|
|
717
|
+
);
|
|
718
|
+
if (tab.stateError) lines.push(detailLine("State err", tab.stateError, " "));
|
|
719
|
+
if (tab.models?.error) lines.push(detailLine("Model err", tab.models.error, " "));
|
|
720
|
+
if (tab.statsError) lines.push(detailLine("Stats err", tab.statsError, " "));
|
|
721
|
+
if (tab.workspaceError) lines.push(detailLine("Work err", tab.workspaceError, " "));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const events = Array.isArray(data.events) ? data.events.slice(-20) : [];
|
|
725
|
+
lines.push("", `Recent events (latest ${events.length}; repeated adjacent events are grouped)`);
|
|
726
|
+
lines.push(...(events.length ? formatEventGroups(events) : [" none"]));
|
|
727
|
+
return lines.join("\n");
|
|
728
|
+
}
|
|
729
|
+
|
|
364
730
|
function usage(): string {
|
|
365
731
|
return [
|
|
366
|
-
"Usage: /start
|
|
732
|
+
"Usage: /webui-start [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
|
|
367
733
|
"Starts the Pi Web UI companion server for the current cwd, prints the localhost URL, and opens it in your default browser.",
|
|
368
734
|
].join("\n");
|
|
369
735
|
}
|
|
370
736
|
|
|
737
|
+
function statusUsage(): string {
|
|
738
|
+
return [
|
|
739
|
+
"Usage: /webui-status [detailed] [port] [--port N] [--host HOST]",
|
|
740
|
+
"Shows the Pi Web UI URL, online state, and local-network exposure. Add 'detailed' for tabs, sessions, models/providers, and recent events.",
|
|
741
|
+
].join("\n");
|
|
742
|
+
}
|
|
743
|
+
|
|
371
744
|
export default function (pi: ExtensionAPI) {
|
|
372
|
-
|
|
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
|
+
|
|
778
|
+
pi.registerCommand("webui-start", {
|
|
373
779
|
description: "Start the local Pi browser Web UI and open it",
|
|
780
|
+
handler: startWebuiHandler,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
pi.registerCommand("start-webui", {
|
|
784
|
+
description: "Alias for /webui-start",
|
|
785
|
+
handler: startWebuiHandler,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
pi.registerCommand("webui-status", {
|
|
789
|
+
description: "Show Pi Web UI URL, online state, network exposure, and optional detailed runtime info",
|
|
374
790
|
handler: async (args, ctx) => {
|
|
375
|
-
let options:
|
|
791
|
+
let options: WebuiStatusOptions;
|
|
376
792
|
try {
|
|
377
|
-
options =
|
|
793
|
+
options = parseWebuiStatusArgs(args);
|
|
378
794
|
} catch (error) {
|
|
379
|
-
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${
|
|
795
|
+
ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
|
|
380
796
|
return;
|
|
381
797
|
}
|
|
382
798
|
|
|
383
|
-
|
|
384
|
-
ctx.ui.setStatus("pi-webui", "starting webui…");
|
|
799
|
+
ctx.ui.setStatus("pi-webui", "checking webui status…");
|
|
385
800
|
try {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
ctx.ui.setStatus("pi-webui", "restarting existing webui…");
|
|
389
|
-
await stopExistingWebui(url, options, existing);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const startedUrl = await startWebui(options, ctx);
|
|
393
|
-
if (options.open) openDefaultBrowser(startedUrl);
|
|
394
|
-
ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}`, "info");
|
|
395
|
-
ctx.ui.setStatus("pi-webui", startedUrl);
|
|
396
|
-
setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
|
|
801
|
+
const result = await fetchWebuiStatus(options);
|
|
802
|
+
ctx.ui.notify(formatWebuiStatus(result, options.detailed), result.online ? "info" : "warning");
|
|
397
803
|
} catch (error) {
|
|
804
|
+
ctx.ui.notify(`Failed to check Pi Web UI status:\n${error instanceof Error ? error.message : String(error)}\n${statusUsage()}`, "error");
|
|
805
|
+
} finally {
|
|
398
806
|
ctx.ui.setStatus("pi-webui", "");
|
|
399
|
-
ctx.ui.notify(`Failed to start Pi Web UI:\n${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
|
|
400
807
|
}
|
|
401
808
|
},
|
|
402
809
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Pi Web UI companion package with a local browser UI CLI and /
|
|
3
|
+
"version": "0.1.3",
|
|
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,13 +21,19 @@
|
|
|
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
|
-
"@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"
|
|
26
31
|
},
|
|
27
32
|
"files": [
|
|
28
33
|
"index.ts",
|
|
29
34
|
"bin",
|
|
30
35
|
"public",
|
|
36
|
+
"tests",
|
|
31
37
|
"README.md",
|
|
32
38
|
"LICENSE"
|
|
33
39
|
],
|