@firstpick/pi-package-webui 0.1.7 → 0.1.9

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Local browser companion for [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
4
4
 
5
+ ![Pi Web UI main window showing multi-tab chat, controls, theme picker, and local status](https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png)
6
+
5
7
  This package provides:
6
8
 
7
9
  - `pi-webui`: a local HTTP/SSE server that starts `pi --mode rpc`, serves the static browser UI, and proxies browser actions to Pi RPC commands.
@@ -64,30 +66,18 @@ pi-webui --cwd /path/to/project
64
66
 
65
67
  ## Features
66
68
 
67
- - Browser chat with Pi over RPC
68
- - Isolated terminal tabs: each Web UI tab starts its own separate `pi --mode rpc` subprocess, event stream, session state, and prompt draft
69
- - Automatic tab naming from the first prompt on default-named tabs, plus `/name <title>` to manually sync the Pi session and browser tab name
70
- - Per-tab activity indicators for idle, working, blocked, and completed unseen work, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications
71
- - Live assistant text streaming, including streamed thinking blocks when exposed by the provider
72
- - Prompt, steer, follow-up, abort, new session, and manual compact controls
73
- - Attachment button plus drag/drop and clipboard paste for images, documents, video, audio, and other files; uploaded files are saved to a server temp path and supported images are also sent through Pi RPC image attachments
74
- - Busy-session behavior selector for follow-up vs steer
75
- - Model and thinking-level controls
76
- - Browser-native selector dialogs for native slash commands such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, and `/tree`; `/login`/`/logout` currently show non-secret guidance rather than accepting credentials in the browser
77
- - Slash-command autocomplete while typing `/...`
78
- - `@` file/path references with live suggestions from the active tab cwd
79
- - Tool, process, compaction, queue, and extension event log
80
- - Collapsible side panel with independently collapsible sections for controls, optional features, session state, queue, available commands, events, local-network exposure status/control, and a theme picker
81
- - Pi-style footer with token, cache, estimated Pi-context tokens, speed, cost, context usage, clickable per-tab cwd picker with server-persisted fast picks, git branch, changes, runtime, model, and thinking level
82
- - Guided Git workflow: `git add .`, ask Pi to run `/git-staged-msg`, preview short/long messages, commit with the selected message, and `git push`
83
- - Hover-expand Publish workflow menu beside Git workflow, currently offering NPM Release and AUR Release
84
- - Basic rendering for user, assistant, tool result, bash execution, and thinking messages
85
- - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING
86
- - Basic extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`
87
- - Specialized `/release-npm` and `/release-aur` widget rendering with scrollable live logs plus toggle/abort actions
88
- - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded
89
- - PWA metadata, icons, and service worker for install-to-home-screen support when served from a secure context
90
- - Static frontend: no bundler, no frontend install step
69
+ - Local browser chat over Pi RPC with isolated terminal tabs; each tab has its own `pi --mode rpc` subprocess, event stream, session state, prompt draft, cwd, and activity indicator.
70
+ - Automatic tab naming from the first prompt on default-named tabs, plus `/name <title>` to manually sync the Pi session and browser tab name.
71
+ - Live transcript with streamed assistant text/thinking, Markdown output, active-run status, tool/bash cards, queue/compaction events, jump-to-latest, sticky last-prompt navigation, and abort by button, Esc, or long press.
72
+ - Prompt composer for prompts, steer/follow-up, busy-session behavior, model/thinking controls, manual compact/new session, uploads by button/drag/drop/paste, slash-command autocomplete, and `@` file/path references with live suggestions.
73
+ - Browser-native selector dialogs for `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, and `/scoped-models`; `/login`/`/logout` show non-secret guidance instead of accepting credentials in the browser.
74
+ - Session/workspace helpers for per-tab cwd changes, a clickable footer cwd picker with server-persisted fast picks, fork/clone/resume/tree navigation, and restart-safe restoration of currently open tabs.
75
+ - Collapsible side-panel control deck for model/thinking/settings, optional features, Codex usage, session/queue/commands/events, local-network exposure, browser notifications, and Web UI themes/custom backgrounds.
76
+ - Pi-style footer with token/cache/context/cost/speed telemetry, estimated Pi-context tokens, cwd/git/runtime/model/thinking metadata, and a scoped-model picker.
77
+ - Optional companion management with capability-based enabled/disabled/install-needed status, localhost-only warned installs, Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded, guided Git commit/push workflow, NPM/AUR Publish menu, todo-progress rendering, and richer git/status/stats widgets.
78
+ - Extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
79
+ - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING.
80
+ - Mobile/PWA support: installable app shell, service worker/icons, backend-offline recovery panel, touch-friendly composer/tabs/footer, and a static frontend with no bundler or frontend install step.
91
81
 
92
82
  ## Mobile/PWA notes
93
83
 
package/bin/pi-webui.mjs CHANGED
@@ -8,7 +8,7 @@ import { homedir, networkInterfaces, tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { fileURLToPath } from "node:url";
11
- import { SessionManager } from "@earendil-works/pi-coding-agent";
11
+ import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const require = createRequire(import.meta.url);
@@ -19,6 +19,10 @@ const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.js
19
19
  const DEFAULT_HOST = "127.0.0.1";
20
20
  const DEFAULT_PORT = 31415;
21
21
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
23
+ const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
24
+ const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
25
+ const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
22
26
  const BODY_LIMIT_BYTES = 1024 * 1024;
23
27
  const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
24
28
  const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
@@ -839,6 +843,302 @@ async function readJsonFileIfExists(filePath) {
839
843
  }
840
844
  }
841
845
 
846
+ function firstDefined(...values) {
847
+ return values.find((value) => value !== undefined && value !== null);
848
+ }
849
+
850
+ function numericValue(value) {
851
+ if (value === undefined || value === null || value === "") return undefined;
852
+ const number = Number(value);
853
+ return Number.isFinite(number) ? number : undefined;
854
+ }
855
+
856
+ function booleanValue(value) {
857
+ return typeof value === "boolean" ? value : undefined;
858
+ }
859
+
860
+ function isoTimestamp(value) {
861
+ const number = numericValue(value);
862
+ if (number !== undefined) {
863
+ const milliseconds = number > 1e12 ? number : number * 1000;
864
+ const date = new Date(milliseconds);
865
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
866
+ }
867
+ if (typeof value === "string" && value.trim()) {
868
+ const date = new Date(value);
869
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
870
+ }
871
+ return undefined;
872
+ }
873
+
874
+ function decodeJwtPayload(token) {
875
+ try {
876
+ const payload = String(token || "").split(".")[1];
877
+ if (!payload) return null;
878
+ const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
879
+ return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
880
+ } catch {
881
+ return null;
882
+ }
883
+ }
884
+
885
+ function codexAccountIdFromAccessToken(accessToken) {
886
+ const payload = decodeJwtPayload(accessToken);
887
+ const auth = payload?.["https://api.openai.com/auth"];
888
+ const accountId = auth?.chatgpt_account_id;
889
+ return typeof accountId === "string" && accountId ? accountId : null;
890
+ }
891
+
892
+ function normalizeCodexRateLimitWindow(rawWindow) {
893
+ if (!rawWindow || typeof rawWindow !== "object") return null;
894
+ const windowDurationSeconds = firstDefined(
895
+ numericValue(rawWindow.windowDurationSeconds),
896
+ numericValue(rawWindow.limitWindowSeconds),
897
+ numericValue(rawWindow.limit_window_seconds),
898
+ numericValue(rawWindow.windowDurationMins) !== undefined ? numericValue(rawWindow.windowDurationMins) * 60 : undefined,
899
+ );
900
+ const windowDurationMins = firstDefined(
901
+ numericValue(rawWindow.windowDurationMins),
902
+ windowDurationSeconds !== undefined ? windowDurationSeconds / 60 : undefined,
903
+ );
904
+ const normalized = {
905
+ usedPercent: numericValue(firstDefined(rawWindow.usedPercent, rawWindow.used_percent)),
906
+ windowDurationSeconds,
907
+ windowDurationMins,
908
+ resetAfterSeconds: numericValue(firstDefined(rawWindow.resetAfterSeconds, rawWindow.reset_after_seconds)),
909
+ resetsAt: isoTimestamp(firstDefined(rawWindow.resetsAt, rawWindow.resetAt, rawWindow.reset_at)),
910
+ };
911
+ return Object.values(normalized).some((value) => value !== undefined) ? normalized : null;
912
+ }
913
+
914
+ function normalizeCodexCredits(rawCredits) {
915
+ if (!rawCredits || typeof rawCredits !== "object") return null;
916
+ return {
917
+ hasCredits: booleanValue(firstDefined(rawCredits.hasCredits, rawCredits.has_credits)),
918
+ unlimited: booleanValue(rawCredits.unlimited),
919
+ balance: firstDefined(rawCredits.balance),
920
+ approxLocalMessages: firstDefined(rawCredits.approxLocalMessages, rawCredits.approx_local_messages),
921
+ approxCloudMessages: firstDefined(rawCredits.approxCloudMessages, rawCredits.approx_cloud_messages),
922
+ };
923
+ }
924
+
925
+ function normalizeCodexRateLimitDetails(rawDetails) {
926
+ if (!rawDetails || typeof rawDetails !== "object") return { primary: null, secondary: null };
927
+ return {
928
+ allowed: booleanValue(rawDetails.allowed),
929
+ limitReached: booleanValue(firstDefined(rawDetails.limitReached, rawDetails.limit_reached)),
930
+ primary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.primary, rawDetails.primaryWindow, rawDetails.primary_window)),
931
+ secondary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.secondary, rawDetails.secondaryWindow, rawDetails.secondary_window)),
932
+ };
933
+ }
934
+
935
+ function normalizeCodexRateLimitReachedType(rawType) {
936
+ if (typeof rawType === "string" && rawType) return rawType;
937
+ if (rawType && typeof rawType === "object") {
938
+ const value = firstDefined(rawType.type, rawType.kind);
939
+ return typeof value === "string" && value ? value : null;
940
+ }
941
+ return null;
942
+ }
943
+
944
+ function makeCodexUsageSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
945
+ const details = normalizeCodexRateLimitDetails(rateLimit);
946
+ return {
947
+ limitId: limitId || null,
948
+ limitName: limitName || null,
949
+ primary: details.primary,
950
+ secondary: details.secondary,
951
+ allowed: details.allowed,
952
+ limitReached: details.limitReached,
953
+ credits: normalizeCodexCredits(credits),
954
+ planType: planType || null,
955
+ rateLimitReachedType: rateLimitReachedType || null,
956
+ };
957
+ }
958
+
959
+ function normalizeCodexUsagePayload(rawPayload) {
960
+ const payload = rawPayload && typeof rawPayload === "object" ? rawPayload : {};
961
+ const planType = firstDefined(payload.planType, payload.plan_type, null);
962
+ const rateLimitReachedType = normalizeCodexRateLimitReachedType(firstDefined(payload.rateLimitReachedType, payload.rate_limit_reached_type));
963
+ const snapshotsByKey = new Map();
964
+ const addSnapshot = (snapshot) => {
965
+ if (!snapshot) return;
966
+ const key = snapshot.limitId || snapshot.limitName || `snapshot-${snapshotsByKey.size + 1}`;
967
+ if (!snapshotsByKey.has(key)) snapshotsByKey.set(key, snapshot);
968
+ };
969
+
970
+ const directRateLimits = firstDefined(payload.rateLimits, payload.rate_limits);
971
+ if (directRateLimits && typeof directRateLimits === "object" && (directRateLimits.primary || directRateLimits.primary_window || directRateLimits.primaryWindow)) {
972
+ addSnapshot(makeCodexUsageSnapshot({
973
+ limitId: firstDefined(directRateLimits.limitId, directRateLimits.limit_id, "codex"),
974
+ limitName: firstDefined(directRateLimits.limitName, directRateLimits.limit_name),
975
+ rateLimit: directRateLimits,
976
+ credits: firstDefined(directRateLimits.credits, payload.credits),
977
+ planType: firstDefined(directRateLimits.planType, directRateLimits.plan_type, planType),
978
+ rateLimitReachedType: firstDefined(directRateLimits.rateLimitReachedType, directRateLimits.rate_limit_reached_type, rateLimitReachedType),
979
+ }));
980
+ } else {
981
+ addSnapshot(makeCodexUsageSnapshot({
982
+ limitId: "codex",
983
+ rateLimit: firstDefined(payload.rateLimit, payload.rate_limit),
984
+ credits: payload.credits,
985
+ planType,
986
+ rateLimitReachedType,
987
+ }));
988
+ }
989
+
990
+ const byLimitId = firstDefined(payload.rateLimitsByLimitId, payload.rate_limits_by_limit_id);
991
+ if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
992
+ for (const [limitId, rawSnapshot] of Object.entries(byLimitId)) {
993
+ if (!rawSnapshot || typeof rawSnapshot !== "object") continue;
994
+ addSnapshot(makeCodexUsageSnapshot({
995
+ limitId: firstDefined(rawSnapshot.limitId, rawSnapshot.limit_id, limitId),
996
+ limitName: firstDefined(rawSnapshot.limitName, rawSnapshot.limit_name),
997
+ rateLimit: rawSnapshot,
998
+ credits: rawSnapshot.credits,
999
+ planType: firstDefined(rawSnapshot.planType, rawSnapshot.plan_type, planType),
1000
+ rateLimitReachedType: firstDefined(rawSnapshot.rateLimitReachedType, rawSnapshot.rate_limit_reached_type),
1001
+ }));
1002
+ }
1003
+ }
1004
+
1005
+ const additionalRateLimits = firstDefined(payload.additionalRateLimits, payload.additional_rate_limits);
1006
+ if (Array.isArray(additionalRateLimits)) {
1007
+ for (const item of additionalRateLimits) {
1008
+ if (!item || typeof item !== "object") continue;
1009
+ addSnapshot(makeCodexUsageSnapshot({
1010
+ limitId: firstDefined(item.limitId, item.limit_id, item.meteredFeature, item.metered_feature, item.limitName, item.limit_name),
1011
+ limitName: firstDefined(item.limitName, item.limit_name),
1012
+ rateLimit: firstDefined(item.rateLimit, item.rate_limit),
1013
+ credits: item.credits,
1014
+ planType,
1015
+ }));
1016
+ }
1017
+ }
1018
+
1019
+ const snapshots = [...snapshotsByKey.values()];
1020
+ const selected = snapshots.find((snapshot) => snapshot.limitId === "codex") || snapshots[0] || null;
1021
+ const rateLimitsByLimitId = Object.fromEntries(snapshots.filter((snapshot) => snapshot.limitId).map((snapshot) => [snapshot.limitId, snapshot]));
1022
+ return {
1023
+ planType: planType || selected?.planType || null,
1024
+ rateLimitReachedType: rateLimitReachedType || selected?.rateLimitReachedType || null,
1025
+ credits: normalizeCodexCredits(payload.credits) || selected?.credits || null,
1026
+ selected,
1027
+ snapshots,
1028
+ rateLimits: selected,
1029
+ rateLimitsByLimitId,
1030
+ };
1031
+ }
1032
+
1033
+ async function getOpenAICodexUsageCredentials({ forceRefresh = false } = {}) {
1034
+ const authStorage = AuthStorage.create();
1035
+ const stored = authStorage.get(OPENAI_CODEX_PROVIDER_ID);
1036
+ const storedExpires = numericValue(stored?.expires);
1037
+ const shouldRefresh = stored?.type === "oauth" && (forceRefresh || storedExpires === undefined || Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS >= storedExpires);
1038
+ let accessToken;
1039
+ let refreshed = false;
1040
+
1041
+ if (shouldRefresh) {
1042
+ try {
1043
+ const refreshResult = await authStorage.refreshOAuthTokenWithLock(OPENAI_CODEX_PROVIDER_ID);
1044
+ if (refreshResult?.apiKey) {
1045
+ accessToken = refreshResult.apiKey;
1046
+ refreshed = forceRefresh || refreshResult.newCredentials?.access !== stored?.access;
1047
+ }
1048
+ } catch (error) {
1049
+ if (forceRefresh || !storedExpires || Date.now() >= storedExpires) {
1050
+ throw makeHttpError(401, "OpenAI Codex OAuth token refresh failed. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) to re-authenticate.");
1051
+ }
1052
+ console.warn(`OpenAI Codex token refresh warning: ${sanitizeError(error)}`);
1053
+ }
1054
+ }
1055
+
1056
+ if (!accessToken) {
1057
+ accessToken = await authStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, { includeFallback: false });
1058
+ }
1059
+ if (!accessToken) {
1060
+ const status = authStorage.getAuthStatus(OPENAI_CODEX_PROVIDER_ID);
1061
+ if (status.configured) throw makeHttpError(401, "OpenAI Codex OAuth token is expired or unavailable. Run /login to refresh credentials.");
1062
+ throw makeHttpError(401, "OpenAI Codex OAuth is not configured. Run /login and choose ChatGPT Plus/Pro (Codex Subscription).");
1063
+ }
1064
+
1065
+ const latest = authStorage.get(OPENAI_CODEX_PROVIDER_ID) || stored || {};
1066
+ const accountId = latest.accountId || codexAccountIdFromAccessToken(accessToken);
1067
+ if (!accountId) {
1068
+ throw makeHttpError(401, "OpenAI Codex account id is unavailable. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) again.");
1069
+ }
1070
+
1071
+ return {
1072
+ accessToken,
1073
+ accountId,
1074
+ refreshed,
1075
+ source: latest.type === "oauth" ? "stored-oauth" : "api-key",
1076
+ expiresAt: numericValue(latest.expires) ? new Date(numericValue(latest.expires)).toISOString() : undefined,
1077
+ };
1078
+ }
1079
+
1080
+ async function fetchOpenAICodexUsagePayload(credentials) {
1081
+ const controller = new AbortController();
1082
+ const timer = setTimeout(() => controller.abort(), CODEX_USAGE_TIMEOUT_MS);
1083
+ timer.unref?.();
1084
+ try {
1085
+ const response = await fetch(OPENAI_CODEX_USAGE_ENDPOINT, {
1086
+ method: "GET",
1087
+ headers: {
1088
+ accept: "application/json",
1089
+ authorization: `Bearer ${credentials.accessToken}`,
1090
+ "chatgpt-account-id": credentials.accountId,
1091
+ originator: "pi-webui",
1092
+ },
1093
+ signal: controller.signal,
1094
+ });
1095
+ const text = await response.text().catch(() => "");
1096
+ if (!response.ok) {
1097
+ const error = makeHttpError(response.status === 401 ? 401 : 502, `OpenAI Codex usage request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})`);
1098
+ error.openaiStatus = response.status;
1099
+ throw error;
1100
+ }
1101
+ try {
1102
+ return JSON.parse(text || "{}");
1103
+ } catch {
1104
+ throw makeHttpError(502, "OpenAI Codex usage response was not valid JSON");
1105
+ }
1106
+ } catch (error) {
1107
+ if (error?.name === "AbortError") throw makeHttpError(504, "OpenAI Codex usage request timed out");
1108
+ throw error;
1109
+ } finally {
1110
+ clearTimeout(timer);
1111
+ }
1112
+ }
1113
+
1114
+ async function getOpenAICodexUsageStatus({ forceRefresh = false } = {}) {
1115
+ let credentials = await getOpenAICodexUsageCredentials({ forceRefresh });
1116
+ let rawPayload;
1117
+ try {
1118
+ rawPayload = await fetchOpenAICodexUsagePayload(credentials);
1119
+ } catch (error) {
1120
+ if (error?.openaiStatus === 401 && !credentials.refreshed) {
1121
+ credentials = await getOpenAICodexUsageCredentials({ forceRefresh: true });
1122
+ rawPayload = await fetchOpenAICodexUsagePayload(credentials);
1123
+ } else {
1124
+ throw error;
1125
+ }
1126
+ }
1127
+
1128
+ return {
1129
+ available: true,
1130
+ providerId: OPENAI_CODEX_PROVIDER_ID,
1131
+ source: "chatgpt.com",
1132
+ fetchedAt: new Date().toISOString(),
1133
+ auth: {
1134
+ source: credentials.source,
1135
+ expiresAt: credentials.expiresAt,
1136
+ refreshed: credentials.refreshed,
1137
+ },
1138
+ ...normalizeCodexUsagePayload(rawPayload),
1139
+ };
1140
+ }
1141
+
842
1142
  async function configuredScopedModelPatterns(cwd = options.cwd) {
843
1143
  const cliPatterns = parseCliScopedModelPatterns();
844
1144
  if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
@@ -2954,6 +3254,16 @@ const server = createServer(async (req, res) => {
2954
3254
  return;
2955
3255
  }
2956
3256
 
3257
+ if (url.pathname === "/api/codex-usage" && req.method === "GET") {
3258
+ try {
3259
+ const forceRefresh = ["1", "true", "yes"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
3260
+ sendJson(res, 200, { ok: true, data: await getOpenAICodexUsageStatus({ forceRefresh }) });
3261
+ } catch (error) {
3262
+ sendJson(res, error?.statusCode || 500, { ok: false, error: error?.message || "Failed to read OpenAI Codex usage" });
3263
+ }
3264
+ return;
3265
+ }
3266
+
2957
3267
  if (url.pathname === "/api/network" && req.method === "GET") {
2958
3268
  sendJson(res, 200, { ok: true, data: networkStatus() });
2959
3269
  return;
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -14,6 +14,7 @@
14
14
  "extension"
15
15
  ],
16
16
  "pi": {
17
+ "image": "https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png",
17
18
  "extensions": [
18
19
  "./index.ts",
19
20
  "../pi-extension-git-footer-status/index.ts",
@@ -63,6 +64,7 @@
63
64
  "index.ts",
64
65
  "bin",
65
66
  "public",
67
+ "images",
66
68
  "tests",
67
69
  "README.md",
68
70
  "LICENSE"