@firstpick/pi-package-webui 0.4.6 → 0.4.8

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/public/index.html CHANGED
@@ -52,7 +52,8 @@
52
52
  <p id="updateNotificationMessage">A newer Pi version is available.</p>
53
53
  <p id="updateNotificationDetail" class="update-notification-detail muted"></p>
54
54
  <div class="update-notification-actions">
55
- <button id="updateNotificationUpdateButton" class="primary" type="button">Update &amp; restart</button>
55
+ <button id="updateNotificationUpdateButton" class="primary" type="button">Update Pi &amp; restart</button>
56
+ <button id="updateNotificationUpdateAllButton" type="button">Update all &amp; restart</button>
56
57
  <button id="updateNotificationDismissButton" type="button">Later</button>
57
58
  </div>
58
59
  </div>
@@ -379,8 +380,8 @@
379
380
  </div>
380
381
  <div id="backgroundStatus" class="background-status muted">Theme default</div>
381
382
  </div>
382
- <div class="control-field network-control-field">
383
- <label>Network</label>
383
+ <div id="networkControlField" class="control-field network-control-field" hidden>
384
+ <label>Remote WebUI</label>
384
385
  <div id="networkStatus" class="network-status closed">Local only</div>
385
386
  <label class="toggle-control remote-auth-toggle" for="remoteAuthToggle">
386
387
  <input id="remoteAuthToggle" type="checkbox" />
@@ -398,6 +399,7 @@
398
399
  <option value="" selected>Choose action…</option>
399
400
  <option value="restart">Restart Server</option>
400
401
  <option value="update">Update Pi &amp; Restart</option>
402
+ <option value="update-all">Update Pi + Packages &amp; Restart</option>
401
403
  <option value="stop">Stop Server</option>
402
404
  </select>
403
405
  <button id="runServerActionButton" type="button" disabled>Run</button>
@@ -539,7 +541,10 @@
539
541
  <h2 id="gitChangesTitle">Uncommitted Changes</h2>
540
542
  <p id="gitChangesSubtitle" class="muted">Current tab git diff</p>
541
543
  </div>
542
- <button id="gitChangesRefreshButton" type="button">Refresh</button>
544
+ <div class="git-changes-actions">
545
+ <button id="gitChangesRefreshButton" type="button">Refresh</button>
546
+ <button id="gitChangesPullButton" class="primary" type="button" disabled>Pull</button>
547
+ </div>
543
548
  </div>
544
549
  <p id="gitChangesStatus" class="git-changes-status muted" role="status" aria-live="polite"></p>
545
550
  <div id="gitChangesBody" class="git-changes-body"></div>
