@firstpick/pi-package-webui 0.4.5 → 0.4.7

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
@@ -539,7 +539,10 @@
539
539
  <h2 id="gitChangesTitle">Uncommitted Changes</h2>
540
540
  <p id="gitChangesSubtitle" class="muted">Current tab git diff</p>
541
541
  </div>
542
- <button id="gitChangesRefreshButton" type="button">Refresh</button>
542
+ <div class="git-changes-actions">
543
+ <button id="gitChangesRefreshButton" type="button">Refresh</button>
544
+ <button id="gitChangesPullButton" class="primary" type="button" disabled>Pull</button>
545
+ </div>
543
546
  </div>
544
547
  <p id="gitChangesStatus" class="git-changes-status muted" role="status" aria-live="polite"></p>
545
548
  <div id="gitChangesBody" class="git-changes-body"></div>
package/public/styles.css CHANGED
@@ -1307,6 +1307,24 @@ body.side-panel-collapsed .terminal-tabs-shell {
1307
1307
  isolation: isolate;
1308
1308
  background: var(--ctp-crust);
1309
1309
  }
1310
+ .terminal-tab[draggable="true"],
1311
+ .terminal-tab-group-item[draggable="true"] {
1312
+ cursor: grab;
1313
+ }
1314
+ .terminal-tab.terminal-tab-dragging,
1315
+ .terminal-tab-group-item.terminal-tab-dragging {
1316
+ cursor: grabbing;
1317
+ opacity: 0.68;
1318
+ transform: scale(0.985);
1319
+ }
1320
+ .terminal-tab-custom-group {
1321
+ border-color: rgba(203, 166, 247, 0.34);
1322
+ }
1323
+ .terminal-tab-custom-group .terminal-tab-group-count {
1324
+ color: var(--ctp-mauve);
1325
+ border-color: rgba(203, 166, 247, 0.30);
1326
+ background: rgba(203, 166, 247, 0.12);
1327
+ }
1310
1328
  .terminal-tab-group:hover,
