@firstpick/pi-package-webui 0.4.6 → 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/bin/pi-webui.mjs CHANGED
@@ -640,7 +640,8 @@ function makeHttpError(statusCode, message) {
640
640
  }
641
641
 
642
642
  function sendError(res, statusCode, error) {
643
- sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
643
+ const message = statusCode >= 500 ? sanitizeError(error) : formatCliError(error);
644
+ sendJson(res, statusCode, { ok: false, error: message });
644
645
  }
645
646
 
646
647
  function formatBytes(bytes) {
@@ -1627,22 +1628,85 @@ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } =
1627
1628
  return { id, label, command, path: filePath, args };
1628
1629
  }
1629
1630
 
1630
- async function readAppRunnerConfig(projectRoot) {
1631
+ function customAppRunnerDiagnostic(severity, message, runner = {}) {
1632
+ const source = runner && typeof runner === "object" ? runner : {};
1633
+ return {
1634
+ severity,
1635
+ message,
1636
+ runnerId: source.id || "",
1637
+ runnerLabel: source.label || "",
1638
+ path: source.path || source.projectFile || "",
1639
+ };
1640
+ }
1641
+
1642
+ function directCustomRunnerUnavailableReason(filePath, stats) {
1643
+ if (process.platform !== "win32" && stats && (stats.mode & 0o111) === 0) {
1644
+ return `Path is not executable: ${filePath}. Run chmod +x ${filePath} or set Command to bash, python3, node, etc.`;
1645
+ }
1646
+ return "";
1647
+ }
1648
+
1649
+ async function customAppRunnerUnavailableReason(projectRoot, runner) {
1650
+ const filePath = runner.path;
1651
+ let stats;
1652
+ try {
1653
+ stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, filePath));
1654
+ } catch (error) {
1655
+ return `Cannot access path ${filePath}: ${formatCliError(error)}`;
1656
+ }
1657
+ if (!stats?.isFile()) return `Path to file does not exist: ${filePath}`;
1658
+ const command = cleanCustomRunnerCommand(runner.command);
1659
+ const directReason = command === "./" ? directCustomRunnerUnavailableReason(filePath, stats) : "";
1660
+ if (directReason) return directReason;
1661
+ const commandParts = customRunnerCommandParts(command);
1662
+ if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return `Command is not available: ${commandParts[0]}`;
1663
+ return "";
1664
+ }
1665
+
1666
+ async function readAppRunnerConfig(projectRoot, { strictRead = false } = {}) {
1631
1667
  const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
1632
- const parsed = await readJsonFileIfExists(configPath);
1633
- const source = parsed && typeof parsed === "object" ? parsed : {};
1668
+ let source = {};
1669
+ const diagnostics = [];
1670
+ try {
1671
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
1672
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1673
+ source = parsed;
1674
+ } else {
1675
+ const message = `${APP_RUNNER_CONFIG_FILE} must contain a JSON object`;
1676
+ if (strictRead) throw makeHttpError(400, message);
1677
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1678
+ }
1679
+ } catch (error) {
1680
+ if (error?.code !== "ENOENT") {
1681
+ const message = `Cannot read ${APP_RUNNER_CONFIG_FILE}: ${formatCliError(error)}`;
1682
+ if (strictRead) throw makeHttpError(400, message);
1683
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1684
+ console.warn(`failed to read custom app runner config ${configPath}: ${sanitizeError(error)}`);
1685
+ }
1686
+ }
1687
+ if (source.runners !== undefined && !Array.isArray(source.runners)) {
1688
+ const message = `${APP_RUNNER_CONFIG_FILE} runners must be an array`;
1689
+ if (strictRead) throw makeHttpError(400, message);
1690
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1691
+ }
1634
1692
  const rawRunners = Array.isArray(source.runners) ? source.runners : [];
1635
1693
  const runners = [];
