@firstpick/pi-package-webui 0.4.8 → 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 +1 -1
- package/bin/pi-webui.mjs +94 -8
- package/package.json +2 -2
- package/public/app.js +880 -71
- package/public/index.html +6 -2
- package/public/styles.css +458 -46
- package/tests/http-endpoints-harness.test.mjs +55 -0
- package/tests/mobile-static.test.mjs +27 -10
- package/tests/native-parity.test.mjs +5 -1
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ Environment variables:
|
|
|
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
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.
|
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) }));
|
|
@@ -6544,7 +6620,7 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
6544
6620
|
return respondNative("export", {
|
|
6545
6621
|
status: "succeeded",
|
|
6546
6622
|
level: "info",
|
|
6547
|
-
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}`,
|
|
6548
6624
|
download,
|
|
6549
6625
|
result: response.data,
|
|
6550
6626
|
refresh: ["state"],
|
|
@@ -7304,7 +7380,9 @@ const server = createServer(async (req, res) => {
|
|
|
7304
7380
|
}
|
|
7305
7381
|
|
|
7306
7382
|
if (url.pathname.startsWith("/api/native-download/") && req.method === "GET") {
|
|
7307
|
-
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
|
+
});
|
|
7308
7386
|
return;
|
|
7309
7387
|
}
|
|
7310
7388
|
|
|
@@ -7399,6 +7477,14 @@ const server = createServer(async (req, res) => {
|
|
|
7399
7477
|
return;
|
|
7400
7478
|
}
|
|
7401
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
|
+
|
|
7402
7488
|
if (url.pathname === "/api/app-runner/stop" && req.method === "POST") {
|
|
7403
7489
|
const body = await readJsonBody(req);
|
|
7404
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",
|