1311
1329
  .terminal-tab-group:focus-within {
1312
1330
  z-index: 100;
@@ -1432,6 +1450,15 @@ body.side-panel-collapsed .terminal-tabs-shell {
1432
1450
  border-color: rgba(166, 227, 161, 0.52);
1433
1451
  box-shadow: 0 0 1rem rgba(166, 227, 161, 0.16), inset 0 1px 0 rgba(255,255,255,0.045);
1434
1452
  }
1453
+ .terminal-tab.terminal-tab-drag-over,
1454
+ .terminal-tab-group-item.terminal-tab-drag-over {
1455
+ border-color: rgba(137, 180, 250, 0.82);
1456
+ box-shadow: 0 0 0 2px rgba(137, 180, 250, 0.34), 0 0 1.2rem rgba(137, 180, 250, 0.28), inset 0 1px 0 rgba(255,255,255,0.06);
1457
+ }
1458
+ .terminal-tab.terminal-tab-drag-over > .terminal-tab-button,
1459
+ .terminal-tab-group-item.terminal-tab-drag-over > .terminal-tab-button {
1460
+ background: linear-gradient(120deg, rgba(137, 180, 250, 0.18), rgba(203, 166, 247, 0.12));
1461
+ }
1435
1462
  .terminal-tab-button,
1436
1463
  .terminal-tab-close,
1437
1464
  .terminal-new-tab-button {
@@ -2267,6 +2294,14 @@ button.footer-meta {
2267
2294
  .git-changes-header > div:first-child {
2268
2295
  min-width: 0;
2269
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
+ }
2270
2305
  .git-changes-kicker {
2271
2306
  display: block;
2272
2307
  color: var(--ctp-yellow);
@@ -2287,6 +2322,7 @@ button.footer-meta {
2287
2322
  .git-changes-empty.error {
2288
2323
  color: var(--ctp-red);
2289
2324
  }
2325
+ .git-changes-status.success,
2290
2326
  .git-changes-empty.success {
2291
2327
  color: var(--ctp-green);
2292
2328
  }
@@ -5765,11 +5801,41 @@ button.composer-skill-tag:focus-visible {
5765
5801
  gap: 0.6rem;
5766
5802
  align-items: center;
5767
5803
  }
5804
+ .app-runner-custom-item.unavailable {
5805
+ border-color: rgba(243, 139, 168, 0.32);
5806
+ }
5768
5807
  .app-runner-custom-item-details {
5769
5808
  display: grid;
5770
5809
  gap: 0.18rem;
5771
5810
  min-width: 0;
5772
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
+ }
5773
5839
  .app-runner-custom-item-actions,
5774
5840
  .app-runner-custom-form-actions {
5775
5841
  display: flex;
File without changes
@@ -219,6 +219,54 @@ try {
219
219
  assert.equal(clampedMessages.body?.data?.since, 3, "since beyond the transcript should clamp to the total count");
220
220
  assert.equal((clampedMessages.body?.data?.messages || []).length, 0);
221
221
 
222
+ // Custom app runners: save failures must be explicit, saved runners must be runnable,
223
+ // and stale saved runners must explain why they are not shown in the Run menu.
224
+ await writeFile(path.join(cwd, "custom-runner.mjs"), "console.log('custom runner ok')\n");
225
+ const missingCommandRunner = await request("127.0.0.1", "/api/app-runner-config", {
226
+ method: "POST",
227
+ body: { tab: tabId, runner: { label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" } },
228
+ });
229
+ assert.equal(missingCommandRunner.status, 400, "saving a custom runner with a missing command should fail visibly");
230
+ assert.match(String(missingCommandRunner.body?.error || ""), /Command is not available: definitely-missing-pi-webui-runner/);
231
+
232
+ const savedCustomRunner = await request("127.0.0.1", "/api/app-runner-config", {
233
+ method: "POST",
234
+ body: { tab: tabId, runner: { label: "Custom node", command: process.execPath, path: "custom-runner.mjs" } },
235
+ timeoutMs: 10_000,
236
+ });
237
+ assert.equal(savedCustomRunner.status, 200, `saving a valid custom runner should succeed: ${savedCustomRunner.body?.error || ""}`);
238
+ const customConfigRunner = savedCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Custom node");
239
+ assert.equal(customConfigRunner?.available, true, "saved custom runner config should mark runnable entries available");
240
+ const customRunner = savedCustomRunner.body?.data?.runners?.find((runner) => runner.custom === true && runner.label === "Custom node");
241
+ assert.ok(customRunner?.id, "saved available custom runner should appear in detected app runners");
242
+
243
+ const customRunStart = await request("127.0.0.1", "/api/app-runner", {
244
+ method: "POST",
245
+ body: { tab: tabId, runnerId: customRunner.id },
246
+ timeoutMs: 10_000,
247
+ });
248
+ assert.equal(customRunStart.status, 200, `custom runner start should return ok: ${customRunStart.body?.error || ""}`);
249
+ let customRunState = customRunStart;
250
+ for (let attempt = 0; attempt < 50; attempt++) {
251
+ if (customRunState.body?.data?.activeRun?.status && customRunState.body.data.activeRun.status !== "running") break;
252
+ await delay(100);
253
+ customRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
254
+ }
255
+ assert.equal(customRunState.body?.data?.activeRun?.status, "done", "custom runner should finish successfully");
256
+ assert.match((customRunState.body?.data?.activeRun?.lines || []).join("\n"), /custom runner ok/, "custom runner output should be captured");
257
+ await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
258
+
259
+ await writeFile(path.join(cwd, ".pi-webui-runners.json"), `${JSON.stringify({
260
+ version: 1,
261
+ runners: [{ id: "broken-custom", label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" }],
262
+ }, null, 2)}\n`);
263
+ const staleCustomRunner = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 10_000 });
264
+ assert.equal(staleCustomRunner.status, 200);
265
+ const brokenConfigRunner = staleCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Broken custom");
266
+ assert.equal(brokenConfigRunner?.available, false, "unavailable saved custom runners should be flagged in config data");
267
+ assert.match(String(brokenConfigRunner?.unavailableReason || ""), /Command is not available: definitely-missing-pi-webui-runner/);
268
+ assert.equal(staleCustomRunner.body?.data?.runners?.some((runner) => runner.label === "Broken custom"), false, "unavailable custom runners should not appear in runnable menu data");
269
+
222
270
  // Native slash command routed through the adapter (/copy → get_last_assistant_text).
223
271
  const copy = await request("127.0.0.1", "/api/prompt", {
224
272
  method: "POST",
@@ -346,16 +346,20 @@ 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, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
358
361
  assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
362
+ 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
363
  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
364
  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
365
  assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
@@ -670,6 +674,9 @@ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided
670
674
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
671
675
  assert.match(app, /async function refreshAppRunners\(tabContext = activeTabContext\(\)\)/, "frontend should load detected app runners for the active tab cwd");
672
676
  assert.match(app, /function renderAppRunnerWidget\(\)/, "frontend should render app runner output in the shared top widget area");
677
+ 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");
678
+ assert.match(app, /appRunnerCustomFeedback[\s\S]*Custom app runner was not saved/, "custom app-runner save failures should be shown inline in the dialog");
679
+ assert.match(server, /function customAppRunnerUnavailableReason\(projectRoot, runner\)[\s\S]*Command is not available/, "server should explain why saved custom app runners are unavailable");
673
680
  assert.match(server, /url\.pathname === "\/api\/app-runners" && req\.method === "GET"/, "server should expose detected app runners for the active tab cwd");
674
681
  assert.match(server, /url\.pathname === "\/api\/app-runner" && req\.method === "POST"/, "server should start selected app runners directly");
675
682
  assert.match(server, /function addGoRunner\(runners, cwd\)[\s\S]*Go\/Golang app entry/, "server should detect Go\/Golang app runners");
@@ -943,7 +950,11 @@ assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\
943
950
  assert.match(app, /appendTerminalTabContent\(button, \{ title: activeTitle,[\s\S]*?count: groupTabs\.length \}\)/, "group buttons should show the active terminal name instead of only the cwd label");
944
951
  assert.match(app, /wrapper\.addEventListener\("pointerenter", \(\) => setOpenTerminalTabGroup\(group\.key\)\)/, "terminal tab groups should mark themselves open while hovered");
945
952
  assert.match(app, /if \(openTerminalTabGroupKey\) \{\n\s+scheduleRefreshTabs\(600\);/, "tab polling should defer full tab refreshes while a group menu is open");
946
- assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should only collapse cwd groups when multiple groups are available");
953
+ assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+if \(group\.custom\) return group\.tabs\.length > 1;\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should always render custom groups while only collapsing cwd groups when multiple groups are available");
954
+ assert.match(app, /TERMINAL_CUSTOM_GROUPS_STORAGE_KEY/, "frontend should persist custom terminal tab groups in browser storage");
955
+ assert.match(app, /function bindTerminalTabDragAndDrop\(/, "terminal tabs should bind drag-and-drop grouping behavior");
956
+ assert.match(app, /function handleTerminalTabDrop\(sourceTabId, target\)[\s\S]*?createTerminalCustomGroup/, "dropping a terminal tab onto another tab or group should create or update a custom group");
957
+ assert.match(css, /\.terminal-tab\.terminal-tab-drag-over,[\s\S]*?\.terminal-tab-group-item\.terminal-tab-drag-over/, "terminal tab drop targets should show drag-over affordance");
947
958
  assert.match(app, /function closeTerminalTabGroup\(group\)[\s\S]*?closeTerminalTabs\(group\.tabs\.map\(\(tab\) => tab\.id\)/, "terminal tab groups should be closable as a batch");
948
959
  assert.match(app, /function closeAllTerminalTabs\(\)[\s\S]*?closeTerminalTabs\(tabs\.map\(\(tab\) => tab\.id\)/, "tab header should close all terminal tabs as a batch");
949
960
  assert.match(app, /WARNING: \$\{activeAgentTabs\.length\}[\s\S]*?still running or waiting for input/, "tab close confirmations should warn when agents are still running");