1636
1694
  for (const raw of rawRunners) {
1637
1695
  try {
1638
1696
  const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
1639
- if (!runners.some((item) => item.id === runner.id)) runners.push(runner);
1697
+ if (runners.some((item) => item.id === runner.id)) {
1698
+ diagnostics.push(customAppRunnerDiagnostic("warning", `Duplicate custom runner ignored: ${runner.label || runner.path || runner.id}`, runner));
1699
+ } else {
1700
+ runners.push(runner);
1701
+ }
1640
1702
  } catch (error) {
1703
+ const message = `Invalid custom runner ignored: ${formatCliError(error)}`;
1704
+ diagnostics.push(customAppRunnerDiagnostic("error", message, raw));
1641
1705
  console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
1642
1706
  }
1643
1707
  if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
1644
1708
  }
1645
- return { projectRoot, configPath, runners };
1709
+ return { projectRoot, configPath, runners, diagnostics };
1646
1710
  }
1647
1711
 
1648
1712
  async function writeAppRunnerConfig(projectRoot, runners) {
@@ -1660,15 +1724,12 @@ async function writeAppRunnerConfig(projectRoot, runners) {
1660
1724
 
1661
1725
  async function customAppRunnerCandidate(projectRoot, configPath, runner) {
1662
1726
  const filePath = runner.path;
1663
- const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
1664
- const stats = await fileStatsIfExists(absolutePath);
1665
- if (!stats?.isFile()) return null;
1727
+ if (await customAppRunnerUnavailableReason(projectRoot, runner)) return null;
1666
1728
  const command = cleanCustomRunnerCommand(runner.command);
1667
1729
  const args = parseCustomRunnerArgs(runner.args);
1668
1730
  const commandParts = customRunnerCommandParts(command);
1669
1731
  const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
1670
1732
  const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
1671
- if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
1672
1733
  return appRunnerCandidate({
1673
1734
  id: appRunnerId("custom", runner.id),
1674
1735
  label: runner.label || path.basename(filePath),
@@ -1696,24 +1757,32 @@ async function addCustomAppRunners(runners, cwd) {
1696
1757
  async function getCustomAppRunnerConfigData(tab) {
1697
1758
  const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1698
1759
  const config = await readAppRunnerConfig(projectRoot);
1760
+ const runners = [];
1761
+ for (const runner of config.runners) {
1762
+ const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, runner);
1763
+ runners.push({
1764
+ ...publicCustomRunnerDefinition(runner),
1765
+ available: !unavailableReason,
1766
+ unavailableReason,
1767
+ });
1768
+ }
1699
1769
  return {
1700
1770
  projectRoot,
1701
1771
  displayProjectRoot: displayPath(projectRoot),
1702
1772
  configFile: config.configPath,
1703
1773
  displayConfigFile: displayPath(config.configPath),
1704
1774
  relativeConfigFile: APP_RUNNER_CONFIG_FILE,
1705
- runners: config.runners.map(publicCustomRunnerDefinition),
1775
+ runners,
1776
+ diagnostics: config.diagnostics,
1706
1777
  };
1707
1778
  }
1708
1779
 
1709
1780
  async function saveCustomAppRunner(tab, rawRunner) {
1710
1781
  const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1711
- const config = await readAppRunnerConfig(projectRoot);
1782
+ const config = await readAppRunnerConfig(projectRoot, { strictRead: true });
1712
1783
  const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
1713
- const stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, normalized.path));
1714
- if (!stats?.isFile()) throw makeHttpError(400, `Path to file does not exist: ${normalized.path}`);
1715
- const commandParts = customRunnerCommandParts(normalized.command);
1716
- if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
1784
+ const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, normalized);
1785
+ if (unavailableReason) throw makeHttpError(400, unavailableReason);
1717
1786
  const runners = config.runners.filter((runner) => runner.id !== normalized.id);
1718
1787
  if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
1719
1788
  runners.push(normalized);
@@ -3066,6 +3135,7 @@ async function getWorkspaceInfo(cwd, startedAt) {
3066
3135
  let activeGitWorkflowProcess = null;
3067
3136
  const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
3068
3137
  const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
3138
+ const GIT_PULL_TIMEOUT_MS = 15 * 60 * 1000;
3069
3139
 
3070
3140
  async function getGitRoot(cwd) {
3071
3141
  const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
@@ -3176,15 +3246,61 @@ async function readGitUntrackedFile(cwd, requestedPath) {
3176
3246
  return readGitUntrackedEntry(root, normalized);
3177
3247
  }
3178
3248
 
3249
+ async function gitUpstreamRef(root) {
3250
+ try {
3251
+ const upstream = await runGitReadCommand(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { timeoutMs: 5000, maxOutputLength: 10_000 });
3252
+ return upstream.trim();
3253
+ } catch {
3254
+ return "";
3255
+ }
3256
+ }
3257
+
3258
+ async function readGitIncomingChanges(root, summary) {
3259
+ const upstream = await gitUpstreamRef(root);
3260
+ const remote = {
3261
+ upstream,
3262
+ behind: Number(summary?.behind || 0) || 0,
3263
+ canPull: !!upstream && (Number(summary?.behind || 0) || 0) > 0,
3264
+ };
3265
+ if (!remote.canPull) return { remote, section: null };
3266
+
3267
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", "HEAD..@{upstream}"];
3268
+ try {
3269
+ const diff = await runGitReadCommand(root, diffArgs);
3270
+ return {
3271
+ remote,
3272
+ section: {
3273
+ key: "incoming",
3274
+ label: `Incoming from ${upstream}`,
3275
+ command: `git diff --unified=0 HEAD..${upstream}`,
3276
+ diff: diff.trimEnd(),
3277
+ },
3278
+ };
3279
+ } catch (error) {
3280
+ return { remote: { ...remote, error: sanitizeError(error) }, section: null };
3281
+ }
3282
+ }
3283
+
3284
+ async function pullGitChanges(cwd) {
3285
+ const root = await getGitRoot(cwd);
3286
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["pull", "--ff-only"], { cwd: root, timeoutMs: GIT_PULL_TIMEOUT_MS }));
3287
+ if (payload.data) payload.data.root = root;
3288
+ if (payload.ok) payload.data.changes = await readGitChanges(root);
3289
+ else payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || "git pull --ff-only failed").trim();
3290
+ return payload;
3291
+ }
3292
+
3179
3293
  async function readGitChanges(cwd) {
3180
3294
  const root = await getGitRoot(cwd);
3181
- const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
3295
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
3182
3296
  const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
3183
3297
  runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
3184
3298
  runGitReadCommand(root, diffArgs),
3185
- runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
3299
+ runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
3186
3300
  runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
3187
3301
  ]);
3302
+ const summary = summarizeGitShortStatus(statusText);
3303
+ const incoming = await readGitIncomingChanges(root, summary);
3188
3304
  const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3189
3305
  const untracked = await readGitUntrackedEntries(root, untrackedFiles);
3190
3306
  return {
@@ -3192,12 +3308,14 @@ async function readGitChanges(cwd) {
3192
3308
  root,
3193
3309
  branch: gitBranchFromStatus(statusText),
3194
3310
  generatedAt: new Date().toISOString(),
3195
- summary: summarizeGitShortStatus(statusText),
3311
+ summary,
3312
+ remote: incoming.remote,
3196
3313
  status: statusText.trimEnd(),
3197
3314
  sections: [
3198
- { key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
3199
- { key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
3200
- ],
3315
+ incoming.section,
3316
+ { key: "staged", label: "Staged", command: "git diff --cached --unified=0", diff: stagedDiff.trimEnd() },
3317
+ { key: "unstaged", label: "Unstaged", command: "git diff --unified=0", diff: unstagedDiff.trimEnd() },
3318
+ ].filter(Boolean),
3201
3319
  untracked,
3202
3320
  };
3203
3321
  }
@@ -7572,6 +7690,17 @@ const server = createServer(async (req, res) => {
7572
7690
  return;
7573
7691
  }
7574
7692
 
7693
+ if (url.pathname === "/api/git-changes/pull" && req.method === "POST") {
7694
+ const body = await readJsonBody(req);
7695
+ const tab = getRequestedTab(req, url, body);
7696
+ try {
7697
+ sendJson(res, 200, await pullGitChanges(tab.cwd));
7698
+ } catch (error) {
7699
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
7700
+ }
7701
+ return;
7702
+ }
7703
+
7575
7704
  if (url.pathname === "/api/git-branches" && req.method === "GET") {
7576
7705
  const tab = getRequestedTab(req, url);
7577
7706
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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.5"
56
+ "@earendil-works/pi-coding-agent": "^0.79.7"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "@firstpick/pi-extension-btw": "^0.1.0",
@@ -66,7 +66,7 @@
66
66
  "@firstpick/pi-extension-todo-progress": "^0.2.4",
67
67
  "@firstpick/pi-extension-tools": "^0.1.6",
68
68
  "@firstpick/pi-extension-workflows": "^0.1.0",
69
- "@firstpick/pi-package-remote-webui": "^0.1.0",
69
+ "@firstpick/pi-package-remote-webui": "^0.1.2",
70
70
  "@firstpick/pi-prompts-git-pr": "^0.1.2",
71
71
  "@firstpick/pi-themes-bundle": "^0.1.4"
72
72
  },
@@ -84,5 +84,12 @@
84
84
  ],
85
85
  "engines": {
86
86
  "node": ">=22.19.0"
87
+ },
88
+ "overrides": {
89
+ "@earendil-works/pi-coding-agent": {
90
+ "protobufjs": "7.6.3",
91
+ "undici": "8.5.0",
92
+ "ws": "8.21.0"
93
+ }
87
94
  }
88
95
  }
package/public/app.js CHANGED
@@ -114,6 +114,7 @@ const elements = {
114
114
  gitChangesStatus: $("#gitChangesStatus"),
115
115
  gitChangesBody: $("#gitChangesBody"),
116
116
  gitChangesRefreshButton: $("#gitChangesRefreshButton"),
117
+ gitChangesPullButton: $("#gitChangesPullButton"),
117
118
  gitChangesCloseButton: $("#gitChangesCloseButton"),
118
119
  modelSelect: $("#modelSelect"),
119
120
  setModelButton: $("#setModelButton"),
@@ -265,7 +266,7 @@ let foregroundReconcileTimer = null;
265
266
  let eventSource = null;
266
267
  let activeDialog = null;
267
268
  let activeGitPrDialogResolve = null;
268
- let gitChangesState = { loading: false, error: "", data: null, tabId: null };
269
+ let gitChangesState = { loading: false, pulling: false, error: "", message: "", data: null, tabId: null };
269
270
  let gitChangesRequestSerial = 0;
270
271
  const gitChangesUntrackedContentRequests = new Set();
271
272
  let nativeCommandTabId = null;
@@ -285,6 +286,7 @@ let appRunnerMenuOpen = false;
285
286
  let busyPromptBehaviorMenuOpen = false;
286
287
  const skillUsageByTab = new Map();
287
288
  let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
289
+ let appRunnerCustomFeedback = { type: "", message: "" };
288
290
  let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
289
291
  let optionsMenuOpen = false;
290
292
  let availableCommands = [];
@@ -6382,13 +6384,23 @@ function renderGitChangesOverview(data) {
6382
6384
  return overview;
6383
6385
  }
6384
6386
 
6387
+ function gitDiffDisplayLine(row, side) {
6388
+ const type = row.type || "context";
6389
+ if (side === "old") {
6390
+ const text = row.left ?? "";
6391
+ return row.oldNumber !== "" && (type === "removed" || type === "changed") ? `-${text}` : text;
6392
+ }
6393
+ const text = row.right ?? "";
6394
+ return row.newNumber !== "" && (type === "added" || type === "changed") ? `+${text}` : text;
6395
+ }
6396
+
6385
6397
  function renderGitDiffRow(row) {
6386
6398
  const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
6387
6399
  node.append(
6388
6400
  make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
6389
- make("code", "git-diff-line old", row.left ?? ""),
6401
+ make("code", "git-diff-line old", gitDiffDisplayLine(row, "old")),
6390
6402
  make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
6391
- make("code", "git-diff-line new", row.right ?? ""),
6403
+ make("code", "git-diff-line new", gitDiffDisplayLine(row, "new")),
6392
6404
  );
6393
6405
  return node;
6394
6406
  }
@@ -6605,16 +6617,28 @@ function gitChangesGeneratedLabel(data) {
6605
6617
 
6606
6618
  function renderGitChangesDialog() {
6607
6619
  if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
6608
- const { loading, error, data } = gitChangesState;
6609
- if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
6610
- if (elements.gitChangesSubtitle) elements.gitChangesSubtitle.textContent = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
6620
+ const { loading, pulling, error, message, data } = gitChangesState;
6621
+ const behind = Number(data?.remote?.behind ?? data?.summary?.behind ?? 0) || 0;
6622
+ const canPull = behind > 0 && data?.remote?.canPull !== false;
6623
+ const remoteNotice = !error && data?.remote?.error ? `Incoming diff unavailable: ${data.remote.error}` : "";
6624
+ if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Git Changes";
6625
+ if (elements.gitChangesSubtitle) {
6626
+ const base = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
6627
+ elements.gitChangesSubtitle.textContent = data?.remote?.upstream ? `${base} · upstream ${data.remote.upstream}` : base;
6628
+ }
6611
6629
  if (elements.gitChangesRefreshButton) {
6612
- elements.gitChangesRefreshButton.disabled = loading;
6630
+ elements.gitChangesRefreshButton.disabled = loading || pulling;
6613
6631
  elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
6614
6632
  }
6633
+ if (elements.gitChangesPullButton) {
6634
+ elements.gitChangesPullButton.disabled = loading || pulling || !canPull;
6635
+ elements.gitChangesPullButton.textContent = pulling ? "Pulling…" : behind > 0 ? `Pull ↓${behind}` : "Pull";
6636
+ elements.gitChangesPullButton.title = canPull ? "Run git pull --ff-only for the current repository" : "No remote commits to pull";
6637
+ }
6615
6638
  if (elements.gitChangesStatus) {
6616
- elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
6617
- elements.gitChangesStatus.textContent = error || (loading ? "Loading git diff…" : data ? gitChangesGeneratedLabel(data) : "");
6639
+ const statusText = error || (pulling ? "Pulling changes…" : loading ? "Loading git diff…" : message || remoteNotice || (data ? gitChangesGeneratedLabel(data) : ""));
6640
+ elements.gitChangesStatus.className = `git-changes-status ${error || remoteNotice ? "error" : message ? "success" : "muted"}`;
6641
+ elements.gitChangesStatus.textContent = statusText;
6618
6642
  elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
6619
6643
  }
6620
6644
 
@@ -6624,7 +6648,7 @@ function renderGitChangesDialog() {
6624
6648
  body.append(make("div", "git-changes-empty", "Loading git diff…"));
6625
6649
  return;
6626
6650
  }
6627
- if (error) {
6651
+ if (error && !data) {
6628
6652
  body.append(make("div", "git-changes-empty error", error));
6629
6653
  return;
6630
6654
  }
@@ -6642,20 +6666,23 @@ function renderGitChangesDialog() {
6642
6666
  if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
6643
6667
  for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
6644
6668
  if (untracked.length) body.append(renderGitUntrackedSection(untracked));
6645
- if (!hasVisibleFiles) body.append(make("div", "git-changes-empty success", "Working tree is clean. No staged or unstaged diff."));
6669
+ if (!hasVisibleFiles) {
6670
+ const emptyMessage = behind > 0 ? "No textual incoming diff was available for the remote commits." : "Working tree is clean. No staged, unstaged, untracked, or incoming diff.";
6671
+ body.append(make("div", "git-changes-empty success", emptyMessage));
6672
+ }
6646
6673
  if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
6647
6674
  }
6648
6675
 
6649
6676
  async function loadGitChangesDialog(tabContext = activeTabContext()) {
6650
6677
  const requestSerial = ++gitChangesRequestSerial;
6651
6678
  gitChangesUntrackedContentRequests.clear();
6652
- gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
6679
+ gitChangesState = { ...gitChangesState, loading: true, error: "", message: "", tabId: tabContext.tabId || activeTabId };
6653
6680
  renderGitChangesDialog();
6654
6681
  try {
6655
6682
  const response = await api("/api/git-changes", { tabId: tabContext.tabId });
6656
6683
  if (requestSerial !== gitChangesRequestSerial) return;
6657
6684
  if (!response.ok) throw new Error(response.error || "Failed to load git changes");
6658
- gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
6685
+ gitChangesState = { loading: false, pulling: false, error: "", message: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
6659
6686
  } catch (error) {
6660
6687
  if (requestSerial !== gitChangesRequestSerial) return;
6661
6688
  gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
@@ -6668,7 +6695,7 @@ function openGitChangesDialog() {
6668
6695
  hideFooterTooltip();
6669
6696
  const tabContext = activeTabContext();
6670
6697
  const tabId = tabContext.tabId || activeTabId;
6671
- gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
6698
+ gitChangesState = { loading: true, pulling: false, error: "", message: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
6672
6699
  renderGitChangesDialog();
6673
6700
  if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
6674
6701
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
@@ -6679,10 +6706,46 @@ function refreshGitChangesDialog() {
6679
6706
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
6680
6707
  }
6681
6708
 
6709
+ async function pullGitChangesDialog() {
6710
+ const tabContext = { tabId: gitChangesState.tabId || activeTabId };
6711
+ const behind = Number(gitChangesState.data?.remote?.behind ?? gitChangesState.data?.summary?.behind ?? 0) || 0;
6712
+ if (behind <= 0 || gitChangesState.pulling || gitChangesState.loading) return;
6713
+ const root = gitChangesState.data?.root || "the current repository";
6714
+ if (!window.confirm(`Run git pull --ff-only in ${root}?`)) return;
6715
+
6716
+ const requestSerial = ++gitChangesRequestSerial;
6717
+ gitChangesState = { ...gitChangesState, pulling: true, loading: false, error: "", message: "", tabId: tabContext.tabId };
6718
+ renderGitChangesDialog();
6719
+ try {
6720
+ const response = await api("/api/git-changes/pull", { method: "POST", body: {}, tabId: tabContext.tabId });
6721
+ if (requestSerial !== gitChangesRequestSerial) return;
6722
+ if (!response.ok) {
6723
+ const detail = [response.error, response.data?.stderr || response.data?.stdout].filter(Boolean).join("\n").trim();
6724
+ throw new Error(detail || "Failed to pull git changes");
6725
+ }
6726
+ const output = String(response.data?.stdout || response.data?.stderr || "").trim();
6727
+ gitChangesState = {
6728
+ loading: false,
6729
+ pulling: false,
6730
+ error: "",
6731
+ message: output || "Pulled remote changes successfully.",
6732
+ data: response.data?.changes || gitChangesState.data,
6733
+ tabId: tabContext.tabId,
6734
+ };
6735
+ addEvent("Pulled remote git changes.", "success");
6736
+ requestGitFooterWebuiPayload(tabContext, { force: true });
6737
+ } catch (error) {
6738
+ if (requestSerial !== gitChangesRequestSerial) return;
6739
+ gitChangesState = { ...gitChangesState, pulling: false, error: error.message || String(error) };
6740
+ addEvent(error.message || String(error), "error");
6741
+ }
6742
+ renderGitChangesDialog();
6743
+ }
6744
+
6682
6745
  function closeGitChangesDialog() {
6683
6746
  gitChangesRequestSerial += 1;
6684
6747
  gitChangesUntrackedContentRequests.clear();
6685
- gitChangesState = { ...gitChangesState, loading: false };
6748
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
6686
6749
  if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
6687
6750
  }
6688
6751
 
@@ -8477,6 +8540,29 @@ async function refreshAppRunners(tabContext = activeTabContext()) {
8477
8540
  renderWidgets();
8478
8541
  }
8479
8542
 
8543
+ function appRunnerFailureState(runnerId, error, data = activeAppRunnerData()) {
8544
+ const runners = Array.isArray(data.runners) ? data.runners : [];
8545
+ const runner = runners.find((item) => item.id === runnerId) || {};
8546
+ const message = cleanStatusText(error?.message || String(error) || "Unknown app runner error");
8547
+ const command = runner.displayCommand || runner.shortDisplayCommand || runner.label || runnerId || "app runner";
8548
+ const timestamp = new Date().toISOString();
8549
+ return {
8550
+ id: `start-error:${Date.now()}`,
8551
+ runnerId,
8552
+ kind: runner.kind || "custom",
8553
+ label: runner.label || "App runner failed",
8554
+ command: runner.command || "",
8555
+ args: Array.isArray(runner.args) ? runner.args : [],
8556
+ displayCommand: command,
8557
+ cwd: data.cwd || "",
8558
+ status: "error",
8559
+ startedAt: timestamp,
8560
+ endedAt: timestamp,
8561
+ lineCount: 3,
8562
+ lines: [`$ ${command}`, "# failed to start app runner", `# ${message}`],
8563
+ };
8564
+ }
8565
+
8480
8566
  async function runAppRunner(runnerId) {
8481
8567
  const tabContext = activeTabContext();
8482
8568
  if (!tabContext.tabId || !runnerId) return;
@@ -8491,7 +8577,12 @@ async function runAppRunner(runnerId) {
8491
8577
  const command = response.data?.activeRun?.displayCommand || "app runner";
8492
8578
  addEvent(`started ${command}`, "info");
8493
8579
  } catch (error) {
8494
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8580
+ if (!isCurrentTabContext(tabContext)) return;
8581
+ const message = cleanStatusText(error.message || String(error));
8582
+ setAppRunnerData(tabContext.tabId, { activeRun: appRunnerFailureState(runnerId, error, activeAppRunnerData()) });
8583
+ renderAppRunnerControls();
8584
+ renderWidgets();
8585
+ addEvent(`app runner failed: ${message}`, "error");
8495
8586
  }
8496
8587
  }
8497
8588
 
@@ -8572,9 +8663,14 @@ function activeAppRunnerCustomConfig() {
8572
8663
  return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
8573
8664
  }
8574
8665
 
8575
- function resetAppRunnerCustomDraft() {
8666
+ function resetAppRunnerCustomDraft({ clearFeedback = true } = {}) {
8576
8667
  appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
8577
8668
  appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
8669
+ if (clearFeedback) appRunnerCustomFeedback = { type: "", message: "" };
8670
+ }
8671
+
8672
+ function setAppRunnerCustomFeedback(type, message) {
8673
+ appRunnerCustomFeedback = { type, message: cleanStatusText(message || "") };
8578
8674
  }
8579
8675
 
8580
8676
  function appRunnerRelativeDir(filePath) {
@@ -8634,8 +8730,10 @@ async function saveAppRunnerCustomRunner(form) {
8634
8730
  updateAppRunnerCustomDraftFrom(form);
8635
8731
  const payload = appRunnerCustomDraftPayload();
8636
8732
  if (!payload.path) {
8733
+ setAppRunnerCustomFeedback("warning", "Custom app runner path is required.");
8734
+ renderAppRunnerInfoDialog();
8735
+ requestAnimationFrame(() => document.querySelector("#appRunnerCustomPathInput")?.focus());
8637
8736
  addEvent("custom app runner path is required", "warn");
8638
- form?.querySelector("#appRunnerCustomPathInput")?.focus();
8639
8737
  return;
8640
8738
  }
8641
8739
  const tabContext = activeTabContext();
@@ -8643,13 +8741,18 @@ async function saveAppRunnerCustomRunner(form) {
8643
8741
  const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
8644
8742
  if (!isCurrentTabContext(tabContext)) return;
8645
8743
  setAppRunnerData(tabContext.tabId, response.data || {});
8646
- resetAppRunnerCustomDraft();
8744
+ resetAppRunnerCustomDraft({ clearFeedback: false });
8745
+ setAppRunnerCustomFeedback("success", "Saved custom app runner. It should now appear in the Run menu when available.");
8647
8746
  renderAppRunnerControls();
8648
8747
  renderWidgets();
8649
8748
  renderAppRunnerInfoDialog();
8650
8749
  addEvent("saved custom app runner", "info");
8651
8750
  } catch (error) {
8652
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8751
+ if (!isCurrentTabContext(tabContext)) return;
8752
+ const message = error.message || String(error);
8753
+ setAppRunnerCustomFeedback("error", `Custom app runner was not saved: ${message}`);
8754
+ renderAppRunnerInfoDialog();
8755
+ addEvent(`custom app runner was not saved: ${message}`, "error");
8653
8756
  }
8654
8757
  }
8655
8758
 
@@ -8659,13 +8762,18 @@ async function deleteAppRunnerCustomRunner(id) {
8659
8762
  const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
8660
8763
  if (!isCurrentTabContext(tabContext)) return;
8661
8764
  setAppRunnerData(tabContext.tabId, response.data || {});
8662
- if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
8765
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft({ clearFeedback: false });
8766
+ setAppRunnerCustomFeedback("success", "Deleted custom app runner.");
8663
8767
  renderAppRunnerControls();
8664
8768
  renderWidgets();
8665
8769
  renderAppRunnerInfoDialog();
8666
8770
  addEvent("deleted custom app runner", "warn");
8667
8771
  } catch (error) {
8668
- if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
8772
+ if (!isCurrentTabContext(tabContext)) return;
8773
+ const message = error.message || String(error);
8774
+ setAppRunnerCustomFeedback("error", `Custom app runner was not deleted: ${message}`);
8775
+ renderAppRunnerInfoDialog();
8776
+ addEvent(`custom app runner was not deleted: ${message}`, "error");
8669
8777
  }
8670
8778
  }
8671
8779
 
@@ -8750,9 +8858,10 @@ function renderAppRunnerCustomSection() {
8750
8858
  existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
8751
8859
  } else {
8752
8860
  for (const runner of customRunners) {
8753
- const row = make("div", "app-runner-custom-item");
8861
+ const row = make("div", `app-runner-custom-item${runner.available === false ? " unavailable" : ""}`);
8754
8862
  const details = make("div", "app-runner-custom-item-details");
8755
8863
  details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
8864
+ if (runner.unavailableReason) details.append(make("span", "app-runner-custom-warning", `Not available: ${runner.unavailableReason}`));
8756
8865
  const actions = make("div", "app-runner-custom-item-actions");
8757
8866
  const edit = make("button", "", "Edit");
8758
8867
  edit.type = "button";
@@ -8774,6 +8883,13 @@ function renderAppRunnerCustomSection() {
8774
8883
  }
8775
8884
  section.append(existing);
8776
8885
 
8886
+ const diagnostics = Array.isArray(config.diagnostics) ? config.diagnostics.filter((item) => item?.message) : [];
8887
+ if (diagnostics.length) {
8888
+ const diagnosticList = make("div", "app-runner-custom-diagnostics");
8889
+ for (const item of diagnostics) diagnosticList.append(make("div", `app-runner-custom-feedback ${item.severity || "warning"}`, item.message));
8890
+ section.append(diagnosticList);
8891
+ }
8892
+
8777
8893
  const form = make("div", "app-runner-custom-form");
8778
8894
  const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
8779
8895
  const commandField = appRunnerInputField({ id: "appRunnerCustomCommandInput", label: "Command", value: appRunnerCustomDraft.command || "./", placeholder: "./", hint: "Use ./ to execute the selected file directly, or use bash, python3, node, bun, uv run, etc." });
@@ -8798,6 +8914,7 @@ function renderAppRunnerCustomSection() {
8798
8914
  reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
8799
8915
  formActions.append(save, reset);
8800
8916
  form.append(formActions);
8917
+ if (appRunnerCustomFeedback.message) form.append(make("div", `app-runner-custom-feedback ${appRunnerCustomFeedback.type || "info"}`, appRunnerCustomFeedback.message));
8801
8918
  const browser = renderAppRunnerFileBrowser();
8802
8919
  if (browser) form.append(browser);
8803
8920
  section.append(form);
@@ -18713,6 +18830,7 @@ window.addEventListener("blur", () => {
18713
18830
  });
18714
18831
 
18715
18832
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
18833
+ elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
18716
18834
  elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
18717
18835
  elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
18718
18836
  elements.gitChangesDialog?.addEventListener("cancel", (event) => {
@@ -18721,7 +18839,7 @@ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
18721
18839
  });
18722
18840
  elements.gitChangesDialog?.addEventListener("close", () => {
18723
18841
  gitChangesRequestSerial += 1;
18724
- gitChangesState = { ...gitChangesState, loading: false };
18842
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
18725
18843
  });
18726
18844
 
18727
18845
  elements.refreshCodexUsageButton?.addEventListener("click", () => {
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
@@ -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;
@@ -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");