package/public/styles.css CHANGED
@@ -2294,6 +2294,14 @@ button.footer-meta {
2294
2294
  .git-changes-header > div:first-child {
2295
2295
  min-width: 0;
2296
2296
  }
2297
+ .git-changes-actions {
2298
+ flex: 0 0 auto;
2299
+ display: flex;
2300
+ align-items: center;
2301
+ justify-content: flex-end;
2302
+ gap: 0.5rem;
2303
+ flex-wrap: wrap;
2304
+ }
2297
2305
  .git-changes-kicker {
2298
2306
  display: block;
2299
2307
  color: var(--ctp-yellow);
@@ -2314,6 +2322,7 @@ button.footer-meta {
2314
2322
  .git-changes-empty.error {
2315
2323
  color: var(--ctp-red);
2316
2324
  }
2325
+ .git-changes-status.success,
2317
2326
  .git-changes-empty.success {
2318
2327
  color: var(--ctp-green);
2319
2328
  }
@@ -5792,11 +5801,41 @@ button.composer-skill-tag:focus-visible {
5792
5801
  gap: 0.6rem;
5793
5802
  align-items: center;
5794
5803
  }
5804
+ .app-runner-custom-item.unavailable {
5805
+ border-color: rgba(243, 139, 168, 0.32);
5806
+ }
5795
5807
  .app-runner-custom-item-details {
5796
5808
  display: grid;
5797
5809
  gap: 0.18rem;
5798
5810
  min-width: 0;
5799
5811
  }
5812
+ .app-runner-custom-warning,
5813
+ .app-runner-custom-feedback {
5814
+ color: var(--danger);
5815
+ line-height: 1.4;
5816
+ font-size: 0.82rem;
5817
+ font-weight: 700;
5818
+ }
5819
+ .app-runner-custom-feedback {
5820
+ padding: 0.58rem 0.66rem;
5821
+ border: 1px solid rgba(243, 139, 168, 0.26);
5822
+ border-radius: 0.68rem;
5823
+ background: rgba(243, 139, 168, 0.08);
5824
+ }
5825
+ .app-runner-custom-feedback.success {
5826
+ color: var(--ctp-green);
5827
+ border-color: rgba(166, 227, 161, 0.28);
5828
+ background: rgba(166, 227, 161, 0.08);
5829
+ }
5830
+ .app-runner-custom-feedback.warning {
5831
+ color: var(--ctp-yellow);
5832
+ border-color: rgba(249, 226, 175, 0.28);
5833
+ background: rgba(249, 226, 175, 0.08);
5834
+ }
5835
+ .app-runner-custom-diagnostics {
5836
+ display: grid;
5837
+ gap: 0.42rem;
5838
+ }
5800
5839
  .app-runner-custom-item-actions,
5801
5840
  .app-runner-custom-form-actions {
5802
5841
  display: flex;
@@ -37,6 +37,22 @@ async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000
37
37
  return { status: response.status, body: payload };
38
38
  }
39
39
 
40
+ function runGitFixture(args, cwd, message) {
41
+ const result = spawnSync("git", args, {
42
+ cwd,
43
+ encoding: "utf8",
44
+ env: {
45
+ ...process.env,
46
+ GIT_AUTHOR_NAME: "Pi WebUI Test",
47
+ GIT_AUTHOR_EMAIL: "pi-webui-test@example.invalid",
48
+ GIT_COMMITTER_NAME: "Pi WebUI Test",
49
+ GIT_COMMITTER_EMAIL: "pi-webui-test@example.invalid",
50
+ },
51
+ });
52
+ assert.equal(result.status, 0, `${message}\n$ git ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
53
+ return result.stdout.trim();
54
+ }
55
+
40
56
  const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
41
57
  const settingsFile = path.join(cwd, "webui-settings.json");
42
58
  await chmod(fakePi, 0o755);
@@ -152,6 +168,47 @@ try {
152
168
  assert.equal(gitMain.status, 200);
153
169
  assert.equal(gitMain.body?.ok, true, "main branch endpoint should rename the branch");
154
170
 
171
+ const remoteFixtureRoot = await mkdtemp(path.join(tmpdir(), "pi-webui-git-remote-"));
172
+ const remoteBare = path.join(remoteFixtureRoot, "origin.git");
173
+ const localRepo = path.join(remoteFixtureRoot, "local");
174
+ const remoteWork = path.join(remoteFixtureRoot, "remote-work");
175
+ runGitFixture(["init", "--bare", remoteBare], remoteFixtureRoot, "remote fixture should initialize a bare origin");
176
+ runGitFixture(["init", localRepo], remoteFixtureRoot, "remote fixture should initialize a local repo");
177
+ runGitFixture(["config", "user.name", "Pi WebUI Test"], localRepo, "local repo should set a user name");
178
+ runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], localRepo, "local repo should set a user email");
179
+ await writeFile(path.join(localRepo, "incoming.txt"), "base\n");
180
+ runGitFixture(["add", "incoming.txt"], localRepo, "local repo should stage base content");
181
+ runGitFixture(["commit", "-m", "base"], localRepo, "local repo should commit base content");
182
+ runGitFixture(["branch", "-M", "main"], localRepo, "local repo should rename main branch");
183
+ runGitFixture(["remote", "add", "origin", remoteBare], localRepo, "local repo should add bare origin");
184
+ runGitFixture(["push", "-u", "origin", "main"], localRepo, "local repo should push main to bare origin");
185
+ runGitFixture(["symbolic-ref", "HEAD", "refs/heads/main"], remoteBare, "bare origin should advertise main as HEAD");
186
+ runGitFixture(["clone", remoteBare, remoteWork], remoteFixtureRoot, "remote worktree should clone bare origin");
187
+ runGitFixture(["config", "user.name", "Pi WebUI Test"], remoteWork, "remote worktree should set a user name");
188
+ runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], remoteWork, "remote worktree should set a user email");
189
+ await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\n");
190
+ runGitFixture(["commit", "-am", "remote one"], remoteWork, "remote worktree should commit first incoming change");
191
+ await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\nremote two\n");
192
+ runGitFixture(["commit", "-am", "remote two"], remoteWork, "remote worktree should commit second incoming change");
193
+ runGitFixture(["push", "origin", "main"], remoteWork, "remote worktree should push incoming commits");
194
+ runGitFixture(["fetch", "origin"], localRepo, "local repo should fetch incoming commits");
195
+
196
+ const remoteTab = await request("127.0.0.1", "/api/tabs", { method: "POST", body: { cwd: localRepo, title: "remote-behind-fixture" } });
197
+ assert.equal(remoteTab.status, 201);
198
+ const remoteTabId = remoteTab.body?.data?.tab?.id;
199
+ assert.ok(remoteTabId, "remote fixture tab should have an id");
200
+ const incomingChanges = await request("127.0.0.1", `/api/git-changes?tab=${encodeURIComponent(remoteTabId)}`);
201
+ assert.equal(incomingChanges.status, 200);
202
+ assert.equal(incomingChanges.body?.ok, true, "git changes endpoint should load a fetched-behind repo");
203
+ assert.equal(incomingChanges.body?.data?.summary?.behind, 2, "git changes endpoint should report two fetched commits behind");
204
+ assert.equal(incomingChanges.body?.data?.remote?.canPull, true, "git changes endpoint should mark fetched commits as pullable");
205
+ assert.ok(incomingChanges.body?.data?.sections?.some((section) => section.key === "incoming"), "git changes endpoint should include an incoming diff section");
206
+
207
+ const pullIncoming = await request("127.0.0.1", "/api/git-changes/pull", { method: "POST", body: { tab: remoteTabId }, timeoutMs: 20_000 });
208
+ assert.equal(pullIncoming.status, 200);
209
+ assert.equal(pullIncoming.body?.ok, true, "pull endpoint should fast-forward fetched incoming commits");
210
+ assert.equal(pullIncoming.body?.data?.changes?.summary?.behind, 0, "pull endpoint should refresh changes with no remote commits left behind");
211
+
155
212
  const gitRemote = await request("127.0.0.1", "/api/git-workflow/remote", { method: "POST", body: { username: "Firstp1ck", repoName: "pi-webui-http-harness", tab: tabId } });
156
213
  assert.equal(gitRemote.status, 200);
157
214
  assert.equal(gitRemote.body?.ok, true, "remote endpoint should add origin without pushing");
@@ -219,6 +276,54 @@ try {
219
276
  assert.equal(clampedMessages.body?.data?.since, 3, "since beyond the transcript should clamp to the total count");
220
277
  assert.equal((clampedMessages.body?.data?.messages || []).length, 0);
221
278
 
279
+ // Custom app runners: save failures must be explicit, saved runners must be runnable,
280
+ // and stale saved runners must explain why they are not shown in the Run menu.
281
+ await writeFile(path.join(cwd, "custom-runner.mjs"), "console.log('custom runner ok')\n");
282
+ const missingCommandRunner = await request("127.0.0.1", "/api/app-runner-config", {
283
+ method: "POST",
284
+ body: { tab: tabId, runner: { label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" } },
285
+ });
286
+ assert.equal(missingCommandRunner.status, 400, "saving a custom runner with a missing command should fail visibly");
287
+ assert.match(String(missingCommandRunner.body?.error || ""), /Command is not available: definitely-missing-pi-webui-runner/);
288
+
289
+ const savedCustomRunner = await request("127.0.0.1", "/api/app-runner-config", {
290
+ method: "POST",
291
+ body: { tab: tabId, runner: { label: "Custom node", command: process.execPath, path: "custom-runner.mjs" } },
292
+ timeoutMs: 10_000,
293
+ });
294
+ assert.equal(savedCustomRunner.status, 200, `saving a valid custom runner should succeed: ${savedCustomRunner.body?.error || ""}`);
295
+ const customConfigRunner = savedCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Custom node");
296
+ assert.equal(customConfigRunner?.available, true, "saved custom runner config should mark runnable entries available");
297
+ const customRunner = savedCustomRunner.body?.data?.runners?.find((runner) => runner.custom === true && runner.label === "Custom node");
298
+ assert.ok(customRunner?.id, "saved available custom runner should appear in detected app runners");
299
+
300
+ const customRunStart = await request("127.0.0.1", "/api/app-runner", {
301
+ method: "POST",
302
+ body: { tab: tabId, runnerId: customRunner.id },
303
+ timeoutMs: 10_000,
304
+ });
305
+ assert.equal(customRunStart.status, 200, `custom runner start should return ok: ${customRunStart.body?.error || ""}`);
306
+ let customRunState = customRunStart;
307
+ for (let attempt = 0; attempt < 50; attempt++) {
308
+ if (customRunState.body?.data?.activeRun?.status && customRunState.body.data.activeRun.status !== "running") break;
309
+ await delay(100);
310
+ customRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
311
+ }
312
+ assert.equal(customRunState.body?.data?.activeRun?.status, "done", "custom runner should finish successfully");
313
+ assert.match((customRunState.body?.data?.activeRun?.lines || []).join("\n"), /custom runner ok/, "custom runner output should be captured");
314
+ await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
315
+
316
+ await writeFile(path.join(cwd, ".pi-webui-runners.json"), `${JSON.stringify({
317
+ version: 1,
318
+ runners: [{ id: "broken-custom", label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" }],
319
+ }, null, 2)}\n`);
320
+ const staleCustomRunner = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 10_000 });
321
+ assert.equal(staleCustomRunner.status, 200);
322
+ const brokenConfigRunner = staleCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Broken custom");
323
+ assert.equal(brokenConfigRunner?.available, false, "unavailable saved custom runners should be flagged in config data");
324
+ assert.match(String(brokenConfigRunner?.unavailableReason || ""), /Command is not available: definitely-missing-pi-webui-runner/);
325
+ assert.equal(staleCustomRunner.body?.data?.runners?.some((runner) => runner.label === "Broken custom"), false, "unavailable custom runners should not appear in runnable menu data");
326
+
222
327
  // Native slash command routed through the adapter (/copy → get_last_assistant_text).
223
328
  const copy = await request("127.0.0.1", "/api/prompt", {
224
329
  method: "POST",
@@ -346,16 +346,21 @@ assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable fo
346
346
  assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
347
347
  assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
348
348
  assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
349
- assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh controls and a diff body");
349
+ assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesPullButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh, pull controls, and a diff body");
350
350
  assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
351
351
  assert.match(app, /async function loadGitChangesDialog[\s\S]*api\("\/api\/git-changes"/, "git changes modal should load diff data from the server endpoint");
352
+ assert.match(app, /async function pullGitChangesDialog\(\)[\s\S]*api\("\/api\/git-changes\/pull", \{ method: "POST"/, "git changes modal should post to the pull endpoint from the Pull button");
353
+ assert.match(app, /function gitDiffDisplayLine\(row, side\)[\s\S]*`-\$\{text\}`[\s\S]*`\+\$\{text\}`/, "git changes modal should render changed lines with +/- prefixes");
352
354
  assert.match(app, /function gitUntrackedEntryToDiffFile\(entry\)[\s\S]*?renderRowLimit:\s*Number\.POSITIVE_INFINITY[\s\S]*?type: "added"/, "untracked files should render as complete added-file diffs without the row preview cap");
353
355
  assert.match(app, /async function loadMissingGitUntrackedContent\(entry[\s\S]*?\/api\/git-changes\/untracked-file\?path=/, "untracked path-only payloads should fetch complete file contents instead of rendering as empty files");
354
356
  assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySelectorAll\("\.git-diff-file\[data-git-diff-file\]"\)/, "git changes modal should derive the sticky current-file header from visible file cards");
355
357
  assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
356
358
  assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
357
- assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?\["diff", "--cached"/, "server should collect both staged and unstaged git diffs for the changes modal");
359
+ assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
360
+ assert.match(server, /\["status", "--porcelain=2", "--branch", "--untracked-files=all"\][\s\S]*?summarizeGitPorcelainStatus\(porcelainStatusText\)/, "server should derive behind/ahead from locale-independent porcelain status so the Pull button activates after fetch");
361
+ assert.match(server, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
358
362
  assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
363
+ assert.match(server, /url\.pathname === "\/api\/git-changes\/pull" && req\.method === "POST"[\s\S]*?pullGitChanges\(tab\.cwd\)/, "server should expose POST /api/git-changes/pull for the changes modal Pull button");
359
364
  assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.footer-line-meta \{[\s\S]*?display:\s*flex;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?\.footer-workspace,\n\s+\.footer-model,\n\s+\.footer-thinking \{ grid-column:\s*auto; \}/, "narrow git-footer metadata should wrap like the top metric row instead of forcing a two-column grid");
360
365
  assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.context-meter-bar \{ display:\s*none !important; \}/, "mobile should hide the WebUI context meter that appears after high context usage");
361
366
  assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
@@ -428,8 +433,9 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
428
433
  assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
429
434
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
430
435
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
431
- assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Controls should expose the remote PIN auth toggle");
432
- assert.match(app, /api\("\/api\/remote-auth\/settings", \{ method: "POST"/, "remote PIN auth toggle should call the settings endpoint");
436
+ assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Remote WebUI controls should bind the remote PIN auth toggle");
437
+ assert.match(html, /id="networkControlField"[^>]*hidden/, "Remote WebUI browser controls should be hidden until the optional package is loaded and enabled");
438
+ assert.match(app, /remoteWebuiCommand\(enable \? "authOn" : "authOff"/, "remote PIN auth toggle should dispatch through the Remote WebUI package command");
433
439
  assert.match(server, /function webuiSettingsFile\(\)[\s\S]*pi-webui[\s\S]*settings\.json/, "server should persist Web UI settings under a pi-webui settings file");
434
440
  assert.match(server, /let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled\(\)/, "server should load the saved Remote PIN auth preference before startup");
435
441
  assert.match(server, /if \(remoteAuthStartupEnabled\(\)\) enableRemoteAuth\(remoteAuthStartupReason\(\)\)/, "saved Remote PIN auth preference should enable auth on startup");
@@ -437,7 +443,7 @@ assert.match(server, /await saveRemoteAuthPreference\(true\)/, "enabling Remote
437
443
  assert.match(server, /await saveRemoteAuthPreference\(false\)/, "disabling Remote PIN auth should persist the off preference");
438
444
  assert.match(server, /function pinFromHash\(\)[\s\S]*new URLSearchParams\(String\(window\.location\.hash \|\| ""\)\.replace\(\/\^#\/, ""\)\)/, "remote auth page should read QR-provided PINs from the URL fragment");
439
445
  assert.match(server, /window\.history\.replaceState\(null, "", window\.location\.pathname \+ \(window\.location\.search \|\| ""\)\)/, "remote auth page should scrub fragment PINs from the address bar before authenticating");
440
- assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
446
+ assert.match(app, /remoteWebuiCommand\(open \? "close" : "open"/, "network open\/close button should dispatch through the Remote WebUI package command");
441
447
  assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
442
448
  assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
443
449
  assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
@@ -452,7 +458,7 @@ assert.match(server, /cwdExplicit: false/, "server should track whether startup
452
458
  assert.match(server, /return options\.cwdExplicit \? \[await createTab\(\)\] : \[\]/, "server should wait for UI cwd selection when no --cwd is supplied");
453
459
  assert.match(server, /async function resolvedPiCliScript\(\)[\s\S]*require\.resolve\.paths\(PI_CODING_AGENT_PACKAGE\)[\s\S]*nodeModulesRoot[\s\S]*dist[\s\S]*cli\.js/, "server should resolve the bundled Pi CLI through Node resolution roots so hoisted global installs can spawn RPC tabs");
454
460
  assert.match(server, /const bundledCli = await resolvedPiCliScript\(\)/, "standalone server should prefer the resolved Pi CLI script before falling back to PATH pi");
455
- assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(\["update"\]\)/, "explicit --pi JavaScript launchers should also work for update commands");
461
+ assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(updateArgs\)/, "explicit --pi JavaScript launchers should also work for update commands");
456
462
  assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
457
463
  assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
458
464
  assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
@@ -462,10 +468,12 @@ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)
462
468
  assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
463
469
  assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
464
470
  assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
465
- assert.match(server, /async function resolveUpdateTasks\(\)[\s\S]*currentWebuiPackageUpdateTask\(\)[\s\S]*agentPackageRootUpdateTask\(\)[\s\S]*npmGlobalPackageRootUpdateTask\(\)[\s\S]*bunGlobalPackageRootUpdateTask\(\)/, "server update should include current, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots");
466
- assert.match(server, /function packageInstallSpecs\(packageNames\)[\s\S]*`\$\{packageName\}@latest`/, "server package update tasks should force latest Web UI/Pi package specs instead of staying inside stale semver ranges");
467
- assert.match(app, /Run Pi\/Web UI package updates now\?/, "frontend update confirmation should describe the broader package update set");
468
- assert.match(readme, /detected local\/global Web UI and Pi package-manager updates/, "README should document that update refreshes local and global Web UI\/Pi package roots");
471
+ assert.match(server, /const updateArgs = all \? \["update", "--all"\] : \["update"\]/, "server update should use pi update by default and pi update --all for package-inclusive updates");
472
+ assert.match(server, /async function resolveUpdateTasks\(\{ all = false \} = \{\}\)[\s\S]*await resolvePiUpdateCommand\(\{ all \}\)/, "server update should resolve a single Pi update command with the selected all mode");
473
+ assert.match(app, /const command = all \? "pi update --all" : "pi update"/, "frontend update confirmation should describe self-only and all update commands");
474
+ assert.match(app, /api\(all \? "\/api\/update\?all=1" : "\/api\/update"/, "frontend all update should call the explicit all-mode endpoint");
475
+ assert.match(html, /<option value="update-all">Update Pi \+ Packages &amp; Restart<\/option>/, "side panel should expose pi update --all as a separate server action");
476
+ assert.match(readme, /`pi update` for Pi-only updates[\s\S]*`pi update --all` for Pi plus configured packages/, "README should document self-only and all update modes");
469
477
  assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
470
478
  assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
471
479
  assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
@@ -622,8 +630,9 @@ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-
622
630
  assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
623
631
  assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
624
632
  assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)[\s\S]*hasAvailableCommand\("remote"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
625
- assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand/, "Options menu should show Open Remote only when /remote is loaded and enabled");
633
+ assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Options menu and browser controls should show Remote WebUI only when /remote is loaded and enabled");
626
634
  assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
635
+ assert.match(app, /REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick\.pi-package-remote-webui\.controls"/, "Remote WebUI package should announce browser controls through a package-owned status payload");
627
636
  assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
628
637
  assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
629
638
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
@@ -670,6 +679,9 @@ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided
670
679
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
671
680
  assert.match(app, /async function refreshAppRunners\(tabContext = activeTabContext\(\)\)/, "frontend should load detected app runners for the active tab cwd");
672
681
  assert.match(app, /function renderAppRunnerWidget\(\)/, "frontend should render app runner output in the shared top widget area");
682
+ assert.match(app, /function appRunnerFailureState\(runnerId, error[\s\S]*failed to start app runner/, "frontend should render visible app-runner start failures instead of only logging them");
683
+ assert.match(app, /appRunnerCustomFeedback[\s\S]*Custom app runner was not saved/, "custom app-runner save failures should be shown inline in the dialog");
684
+ assert.match(server, /function customAppRunnerUnavailableReason\(projectRoot, runner\)[\s\S]*Command is not available/, "server should explain why saved custom app runners are unavailable");
673
685
  assert.match(server, /url\.pathname === "\/api\/app-runners" && req\.method === "GET"/, "server should expose detected app runners for the active tab cwd");
674
686
  assert.match(server, /url\.pathname === "\/api\/app-runner" && req\.method === "POST"/, "server should start selected app runners directly");
675
687
  assert.match(server, /function addGoRunner\(runners, cwd\)[\s\S]*Go\/Golang app entry/, "server should detect Go\/Golang app runners");