@firstpick/pi-package-webui 0.4.7 → 0.4.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 +10 -10
- package/bin/pi-webui.mjs +148 -52
- package/package.json +2 -2
- package/public/app.js +998 -152
- package/public/index.html +11 -5
- package/public/styles.css +458 -46
- package/tests/http-endpoints-harness.test.mjs +112 -0
- package/tests/mobile-static.test.mjs +40 -18
- package/tests/native-parity.test.mjs +5 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Local browser UI for [Pi coding agent](https://www.npmjs.com/package/@earendil-w
|
|
|
6
6
|
|
|
7
7
|
Pi Web UI gives you a local browser companion for Pi: multi-tab chat, streaming output, model controls, uploads, slash-command helpers, workspace navigation, and optional extension widgets.
|
|
8
8
|
|
|
9
|
-
> **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Remote PIN
|
|
9
|
+
> **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Trusted-LAN opening/closing and Remote PIN auth controls are owned by the optional `@firstpick/pi-package-remote-webui` companion; when enabled, Remote PIN auth persists for later Web UI starts.
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
@@ -134,13 +134,13 @@ Environment variables:
|
|
|
134
134
|
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
|
|
135
135
|
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
136
136
|
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
137
|
-
- Model, thinking, session, workspace, theme, optional-feature, Codex usage,
|
|
137
|
+
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, optional Remote WebUI, update/restart, event, and notification controls in the side panel.
|
|
138
138
|
- Persistent context-window meter with manual compact and auto-compaction controls near the composer.
|
|
139
139
|
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
140
140
|
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
141
|
-
- Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
|
|
141
|
+
- Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Running app runners expose line-oriented stdin in the widget for interactive scripts. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
|
|
142
142
|
- Browser support for Pi extension UI prompts, widgets, status updates, `/btw` side-question output widgets with optional context transfer/live steering, browser notifications when a tab needs an extension UI response, and an optional side-panel toggle for agent-done notifications.
|
|
143
|
-
- Localhost-only Pi/Web UI update checks with a top-right update notification and
|
|
143
|
+
- Localhost-only Pi/Web UI update checks with a top-right update notification and confirmed restart actions: **Update Pi & restart** runs `pi update` for Pi-only updates, while **Update Pi + Packages & Restart** runs `pi update --all` for Pi plus configured packages.
|
|
144
144
|
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
|
|
145
145
|
- Mobile-friendly layout and PWA install support where the browser allows it.
|
|
146
146
|
|
|
@@ -150,7 +150,7 @@ Useful browser endpoints exposed by the local server include:
|
|
|
150
150
|
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
151
151
|
- `GET /api/optional-features` for optional companion package install/update status.
|
|
152
152
|
- `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
|
|
153
|
-
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update`
|
|
153
|
+
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` followed by a Web UI server restart. Use `POST /api/update?all=1` to run `pi update --all` for Pi plus configured packages.
|
|
154
154
|
- `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for optional 4-digit PIN authentication when serving non-local browser clients.
|
|
155
155
|
|
|
156
156
|
For local development, run the checkout helper directly, for example:
|
|
@@ -178,7 +178,7 @@ Optional companions:
|
|
|
178
178
|
- `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
|
|
179
179
|
- `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
|
|
180
180
|
- `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
|
|
181
|
-
- `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper for
|
|
181
|
+
- `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper plus the optional browser controls for opening/closing LAN access and Remote PIN auth.
|
|
182
182
|
- `@firstpick/pi-extension-git-footer-status` — richer extension-owned git/footer status, including the structured Web UI footer payload.
|
|
183
183
|
- `@firstpick/pi-extension-stats` — stats commands and status data.
|
|
184
184
|
- `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
|
|
@@ -208,9 +208,9 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
|
|
|
208
208
|
## Network safety
|
|
209
209
|
|
|
210
210
|
- Default bind is localhost-only: `127.0.0.1:31415`.
|
|
211
|
-
-
|
|
212
|
-
- The
|
|
213
|
-
- Localhost clients stay frictionless and can toggle Remote PIN auth; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
|
|
211
|
+
- When `@firstpick/pi-package-remote-webui` is loaded and enabled, the side-panel **Remote WebUI** controls dispatch through `/remote`: opening rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
|
|
212
|
+
- The optional **Remote PIN auth** toggle is off by default on first use. When enabled through `/remote auth on` or the Remote WebUI controls, the server saves that preference, generates a fresh random 4-digit PIN for each server start, shows it in the Remote WebUI controls and `/webui-status`, and requires it from non-local browser clients.
|
|
213
|
+
- Localhost clients stay frictionless and can toggle Remote PIN auth through the remote companion; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
|
|
214
214
|
- `--host 0.0.0.0` also exposes the Web UI to the local network; pass `--remote-auth` to start with PIN auth already enabled.
|
|
215
215
|
- Any connected browser client with access (and the PIN, if enabled) can control Pi and run Web UI bash actions as the Web UI process user.
|
|
216
216
|
- Remote PIN auth is a simple trusted-LAN HTTP gate, not hardened multi-user authentication; do not expose it to untrusted networks.
|
|
@@ -222,5 +222,5 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
|
|
|
222
222
|
- **`/webui-start` is missing:** restart Pi after installing the package.
|
|
223
223
|
- **Wrong port or existing server:** use `/webui-status detailed`, or start on another port with `/webui-start --port 31500`.
|
|
224
224
|
- **Optional feature is disabled or missing:** check the side panel, install the companion package if needed, then run `/reload` in the active Pi tab.
|
|
225
|
-
- **Remote browser asks for a PIN:** read it from
|
|
225
|
+
- **Remote browser asks for a PIN:** read it from the optional **Remote WebUI** side-panel controls, `/webui-status`, `/remote status`, or the local Web UI server log. Disable the toggle from localhost to remove the PIN gate.
|
|
226
226
|
- **PWA install or notifications are unavailable:** use `localhost` or HTTPS; browser support varies on LAN HTTP URLs.
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -177,6 +177,7 @@ const APP_RUNNER_DETECTION_TIMEOUT_MS = 1_200;
|
|
|
177
177
|
const APP_RUNNER_COMMAND_CACHE_TTL_MS = 30_000;
|
|
178
178
|
const APP_RUNNER_OUTPUT_LINE_LIMIT = 1_000;
|
|
179
179
|
const APP_RUNNER_OUTPUT_MAX_CHARS = 240_000;
|
|
180
|
+
const APP_RUNNER_INPUT_MAX_CHARS = 16_000;
|
|
180
181
|
const APP_RUNNER_STOP_GRACE_MS = 2_500;
|
|
181
182
|
const APP_RUNNER_PYTHON_ENTRIES = ["Main.py", "main.py", "src/main.py", "src/Main.py", "app.py", "src/app.py"];
|
|
182
183
|
const APP_RUNNER_JS_ENTRIES = ["main.js", "src/main.js", "index.js", "src/index.js", "server.js", "src/server.js", "app.js", "src/app.js"];
|
|
@@ -838,10 +839,18 @@ function safeDownloadFileName(name, fallback = "pi-export") {
|
|
|
838
839
|
return (text || fallback).slice(0, 180);
|
|
839
840
|
}
|
|
840
841
|
|
|
841
|
-
function
|
|
842
|
+
function contentDispositionHeader(fileName, disposition = "attachment") {
|
|
842
843
|
const safeName = safeDownloadFileName(fileName);
|
|
843
844
|
const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
844
|
-
return
|
|
845
|
+
return `${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function contentDispositionAttachment(fileName) {
|
|
849
|
+
return contentDispositionHeader(fileName, "attachment");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function contentDispositionInline(fileName) {
|
|
853
|
+
return contentDispositionHeader(fileName, "inline");
|
|
845
854
|
}
|
|
846
855
|
|
|
847
856
|
function registerNativeDownload(filePath, { fileName, contentType, command = "native" } = {}) {
|
|
@@ -856,15 +865,17 @@ function registerNativeDownload(filePath, { fileName, contentType, command = "na
|
|
|
856
865
|
expiresAt,
|
|
857
866
|
};
|
|
858
867
|
nativeDownloadTokens.set(token, record);
|
|
868
|
+
const url = `/api/native-download/${encodeURIComponent(token)}`;
|
|
859
869
|
return {
|
|
860
|
-
url
|
|
870
|
+
url,
|
|
871
|
+
openUrl: record.contentType === MIME_TYPES.get(".html") ? `${url}?disposition=inline` : undefined,
|
|
861
872
|
fileName: record.fileName,
|
|
862
873
|
contentType: record.contentType,
|
|
863
874
|
expiresAt: new Date(expiresAt).toISOString(),
|
|
864
875
|
};
|
|
865
876
|
}
|
|
866
877
|
|
|
867
|
-
async function sendNativeDownload(res, token) {
|
|
878
|
+
async function sendNativeDownload(res, token, { inline = false } = {}) {
|
|
868
879
|
pruneNativeDownloadTokens();
|
|
869
880
|
const item = nativeDownloadTokens.get(token);
|
|
870
881
|
if (!item) throw makeHttpError(404, "Download token expired or not found");
|
|
@@ -873,10 +884,11 @@ async function sendNativeDownload(res, token) {
|
|
|
873
884
|
nativeDownloadTokens.delete(token);
|
|
874
885
|
throw makeHttpError(404, "Download file expired or not found");
|
|
875
886
|
}
|
|
887
|
+
const canRenderInline = inline === true && item.contentType === MIME_TYPES.get(".html");
|
|
876
888
|
res.writeHead(200, {
|
|
877
889
|
"content-type": item.contentType,
|
|
878
890
|
"content-length": String(fileStats.size),
|
|
879
|
-
"content-disposition": contentDispositionAttachment(item.fileName),
|
|
891
|
+
"content-disposition": canRenderInline ? contentDispositionInline(item.fileName) : contentDispositionAttachment(item.fileName),
|
|
880
892
|
"cache-control": "no-store",
|
|
881
893
|
"x-content-type-options": "nosniff",
|
|
882
894
|
});
|
|
@@ -2274,6 +2286,11 @@ async function detectAppRunners(tab) {
|
|
|
2274
2286
|
.map(publicAppRunner);
|
|
2275
2287
|
}
|
|
2276
2288
|
|
|
2289
|
+
function appRunnerPendingLine(run) {
|
|
2290
|
+
if (!run || run.status !== "running") return "";
|
|
2291
|
+
return [run.stdoutRemainder, run.stderrRemainder].map((part) => String(part || "")).filter(Boolean).join("");
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2277
2294
|
function publicAppRunnerState(run) {
|
|
2278
2295
|
if (!run) return null;
|
|
2279
2296
|
return {
|
|
@@ -2295,6 +2312,11 @@ function publicAppRunnerState(run) {
|
|
|
2295
2312
|
truncated: run.truncated === true,
|
|
2296
2313
|
lineCount: run.lineCount || run.lines?.length || 0,
|
|
2297
2314
|
lines: Array.isArray(run.lines) ? [...run.lines] : [],
|
|
2315
|
+
pendingLine: appRunnerPendingLine(run),
|
|
2316
|
+
stdinClosed: run.stdinClosed === true,
|
|
2317
|
+
stdinError: run.stdinError || "",
|
|
2318
|
+
stdinWrites: run.stdinWrites || 0,
|
|
2319
|
+
lastStdinAt: run.lastStdinAt || "",
|
|
2298
2320
|
};
|
|
2299
2321
|
}
|
|
2300
2322
|
|
|
@@ -2398,6 +2420,7 @@ function finishAppRunner(tab, run, patch = {}) {
|
|
|
2398
2420
|
run.error = patch.error;
|
|
2399
2421
|
run.status = patch.error ? "error" : patch.exitCode === 0 ? "done" : "failed";
|
|
2400
2422
|
run.child = null;
|
|
2423
|
+
run.stdinClosed = true;
|
|
2401
2424
|
run.stopping = false;
|
|
2402
2425
|
appendAppRunnerLine(run, `# ${appRunnerStatusLabel(run)} after ${Math.max(0, Math.round((Date.parse(run.endedAt) - Date.parse(run.startedAt)) / 1000))}s`);
|
|
2403
2426
|
if (patch.error) appendAppRunnerLine(run, `# ${patch.error}`);
|
|
@@ -2407,6 +2430,45 @@ function finishAppRunner(tab, run, patch = {}) {
|
|
|
2407
2430
|
broadcastAppRunnerState(tab);
|
|
2408
2431
|
}
|
|
2409
2432
|
|
|
2433
|
+
function normalizeAppRunnerInputText(value) {
|
|
2434
|
+
const text = String(value ?? "");
|
|
2435
|
+
if (text.includes("\0")) throw makeHttpError(400, "App runner input cannot contain null bytes");
|
|
2436
|
+
if (text.length > APP_RUNNER_INPUT_MAX_CHARS) throw makeHttpError(413, `App runner input is too long; limit is ${APP_RUNNER_INPUT_MAX_CHARS} characters`);
|
|
2437
|
+
return text;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
function sendAppRunnerInput(tab, value, { appendNewline = true, closeStdin = false } = {}) {
|
|
2441
|
+
const run = tab?.appRunner;
|
|
2442
|
+
if (!run || run.status !== "running") throw makeHttpError(409, "No app runner is running in this tab");
|
|
2443
|
+
const stdin = run.child?.stdin;
|
|
2444
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded || run.stdinClosed === true) throw makeHttpError(409, "App runner stdin is closed");
|
|
2445
|
+
const text = normalizeAppRunnerInputText(value);
|
|
2446
|
+
const chunk = `${text}${appendNewline === false ? "" : "\n"}`;
|
|
2447
|
+
if (!chunk && !closeStdin) throw makeHttpError(400, "App runner input is empty");
|
|
2448
|
+
let buffered = false;
|
|
2449
|
+
try {
|
|
2450
|
+
if (closeStdin) {
|
|
2451
|
+
if (chunk) stdin.end(chunk, "utf8");
|
|
2452
|
+
else stdin.end();
|
|
2453
|
+
run.stdinClosed = true;
|
|
2454
|
+
} else {
|
|
2455
|
+
buffered = stdin.write(chunk, "utf8") === false;
|
|
2456
|
+
}
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
run.stdinClosed = true;
|
|
2459
|
+
run.stdinError = sanitizeError(error);
|
|
2460
|
+
throw makeHttpError(409, `App runner stdin write failed: ${run.stdinError}`);
|
|
2461
|
+
}
|
|
2462
|
+
run.stdinWrites = (run.stdinWrites || 0) + 1;
|
|
2463
|
+
run.lastStdinAt = new Date().toISOString();
|
|
2464
|
+
const closeSuffix = closeStdin ? " and closed" : "";
|
|
2465
|
+
if (chunk) appendAppRunnerLine(run, text ? `# stdin sent (${text.length} char${text.length === 1 ? "" : "s"})${closeSuffix}` : `# stdin sent (Enter)${closeSuffix}`);
|
|
2466
|
+
else appendAppRunnerLine(run, "# stdin closed (EOF)");
|
|
2467
|
+
recordEvent({ type: "webui_app_runner_stdin", tabId: tab.id, tabTitle: tab.title, command: run.displayCommand, chars: text.length, newline: appendNewline !== false, closed: closeStdin === true });
|
|
2468
|
+
scheduleAppRunnerBroadcast(tab);
|
|
2469
|
+
return { cwd: tab.cwd, activeRun: publicAppRunnerState(run), inputBuffered: buffered };
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2410
2472
|
async function startAppRunner(tab, runnerId) {
|
|
2411
2473
|
if (tab.appRunner?.status === "running") throw makeHttpError(409, `App runner already running: ${tab.appRunner.displayCommand}`);
|
|
2412
2474
|
const runners = await detectAppRunners(tab);
|
|
@@ -2427,12 +2489,14 @@ async function startAppRunner(tab, runnerId) {
|
|
|
2427
2489
|
lines: [],
|
|
2428
2490
|
lineCount: 0,
|
|
2429
2491
|
outputChars: 0,
|
|
2492
|
+
stdinClosed: false,
|
|
2493
|
+
stdinWrites: 0,
|
|
2430
2494
|
};
|
|
2431
2495
|
appendAppRunnerLine(run, `$ ${run.displayCommand}`);
|
|
2432
2496
|
const child = spawn(run.command, run.args, {
|
|
2433
2497
|
cwd: run.cwd,
|
|
2434
2498
|
env: process.env,
|
|
2435
|
-
stdio: ["
|
|
2499
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2436
2500
|
windowsHide: true,
|
|
2437
2501
|
detached: process.platform !== "win32",
|
|
2438
2502
|
});
|
|
@@ -2440,6 +2504,18 @@ async function startAppRunner(tab, runnerId) {
|
|
|
2440
2504
|
run.pid = child.pid;
|
|
2441
2505
|
tab.appRunner = run;
|
|
2442
2506
|
|
|
2507
|
+
child.stdin?.on("error", (error) => {
|
|
2508
|
+
run.stdinClosed = true;
|
|
2509
|
+
run.stdinError = sanitizeError(error);
|
|
2510
|
+
if (run.status === "running") {
|
|
2511
|
+
appendAppRunnerLine(run, `# stdin error: ${run.stdinError}`);
|
|
2512
|
+
scheduleAppRunnerBroadcast(tab);
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
child.stdin?.on("close", () => {
|
|
2516
|
+
run.stdinClosed = true;
|
|
2517
|
+
if (run.status === "running") scheduleAppRunnerBroadcast(tab);
|
|
2518
|
+
});
|
|
2443
2519
|
child.stdout.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stdout"));
|
|
2444
2520
|
child.stderr.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stderr"));
|
|
2445
2521
|
child.on("error", (error) => finishAppRunner(tab, run, { error: sanitizeError(error) }));
|
|
@@ -3155,40 +3231,43 @@ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_T
|
|
|
3155
3231
|
throw new Error(String(message).trim());
|
|
3156
3232
|
}
|
|
3157
3233
|
|
|
3158
|
-
function
|
|
3159
|
-
const
|
|
3160
|
-
|
|
3234
|
+
function gitBranchFromPorcelainStatus(statusText) {
|
|
3235
|
+
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
3236
|
+
if (!line.startsWith("# branch.head ")) continue;
|
|
3237
|
+
const branch = line.slice("# branch.head ".length).trim();
|
|
3238
|
+
return branch && branch !== "(detached)" ? branch : "detached";
|
|
3239
|
+
}
|
|
3240
|
+
return "detached";
|
|
3161
3241
|
}
|
|
3162
3242
|
|
|
3163
|
-
function
|
|
3164
|
-
const
|
|
3165
|
-
const
|
|
3166
|
-
|
|
3167
|
-
|
|
3243
|
+
function addGitPorcelainTrackedSummary(summary, xy) {
|
|
3244
|
+
const x = xy?.[0] || ".";
|
|
3245
|
+
const y = xy?.[1] || ".";
|
|
3246
|
+
if (x !== ".") summary.staged += 1;
|
|
3247
|
+
if (y !== ".") summary.unstaged += 1;
|
|
3168
3248
|
}
|
|
3169
3249
|
|
|
3170
|
-
function
|
|
3250
|
+
function summarizeGitPorcelainStatus(statusText) {
|
|
3171
3251
|
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
|
|
3172
3252
|
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
3173
3253
|
if (!line) continue;
|
|
3174
|
-
if (line.startsWith("
|
|
3175
|
-
const
|
|
3176
|
-
|
|
3177
|
-
|
|
3254
|
+
if (line.startsWith("# branch.ab ")) {
|
|
3255
|
+
const match = line.match(/\+(\d+)\s+-(\d+)/);
|
|
3256
|
+
if (match) {
|
|
3257
|
+
summary.ahead = Number.parseInt(match[1] || "0", 10) || 0;
|
|
3258
|
+
summary.behind = Number.parseInt(match[2] || "0", 10) || 0;
|
|
3259
|
+
}
|
|
3178
3260
|
continue;
|
|
3179
3261
|
}
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
if (x === "?" && y === "?") {
|
|
3183
|
-
summary.untracked += 1;
|
|
3262
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
3263
|
+
addGitPorcelainTrackedSummary(summary, line.split(" ")[1] || "..");
|
|
3184
3264
|
continue;
|
|
3185
3265
|
}
|
|
3186
|
-
if (
|
|
3266
|
+
if (line.startsWith("u ")) {
|
|
3187
3267
|
summary.conflicted += 1;
|
|
3188
3268
|
continue;
|
|
3189
3269
|
}
|
|
3190
|
-
if (
|
|
3191
|
-
if (y && y !== " ") summary.unstaged += 1;
|
|
3270
|
+
if (line.startsWith("? ")) summary.untracked += 1;
|
|
3192
3271
|
}
|
|
3193
3272
|
return summary;
|
|
3194
3273
|
}
|
|
@@ -3293,20 +3372,21 @@ async function pullGitChanges(cwd) {
|
|
|
3293
3372
|
async function readGitChanges(cwd) {
|
|
3294
3373
|
const root = await getGitRoot(cwd);
|
|
3295
3374
|
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3296
|
-
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3375
|
+
const [statusText, porcelainStatusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3297
3376
|
runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3377
|
+
runGitReadCommand(root, ["status", "--porcelain=2", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3298
3378
|
runGitReadCommand(root, diffArgs),
|
|
3299
3379
|
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3300
3380
|
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
3301
3381
|
]);
|
|
3302
|
-
const summary =
|
|
3382
|
+
const summary = summarizeGitPorcelainStatus(porcelainStatusText);
|
|
3303
3383
|
const incoming = await readGitIncomingChanges(root, summary);
|
|
3304
3384
|
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3305
3385
|
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
3306
3386
|
return {
|
|
3307
3387
|
cwd,
|
|
3308
3388
|
root,
|
|
3309
|
-
branch:
|
|
3389
|
+
branch: gitBranchFromPorcelainStatus(porcelainStatusText),
|
|
3310
3390
|
generatedAt: new Date().toISOString(),
|
|
3311
3391
|
summary,
|
|
3312
3392
|
remote: incoming.remote,
|
|
@@ -4276,6 +4356,9 @@ try {
|
|
|
4276
4356
|
process.exit(2);
|
|
4277
4357
|
}
|
|
4278
4358
|
|
|
4359
|
+
process.env.PI_WEBUI_HOST = options.host;
|
|
4360
|
+
process.env.PI_WEBUI_PORT = String(options.port);
|
|
4361
|
+
|
|
4279
4362
|
const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
|
|
4280
4363
|
delete process.env.PI_WEBUI_START_DELAY_MS;
|
|
4281
4364
|
if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
|
|
@@ -5198,32 +5281,35 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
5198
5281
|
checkedAt: new Date(now).toISOString(),
|
|
5199
5282
|
updateAvailable,
|
|
5200
5283
|
restartRequired: true,
|
|
5201
|
-
command: "pi update
|
|
5284
|
+
command: "pi update",
|
|
5285
|
+
allCommand: "pi update --all",
|
|
5202
5286
|
webuiDev: webuiDevServer,
|
|
5203
5287
|
pi: piStatus,
|
|
5204
5288
|
webui: webuiStatus,
|
|
5205
5289
|
packages: {
|
|
5206
5290
|
checked: false,
|
|
5207
|
-
note: "
|
|
5291
|
+
note: "Default update runs pi update for Pi only. Use update all to run pi update --all for Pi and configured packages."
|
|
5208
5292
|
},
|
|
5209
5293
|
};
|
|
5210
5294
|
updateStatusCacheAt = now;
|
|
5211
5295
|
return updateStatusCache;
|
|
5212
5296
|
}
|
|
5213
5297
|
|
|
5214
|
-
async function resolvePiUpdateCommand() {
|
|
5298
|
+
async function resolvePiUpdateCommand({ all = false } = {}) {
|
|
5299
|
+
const updateArgs = all ? ["update", "--all"] : ["update"];
|
|
5300
|
+
const label = all ? "Pi CLI and configured packages" : "Pi CLI";
|
|
5215
5301
|
if (options.piBinExplicit) {
|
|
5216
|
-
const command = await resolvePiCommand(
|
|
5217
|
-
return { ...command, label:
|
|
5302
|
+
const command = await resolvePiCommand(updateArgs);
|
|
5303
|
+
return { ...command, label, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5218
5304
|
}
|
|
5219
5305
|
|
|
5220
5306
|
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
5221
5307
|
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
5222
|
-
return { label
|
|
5308
|
+
return { label, command: options.piBin, args: updateArgs, displayCommand: formatCommandForDisplay(options.piBin, updateArgs), timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5223
5309
|
}
|
|
5224
5310
|
|
|
5225
|
-
const fallback = await resolvePiCommand(
|
|
5226
|
-
return { ...fallback, label:
|
|
5311
|
+
const fallback = await resolvePiCommand(updateArgs);
|
|
5312
|
+
return { ...fallback, label: `bundled ${label}`, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
|
|
5227
5313
|
}
|
|
5228
5314
|
|
|
5229
5315
|
function packageNodeModulesPath(nodeModulesRoot, packageName) {
|
|
@@ -5398,13 +5484,9 @@ function uniqueUpdateTasks(tasks) {
|
|
|
5398
5484
|
return unique;
|
|
5399
5485
|
}
|
|
5400
5486
|
|
|
5401
|
-
async function resolveUpdateTasks() {
|
|
5487
|
+
async function resolveUpdateTasks({ all = false } = {}) {
|
|
5402
5488
|
return uniqueUpdateTasks([
|
|
5403
|
-
await resolvePiUpdateCommand(),
|
|
5404
|
-
await currentWebuiPackageUpdateTask(),
|
|
5405
|
-
await agentPackageRootUpdateTask(),
|
|
5406
|
-
await npmGlobalPackageRootUpdateTask(),
|
|
5407
|
-
await bunGlobalPackageRootUpdateTask(),
|
|
5489
|
+
await resolvePiUpdateCommand({ all }),
|
|
5408
5490
|
]);
|
|
5409
5491
|
}
|
|
5410
5492
|
|
|
@@ -5445,16 +5527,17 @@ function combinedUpdateOutput(results, field) {
|
|
|
5445
5527
|
.join("\n\n");
|
|
5446
5528
|
}
|
|
5447
5529
|
|
|
5448
|
-
async function runPiUpdateAndPrepareRestart() {
|
|
5530
|
+
async function runPiUpdateAndPrepareRestart({ all = false } = {}) {
|
|
5449
5531
|
if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
|
|
5450
5532
|
piUpdateInProgress = true;
|
|
5451
5533
|
let restartPrepared = false;
|
|
5452
5534
|
try {
|
|
5453
5535
|
const restorableTabs = await restorableTabsForRestart();
|
|
5454
|
-
const updateTasks = await resolveUpdateTasks();
|
|
5455
|
-
if (!updateTasks.length) throw makeHttpError(500, "No Pi
|
|
5536
|
+
const updateTasks = await resolveUpdateTasks({ all });
|
|
5537
|
+
if (!updateTasks.length) throw makeHttpError(500, "No Pi update command could be resolved.");
|
|
5456
5538
|
const command = updateTasks.map(updateTaskDisplay).join(" && ");
|
|
5457
|
-
|
|
5539
|
+
const updateLabel = all ? "Pi and package updates" : "Pi update";
|
|
5540
|
+
recordEvent({ type: "webui_update_started", command, updateAll: all, restorableTabCount: restorableTabs.length });
|
|
5458
5541
|
const results = [];
|
|
5459
5542
|
for (const task of updateTasks) results.push(await runUpdateTask(task));
|
|
5460
5543
|
|
|
@@ -5462,9 +5545,9 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
5462
5545
|
updateStatusCacheAt = 0;
|
|
5463
5546
|
const child = spawnRestartServer(restorableTabs);
|
|
5464
5547
|
restartPrepared = true;
|
|
5465
|
-
recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
5548
|
+
recordEvent({ type: "webui_update_restarting", command, updateAll: all, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
5466
5549
|
return {
|
|
5467
|
-
message:
|
|
5550
|
+
message: `${updateLabel} completed. Pi Web UI is restarting.`,
|
|
5468
5551
|
command,
|
|
5469
5552
|
commands: results.map((result) => ({ label: result.label, command: result.command })),
|
|
5470
5553
|
stdout: combinedUpdateOutput(results, "stdout"),
|
|
@@ -6537,7 +6620,7 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
6537
6620
|
return respondNative("export", {
|
|
6538
6621
|
status: "succeeded",
|
|
6539
6622
|
level: "info",
|
|
6540
|
-
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
|
|
6623
|
+
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nOpen it in your browser when prompted.\nOpen URL: ${download.openUrl || download.url}\nDownload URL: ${download.url}\nLink expires: ${download.expiresAt}`,
|
|
6541
6624
|
download,
|
|
6542
6625
|
result: response.data,
|
|
6543
6626
|
refresh: ["state"],
|
|
@@ -7297,7 +7380,9 @@ const server = createServer(async (req, res) => {
|
|
|
7297
7380
|
}
|
|
7298
7381
|
|
|
7299
7382
|
if (url.pathname.startsWith("/api/native-download/") && req.method === "GET") {
|
|
7300
|
-
await sendNativeDownload(res, decodeURIComponent(url.pathname.slice("/api/native-download/".length))
|
|
7383
|
+
await sendNativeDownload(res, decodeURIComponent(url.pathname.slice("/api/native-download/".length)), {
|
|
7384
|
+
inline: url.searchParams.get("disposition") === "inline",
|
|
7385
|
+
});
|
|
7301
7386
|
return;
|
|
7302
7387
|
}
|
|
7303
7388
|
|
|
@@ -7354,7 +7439,10 @@ const server = createServer(async (req, res) => {
|
|
|
7354
7439
|
|
|
7355
7440
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
7356
7441
|
requireLocalhostRoute(req, url.pathname);
|
|
7357
|
-
const
|
|
7442
|
+
const body = await readJsonBody(req);
|
|
7443
|
+
const queryAll = ["1", "true", "yes", "all"].includes(String(url.searchParams.get("all") || "").toLowerCase());
|
|
7444
|
+
const bodyAll = body?.all === true || String(body?.mode || "").toLowerCase() === "all";
|
|
7445
|
+
const data = await runPiUpdateAndPrepareRestart({ all: queryAll || bodyAll });
|
|
7358
7446
|
sendJson(res, 200, { ok: true, data });
|
|
7359
7447
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
7360
7448
|
return;
|
|
@@ -7389,6 +7477,14 @@ const server = createServer(async (req, res) => {
|
|
|
7389
7477
|
return;
|
|
7390
7478
|
}
|
|
7391
7479
|
|
|
7480
|
+
if (url.pathname === "/api/app-runner/input" && req.method === "POST") {
|
|
7481
|
+
const body = await readJsonBody(req);
|
|
7482
|
+
const tab = getRequestedTab(req, url, body);
|
|
7483
|
+
const text = Object.prototype.hasOwnProperty.call(body, "text") ? body.text : body.input;
|
|
7484
|
+
sendJson(res, 200, { ok: true, data: sendAppRunnerInput(tab, text, { appendNewline: body.newline !== false, closeStdin: body.closeStdin === true || body.close === true }) });
|
|
7485
|
+
return;
|
|
7486
|
+
}
|
|
7487
|
+
|
|
7392
7488
|
if (url.pathname === "/api/app-runner/stop" && req.method === "POST") {
|
|
7393
7489
|
const body = await readJsonBody(req);
|
|
7394
7490
|
const tab = getRequestedTab(req, url, body);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "node tests/run-all.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.79.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.79.8"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"@firstpick/pi-extension-btw": "^0.1.0",
|