@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 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 contentDispositionAttachment(fileName) {
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 `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`;
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: `/api/native-download/${encodeURIComponent(token)}`,
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: ["ignore", "pipe", "pipe"],
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.8",
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.7"
56
+ "@earendil-works/pi-coding-agent": "^0.79.8"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "@firstpick/pi-extension-btw": "^0.1.0",