@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/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;
@@ -276,12 +277,16 @@ let pathFastPicksReady = false;
276
277
  let pathFastPicksLoadPromise = null;
277
278
  let mobileTabsExpanded = false;
278
279
  let openTerminalTabGroupKey = null;
280
+ let terminalCustomGroups = new Map();
281
+ let terminalCustomGroupSerial = 1;
282
+ let terminalTabDragId = null;
279
283
  let newTabMenuOpen = false;
280
284
  let nativeCommandMenuOpen = false;
281
285
  let appRunnerMenuOpen = false;
282
286
  let busyPromptBehaviorMenuOpen = false;
283
287
  const skillUsageByTab = new Map();
284
288
  let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
289
+ let appRunnerCustomFeedback = { type: "", message: "" };
285
290
  let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
286
291
  let optionsMenuOpen = false;
287
292
  let availableCommands = [];
@@ -409,6 +414,8 @@ const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
409
414
  const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
410
415
  const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
411
416
  const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
417
+ const TERMINAL_CUSTOM_GROUPS_STORAGE_KEY = "pi-webui-terminal-custom-groups-v1";
418
+ const TERMINAL_TAB_DRAG_MIME = "application/x-pi-terminal-tab-id";
412
419
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
413
420
  const THEME_STORAGE_KEY = "pi-webui-theme";
414
421
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
@@ -1245,6 +1252,252 @@ function restoreTerminalTabsLayoutSetting() {
1245
1252
  setTerminalTabsLayout(readStoredTerminalTabsLayout(), { persist: false });
1246
1253
  }
1247
1254
 
1255
+ function normalizeTerminalCustomGroupTitle(value, fallback = "Custom group") {
1256
+ const title = String(value || "").replace(/\s+/g, " ").trim();
1257
+ return (title || fallback).slice(0, 40);
1258
+ }
1259
+
1260
+ function normalizeTerminalCustomGroupTabIds(value) {
1261
+ const ids = [];
1262
+ const seen = new Set();
1263
+ for (const item of Array.isArray(value) ? value : []) {
1264
+ const id = String(item || "").trim();
1265
+ if (!id || seen.has(id)) continue;
1266
+ seen.add(id);
1267
+ ids.push(id);
1268
+ }
1269
+ return ids;
1270
+ }
1271
+
1272
+ function nextTerminalCustomGroupId() {
1273
+ const id = `group-${Date.now().toString(36)}-${terminalCustomGroupSerial.toString(36)}`;
1274
+ terminalCustomGroupSerial += 1;
1275
+ return id;
1276
+ }
1277
+
1278
+ function restoreTerminalCustomGroups() {
1279
+ terminalCustomGroups = new Map();
1280
+ terminalCustomGroupSerial = 1;
1281
+ let parsed = null;
1282
+ try {
1283
+ parsed = JSON.parse(localStorage.getItem(TERMINAL_CUSTOM_GROUPS_STORAGE_KEY) || "null");
1284
+ } catch {
1285
+ return;
1286
+ }
1287
+ const records = Array.isArray(parsed?.groups) ? parsed.groups : Array.isArray(parsed) ? parsed : [];
1288
+ const usedTabIds = new Set();
1289
+ for (const record of records) {
1290
+ const rawId = String(record?.id || "").trim();
1291
+ const id = rawId && !terminalCustomGroups.has(rawId) ? rawId : nextTerminalCustomGroupId();
1292
+ const title = normalizeTerminalCustomGroupTitle(record?.title, `Group ${terminalCustomGroups.size + 1}`);
1293
+ const tabIds = normalizeTerminalCustomGroupTabIds(record?.tabIds).filter((tabId) => {
1294
+ if (usedTabIds.has(tabId)) return false;
1295
+ usedTabIds.add(tabId);
1296
+ return true;
1297
+ });
1298
+ if (tabIds.length < 2) continue;
1299
+ terminalCustomGroups.set(id, { id, title, tabIds });
1300
+ const serialMatch = /^Group\s+(\d+)$/i.exec(title);
1301
+ if (serialMatch) terminalCustomGroupSerial = Math.max(terminalCustomGroupSerial, Number(serialMatch[1]) + 1);
1302
+ }
1303
+ }
1304
+
1305
+ function persistTerminalCustomGroups() {
1306
+ try {
1307
+ const groups = [...terminalCustomGroups.values()].map((group) => ({
1308
+ id: group.id,
1309
+ title: normalizeTerminalCustomGroupTitle(group.title),
1310
+ tabIds: normalizeTerminalCustomGroupTabIds(group.tabIds),
1311
+ })).filter((group) => group.tabIds.length >= 2);
1312
+ localStorage.setItem(TERMINAL_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify({ version: 1, groups }));
1313
+ } catch {
1314
+ // Ignore storage failures; custom tab groups still work for this page load.
1315
+ }
1316
+ }
1317
+
1318
+ function terminalCustomGroupByTabId() {
1319
+ const map = new Map();
1320
+ for (const [groupId, group] of terminalCustomGroups) {
1321
+ for (const tabId of group.tabIds || []) {
1322
+ if (!map.has(tabId)) map.set(tabId, groupId);
1323
+ }
1324
+ }
1325
+ return map;
1326
+ }
1327
+
1328
+ function terminalCustomGroupIdForTab(tabId) {
1329
+ const id = String(tabId || "").trim();
1330
+ if (!id) return null;
1331
+ for (const [groupId, group] of terminalCustomGroups) {
1332
+ if (group.tabIds?.includes(id)) return groupId;
1333
+ }
1334
+ return null;
1335
+ }
1336
+
1337
+ function syncTerminalCustomGroupsWithTabs(currentTabs = tabs, { persist = true } = {}) {
1338
+ const validTabIds = new Set((currentTabs || []).map((tab) => tab.id).filter(Boolean));
1339
+ const claimedTabIds = new Set();
1340
+ let changed = false;
1341
+ for (const [groupId, group] of [...terminalCustomGroups]) {
1342
+ const filtered = [];
1343
+ for (const tabId of normalizeTerminalCustomGroupTabIds(group.tabIds)) {
1344
+ if (!validTabIds.has(tabId) || claimedTabIds.has(tabId)) {
1345
+ changed = true;
1346
+ continue;
1347
+ }
1348
+ claimedTabIds.add(tabId);
1349
+ filtered.push(tabId);
1350
+ }
1351
+ if (filtered.length < 2) {
1352
+ terminalCustomGroups.delete(groupId);
1353
+ changed = true;
1354
+ continue;
1355
+ }
1356
+ if (filtered.length !== group.tabIds.length || filtered.some((tabId, index) => tabId !== group.tabIds[index])) {
1357
+ group.tabIds = filtered;
1358
+ changed = true;
1359
+ }
1360
+ }
1361
+ if (changed && persist) persistTerminalCustomGroups();
1362
+ return changed;
1363
+ }
1364
+
1365
+ function removeTabsFromOtherTerminalCustomGroups(tabIds, keepGroupId = null) {
1366
+ const moving = new Set(normalizeTerminalCustomGroupTabIds(tabIds));
1367
+ if (!moving.size) return false;
1368
+ let changed = false;
1369
+ for (const [groupId, group] of [...terminalCustomGroups]) {
1370
+ if (groupId === keepGroupId) continue;
1371
+ const filtered = normalizeTerminalCustomGroupTabIds(group.tabIds).filter((tabId) => !moving.has(tabId));
1372
+ if (filtered.length === group.tabIds.length) continue;
1373
+ changed = true;
1374
+ if (filtered.length < 2) terminalCustomGroups.delete(groupId);
1375
+ else group.tabIds = filtered;
1376
+ }
1377
+ return changed;
1378
+ }
1379
+
1380
+ function createTerminalCustomGroup(tabIds) {
1381
+ const validTabIds = new Set(tabs.map((tab) => tab.id).filter(Boolean));
1382
+ const unique = normalizeTerminalCustomGroupTabIds(tabIds).filter((tabId) => validTabIds.has(tabId));
1383
+ if (unique.length < 2) return null;
1384
+ removeTabsFromOtherTerminalCustomGroups(unique);
1385
+ const title = `Group ${terminalCustomGroupSerial}`;
1386
+ const id = nextTerminalCustomGroupId();
1387
+ const group = { id, title, tabIds: unique };
1388
+ terminalCustomGroups.set(id, group);
1389
+ syncTerminalCustomGroupsWithTabs(tabs, { persist: false });
1390
+ persistTerminalCustomGroups();
1391
+ return terminalCustomGroups.get(id) || group;
1392
+ }
1393
+
1394
+ function addTabsToTerminalCustomGroup(groupId, tabIds) {
1395
+ const group = terminalCustomGroups.get(groupId);
1396
+ if (!group) return null;
1397
+ const validTabIds = new Set(tabs.map((tab) => tab.id).filter(Boolean));
1398
+ const unique = normalizeTerminalCustomGroupTabIds(tabIds).filter((tabId) => validTabIds.has(tabId) && !group.tabIds.includes(tabId));
1399
+ if (!unique.length) return group;
1400
+ removeTabsFromOtherTerminalCustomGroups(unique, groupId);
1401
+ group.tabIds = normalizeTerminalCustomGroupTabIds([...group.tabIds, ...unique]);
1402
+ syncTerminalCustomGroupsWithTabs(tabs, { persist: false });
1403
+ persistTerminalCustomGroups();
1404
+ return terminalCustomGroups.get(groupId) || null;
1405
+ }
1406
+
1407
+ function terminalTabById(tabId) {
1408
+ return tabs.find((tab) => tab.id === tabId) || null;
1409
+ }
1410
+
1411
+ function terminalTabDragIdFromEvent(event) {
1412
+ return event?.dataTransfer?.getData?.(TERMINAL_TAB_DRAG_MIME) || terminalTabDragId || "";
1413
+ }
1414
+
1415
+ function canDropTerminalTabOnTarget(sourceTabId, target) {
1416
+ if (!sourceTabId || !terminalTabById(sourceTabId) || !target) return false;
1417
+ if (target.type === "group") {
1418
+ const groupTabs = target.group?.tabs || [];
1419
+ return groupTabs.length > 0 && !groupTabs.some((tab) => tab.id === sourceTabId);
1420
+ }
1421
+ return Boolean(target.tabId && target.tabId !== sourceTabId && terminalTabById(target.tabId));
1422
+ }
1423
+
1424
+ function clearTerminalTabDragState() {
1425
+ terminalTabDragId = null;
1426
+ document.body.classList.remove("terminal-tab-dragging");
1427
+ elements.tabBar?.querySelectorAll(".terminal-tab-dragging, .terminal-tab-drag-over").forEach((item) => {
1428
+ item.classList.remove("terminal-tab-dragging", "terminal-tab-drag-over");
1429
+ });
1430
+ }
1431
+
1432
+ function bindTerminalTabDragAndDrop(element, { sourceTabId = "", target = null } = {}) {
1433
+ if (!element) return;
1434
+ if (sourceTabId) {
1435
+ element.draggable = true;
1436
+ element.dataset.dragTabId = sourceTabId;
1437
+ element.addEventListener("dragstart", (event) => {
1438
+ if (event.target?.closest?.(".terminal-tab-close, .terminal-tab-group-add")) {
1439
+ event.preventDefault();
1440
+ return;
1441
+ }
1442
+ terminalTabDragId = sourceTabId;
1443
+ document.body.classList.add("terminal-tab-dragging");
1444
+ element.classList.add("terminal-tab-dragging");
1445
+ try {
1446
+ event.dataTransfer?.setData(TERMINAL_TAB_DRAG_MIME, sourceTabId);
1447
+ event.dataTransfer?.setData("text/plain", terminalTabById(sourceTabId)?.title || sourceTabId);
1448
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
1449
+ } catch {
1450
+ // Ignore browser drag payload restrictions; same-page state still carries the tab id.
1451
+ }
1452
+ });
1453
+ element.addEventListener("dragend", clearTerminalTabDragState);
1454
+ }
1455
+ if (!target) return;
1456
+ element.addEventListener("dragover", (event) => {
1457
+ const sourceTabIdFromEvent = terminalTabDragIdFromEvent(event);
1458
+ if (!canDropTerminalTabOnTarget(sourceTabIdFromEvent, target)) return;
1459
+ event.preventDefault();
1460
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
1461
+ element.classList.add("terminal-tab-drag-over");
1462
+ if (target.type === "group") setOpenTerminalTabGroup(target.group?.key);
1463
+ });
1464
+ element.addEventListener("dragleave", (event) => {
1465
+ if (event.relatedTarget && element.contains(event.relatedTarget)) return;
1466
+ element.classList.remove("terminal-tab-drag-over");
1467
+ });
1468
+ element.addEventListener("drop", (event) => {
1469
+ const sourceTabIdFromEvent = terminalTabDragIdFromEvent(event);
1470
+ element.classList.remove("terminal-tab-drag-over");
1471
+ if (!canDropTerminalTabOnTarget(sourceTabIdFromEvent, target)) return;
1472
+ event.preventDefault();
1473
+ event.stopPropagation();
1474
+ handleTerminalTabDrop(sourceTabIdFromEvent, target);
1475
+ });
1476
+ }
1477
+
1478
+ function handleTerminalTabDrop(sourceTabId, target) {
1479
+ const sourceTab = terminalTabById(sourceTabId);
1480
+ if (!sourceTab) return;
1481
+ let group = null;
1482
+ if (target.type === "group" && target.group) {
1483
+ if (target.group.custom && target.group.customGroupId) {
1484
+ group = addTabsToTerminalCustomGroup(target.group.customGroupId, [sourceTabId]);
1485
+ } else {
1486
+ group = createTerminalCustomGroup([...target.group.tabs.map((tab) => tab.id), sourceTabId]);
1487
+ }
1488
+ } else if (target.tabId) {
1489
+ const targetTab = terminalTabById(target.tabId);
1490
+ if (!targetTab || targetTab.id === sourceTabId) return;
1491
+ const targetGroupId = terminalCustomGroupIdForTab(targetTab.id);
1492
+ group = targetGroupId ? addTabsToTerminalCustomGroup(targetGroupId, [sourceTabId]) : createTerminalCustomGroup([targetTab.id, sourceTabId]);
1493
+ }
1494
+ if (!group) return;
1495
+ clearOpenTerminalTabGroup(null, { force: true });
1496
+ clearTerminalTabDragState();
1497
+ renderTabs();
1498
+ addEvent(`added ${sourceTab.title || "tab"} to ${normalizeTerminalCustomGroupTitle(group.title).toLowerCase()}`, "info");
1499
+ }
1500
+
1248
1501
  function removeStreamingThinkingBubble() {
1249
1502
  streamThinkingBubble?.remove();
1250
1503
  streamThinkingBubble = null;
@@ -4261,13 +4514,39 @@ function tabCwdGroupKey(tab) {
4261
4514
  }
4262
4515
 
4263
4516
  function tabCwdGroups() {
4517
+ syncTerminalCustomGroupsWithTabs(tabs);
4264
4518
  const groups = [];
4265
4519
  const byKey = new Map();
4520
+ const customByTab = terminalCustomGroupByTabId();
4521
+ const customGroups = new Map([...terminalCustomGroups.values()].map((group) => [group.id, {
4522
+ key: `custom:${group.id}`,
4523
+ custom: true,
4524
+ customGroupId: group.id,
4525
+ title: normalizeTerminalCustomGroupTitle(group.title),
4526
+ tabs: [],
4527
+ cwd: "",
4528
+ }]));
4529
+
4266
4530
  for (const tab of tabs) {
4531
+ const customGroupId = customByTab.get(tab.id);
4532
+ if (customGroupId) customGroups.get(customGroupId)?.tabs.push(tab);
4533
+ }
4534
+
4535
+ const emittedCustomGroups = new Set();
4536
+ for (const tab of tabs) {
4537
+ const customGroupId = customByTab.get(tab.id);
4538
+ if (customGroupId) {
4539
+ const customGroup = customGroups.get(customGroupId);
4540
+ if (customGroup && !emittedCustomGroups.has(customGroupId)) {
4541
+ groups.push(customGroup);
4542
+ emittedCustomGroups.add(customGroupId);
4543
+ }
4544
+ continue;
4545
+ }
4267
4546
  const key = tabCwdGroupKey(tab);
4268
4547
  let group = byKey.get(key);
4269
4548
  if (!group) {
4270
- group = { key, cwd: tab.cwd || "", tabs: [] };
4549
+ group = { key, custom: false, cwd: tab.cwd || "", tabs: [] };
4271
4550
  byKey.set(key, group);
4272
4551
  groups.push(group);
4273
4552
  }
@@ -4282,6 +4561,18 @@ function tabGroupTitle(cwd, fallback = "cwd") {
4282
4561
  return leaf.length > 26 ? `…${leaf.slice(-25)}` : leaf;
4283
4562
  }
4284
4563
 
4564
+ function terminalDisplayGroupTitle(group, fallback = "group") {
4565
+ return group?.custom ? normalizeTerminalCustomGroupTitle(group.title, "Custom group") : tabGroupTitle(group?.cwd, fallback);
4566
+ }
4567
+
4568
+ function terminalDisplayGroupDetail(group, fallback = "group") {
4569
+ if (!group?.custom) return normalizeDisplayPath(group?.cwd || fallback);
4570
+ const cwdLabels = [...new Set((group.tabs || []).map((tab) => normalizeDisplayPath(tab.cwd || "")).filter(Boolean))];
4571
+ if (!cwdLabels.length) return "custom group";
4572
+ if (cwdLabels.length === 1) return cwdLabels[0];
4573
+ return `${cwdLabels[0]} + ${cwdLabels.length - 1} cwd${cwdLabels.length === 2 ? "" : "s"}`;
4574
+ }
4575
+
4285
4576
  function terminalTabMeta(tab, indicator) {
4286
4577
  return tab.running ? `${indicator.meta} · pid ${tab.pid || "…"}` : "stopped";
4287
4578
  }
@@ -4299,12 +4590,15 @@ function renderTerminalTab(tab) {
4299
4590
  const isActive = tab.id === activeTabId;
4300
4591
  const indicator = tabIndicator(tab);
4301
4592
  const wrapper = make("div", `terminal-tab activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
4593
+ wrapper.dataset.tabId = tab.id;
4594
+ bindTerminalTabDragAndDrop(wrapper, { sourceTabId: tab.id, target: { type: "tab", tabId: tab.id } });
4302
4595
  const button = make("button", "terminal-tab-button");
4303
4596
  button.type = "button";
4597
+ button.draggable = false;
4304
4598
  button.setAttribute("role", "tab");
4305
4599
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4306
4600
  button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
4307
- button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
4601
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"} · drag onto another tab or group to group`;
4308
4602
  appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
4309
4603
  button.addEventListener("click", () => switchTab(tab.id));
4310
4604
  wrapper.append(button);
@@ -4312,6 +4606,7 @@ function renderTerminalTab(tab) {
4312
4606
  if (tabs.length > 1) {
4313
4607
  const close = make("button", "terminal-tab-close", "×");
4314
4608
  close.type = "button";
4609
+ close.draggable = false;
4315
4610
  close.title = `Close ${tab.title}`;
4316
4611
  close.setAttribute("aria-label", `Close ${tab.title}`);
4317
4612
  close.addEventListener("click", (event) => {
@@ -4324,16 +4619,19 @@ function renderTerminalTab(tab) {
4324
4619
  return wrapper;
4325
4620
  }
4326
4621
 
4327
- function renderTerminalTabGroupItem(tab) {
4622
+ function renderTerminalTabGroupItem(tab, group) {
4328
4623
  const isActive = tab.id === activeTabId;
4329
4624
  const indicator = tabIndicator(tab);
4330
4625
  const item = make("div", `terminal-tab-group-item activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
4626
+ item.dataset.tabId = tab.id;
4627
+ bindTerminalTabDragAndDrop(item, { sourceTabId: tab.id, target: group?.custom ? { type: "group", group } : { type: "tab", tabId: tab.id } });
4331
4628
  const button = make("button", "terminal-tab-button terminal-tab-group-item-button");
4332
4629
  button.type = "button";
4630
+ button.draggable = false;
4333
4631
  button.setAttribute("role", "tab");
4334
4632
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4335
4633
  button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
4336
- button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
4634
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"} · drag onto another tab or group to group`;
4337
4635
  appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
4338
4636
  button.addEventListener("click", (event) => {
4339
4637
  event.stopPropagation();
@@ -4344,6 +4642,7 @@ function renderTerminalTabGroupItem(tab) {
4344
4642
  if (tabs.length > 1) {
4345
4643
  const close = make("button", "terminal-tab-close terminal-tab-group-item-close", "×");
4346
4644
  close.type = "button";
4645
+ close.draggable = false;
4347
4646
  close.title = `Close ${tab.title}`;
4348
4647
  close.setAttribute("aria-label", `Close ${tab.title}`);
4349
4648
  close.addEventListener("click", (event) => {
@@ -4357,6 +4656,7 @@ function renderTerminalTabGroupItem(tab) {
4357
4656
  }
4358
4657
 
4359
4658
  function shouldRenderTerminalTabGroup(group, groupCount) {
4659
+ if (group.custom) return group.tabs.length > 1;
4360
4660
  return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
4361
4661
  }
4362
4662
 
@@ -4366,11 +4666,13 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4366
4666
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
4367
4667
  const isStopped = groupTabs.every((tab) => !tab.running);
4368
4668
  const indicator = tabGroupIndicator(groupTabs);
4369
- const cwdTitle = tabGroupTitle(group.cwd, activeGroupTab?.title || "cwd");
4370
- const activeTitle = activeGroupTab?.title || cwdTitle;
4371
- const displayCwd = normalizeDisplayPath(group.cwd || cwdTitle);
4372
- const wrapper = make("div", `terminal-tab terminal-tab-group activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
4669
+ const groupTitle = terminalDisplayGroupTitle(group, activeGroupTab?.title || "group");
4670
+ const activeTitle = activeGroupTab?.title || groupTitle;
4671
+ const groupDetail = terminalDisplayGroupDetail(group, groupTitle);
4672
+ const wrapper = make("div", `terminal-tab terminal-tab-group${group.custom ? " terminal-tab-custom-group" : ""} activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
4373
4673
  wrapper.dataset.groupKey = group.key;
4674
+ if (group.customGroupId) wrapper.dataset.customGroupId = group.customGroupId;
4675
+ bindTerminalTabDragAndDrop(wrapper, { sourceTabId: activeGroupTab?.id || "", target: { type: "group", group } });
4374
4676
  wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
4375
4677
  wrapper.addEventListener("pointerleave", () => clearOpenTerminalTabGroup(group.key));
4376
4678
  wrapper.addEventListener("focusin", () => setOpenTerminalTabGroup(group.key));
@@ -4381,21 +4683,23 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4381
4683
  });
4382
4684
  const button = make("button", "terminal-tab-button terminal-tab-group-button");
4383
4685
  button.type = "button";
4686
+ button.draggable = false;
4384
4687
  button.setAttribute("role", "tab");
4385
4688
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4386
4689
  button.setAttribute("aria-haspopup", "true");
4387
4690
  button.setAttribute("aria-expanded", group.key === openTerminalTabGroupKey ? "true" : "false");
4388
- button.setAttribute("aria-label", `${cwdTitle} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeTitle}`);
4389
- button.title = `${activeTitle} · ${displayCwd} · ${groupTabs.length} tabs · ${indicator.label}`;
4390
- appendTerminalTabContent(button, { title: activeTitle, indicator, meta: `${cwdTitle} · ${indicator.meta}`, count: groupTabs.length });
4691
+ button.setAttribute("aria-label", `${groupTitle} ${group.custom ? "custom" : "cwd"} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeTitle}`);
4692
+ button.title = `${activeTitle} · ${groupTitle} · ${groupDetail} · ${groupTabs.length} tabs · ${indicator.label} · drop tabs here to add to group`;
4693
+ appendTerminalTabContent(button, { title: activeTitle, indicator, meta: `${groupTitle} · ${indicator.meta}`, count: groupTabs.length });
4391
4694
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
4392
4695
  wrapper.append(button);
4393
4696
 
4394
- if (groupCount > 1) {
4697
+ if (groupCount > 1 || group.custom) {
4395
4698
  const close = make("button", "terminal-tab-close terminal-tab-group-close", "×");
4396
4699
  close.type = "button";
4397
- close.title = `Close ${displayCwd} group`;
4398
- close.setAttribute("aria-label", `Close ${displayCwd} group`);
4700
+ close.draggable = false;
4701
+ close.title = `Close ${groupTitle} group`;
4702
+ close.setAttribute("aria-label", `Close ${groupTitle} group`);
4399
4703
  close.addEventListener("click", (event) => {
4400
4704
  event.stopPropagation();
4401
4705
  closeTerminalTabGroup(group);
@@ -4405,16 +4709,17 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4405
4709
 
4406
4710
  const menu = make("div", "terminal-tab-group-menu");
4407
4711
  menu.setAttribute("role", "group");
4408
- menu.setAttribute("aria-label", `${displayCwd} tabs`);
4409
- for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab));
4712
+ menu.setAttribute("aria-label", `${groupTitle} tabs`);
4713
+ for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab, group));
4410
4714
 
4411
4715
  const add = make("button", "terminal-tab-group-add", "+ Tab");
4412
4716
  add.type = "button";
4413
- add.title = `Add tab in ${displayCwd}`;
4414
- add.setAttribute("aria-label", `Add tab in ${displayCwd}`);
4717
+ add.draggable = false;
4718
+ add.title = `Add tab in ${groupTitle}`;
4719
+ add.setAttribute("aria-label", `Add tab in ${groupTitle}`);
4415
4720
  add.addEventListener("click", (event) => {
4416
4721
  event.stopPropagation();
4417
- createTerminalTab(group.cwd, { triggerButton: add });
4722
+ createTerminalTab(activeGroupTab?.cwd || group.cwd || currentDirectoryForNewTab(), { triggerButton: add, customGroupId: group.customGroupId || null });
4418
4723
  });
4419
4724
  menu.append(add);
4420
4725
  wrapper.append(menu);
@@ -4543,7 +4848,7 @@ function currentDirectoryForNewTab() {
4543
4848
  return latestWorkspace?.cwd || activeTab()?.cwd || "";
4544
4849
  }
4545
4850
 
4546
- async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton } = {}) {
4851
+ async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton, customGroupId = null } = {}) {
4547
4852
  setMobileTabsExpanded(false);
4548
4853
  setNewTabMenuOpen(false);
4549
4854
  const resolvedCwd = cwd || currentDirectoryForNewTab();
@@ -4558,6 +4863,7 @@ async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerBut
4558
4863
  tabs = response.data?.tabs || tabs;
4559
4864
  syncTabMetadata(tabs);
4560
4865
  const tab = response.data?.tab;
4866
+ if (tab?.id && customGroupId && terminalCustomGroups.has(customGroupId)) addTabsToTerminalCustomGroup(customGroupId, [tab.id]);
4561
4867
  renderTabs();
4562
4868
  if (tab?.id) {
4563
4869
  await switchTab(tab.id);
@@ -4657,6 +4963,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
4657
4963
  appRunnerDataByTab.delete(id);
4658
4964
  tabMessagesCache.delete(id);
4659
4965
  }
4966
+ syncTerminalCustomGroupsWithTabs(tabs);
4660
4967
  clearOpenTerminalTabGroup(null, { force: true });
4661
4968
 
4662
4969
  const activeTabNeedsFallback = closedIds.includes(activeTabId) || !tabs.some((item) => item.id === activeTabId);
@@ -4689,7 +4996,7 @@ async function closeTerminalTab(tabId) {
4689
4996
  }
4690
4997
 
4691
4998
  async function closeTerminalTabGroup(group) {
4692
- const title = tabGroupTitle(group.cwd, group.tabs[0]?.title || "cwd");
4999
+ const title = terminalDisplayGroupTitle(group, group.tabs[0]?.title || "group");
4693
5000
  await closeTerminalTabs(group.tabs.map((tab) => tab.id), { label: `${title} group` });
4694
5001
  }
4695
5002
 
@@ -6077,13 +6384,23 @@ function renderGitChangesOverview(data) {
6077
6384
  return overview;
6078
6385
  }
6079
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
+
6080
6397
  function renderGitDiffRow(row) {
6081
6398
  const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
6082
6399
  node.append(
6083
6400
  make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
6084
- make("code", "git-diff-line old", row.left ?? ""),
6401
+ make("code", "git-diff-line old", gitDiffDisplayLine(row, "old")),
6085
6402
  make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
6086
- make("code", "git-diff-line new", row.right ?? ""),
6403
+ make("code", "git-diff-line new", gitDiffDisplayLine(row, "new")),
6087
6404
  );
6088
6405
  return node;
6089
6406
  }
@@ -6300,16 +6617,28 @@ function gitChangesGeneratedLabel(data) {
6300
6617
 
6301
6618
  function renderGitChangesDialog() {
6302
6619
  if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
6303
- const { loading, error, data } = gitChangesState;
6304
- if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
6305
- 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
+ }
6306
6629
  if (elements.gitChangesRefreshButton) {
6307
- elements.gitChangesRefreshButton.disabled = loading;
6630
+ elements.gitChangesRefreshButton.disabled = loading || pulling;
6308
6631
  elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
6309
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
+ }
6310
6638
  if (elements.gitChangesStatus) {
6311
- elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
6312
- 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;
6313
6642
  elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
6314
6643
  }
6315
6644
 
@@ -6319,7 +6648,7 @@ function renderGitChangesDialog() {
6319
6648
  body.append(make("div", "git-changes-empty", "Loading git diff…"));
6320
6649
  return;
6321
6650
  }
6322
- if (error) {
6651
+ if (error && !data) {
6323
6652
  body.append(make("div", "git-changes-empty error", error));
6324
6653
  return;
6325
6654
  }
@@ -6337,20 +6666,23 @@ function renderGitChangesDialog() {
6337
6666
  if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
6338
6667
  for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
6339
6668
  if (untracked.length) body.append(renderGitUntrackedSection(untracked));
6340
- 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
+ }
6341
6673
  if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
6342
6674
  }
6343
6675
 
6344
6676
  async function loadGitChangesDialog(tabContext = activeTabContext()) {
6345
6677
  const requestSerial = ++gitChangesRequestSerial;
6346
6678
  gitChangesUntrackedContentRequests.clear();
6347
- gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
6679
+ gitChangesState = { ...gitChangesState, loading: true, error: "", message: "", tabId: tabContext.tabId || activeTabId };
6348
6680
  renderGitChangesDialog();
6349
6681
  try {
6350
6682
  const response = await api("/api/git-changes", { tabId: tabContext.tabId });
6351
6683
  if (requestSerial !== gitChangesRequestSerial) return;
6352
6684
  if (!response.ok) throw new Error(response.error || "Failed to load git changes");
6353
- 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 };
6354
6686
  } catch (error) {
6355
6687
  if (requestSerial !== gitChangesRequestSerial) return;
6356
6688
  gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
@@ -6363,7 +6695,7 @@ function openGitChangesDialog() {
6363
6695
  hideFooterTooltip();
6364
6696
  const tabContext = activeTabContext();
6365
6697
  const tabId = tabContext.tabId || activeTabId;
6366
- 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 };
6367
6699
  renderGitChangesDialog();
6368
6700
  if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
6369
6701
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
@@ -6374,10 +6706,46 @@ function refreshGitChangesDialog() {
6374
6706
  loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
6375
6707
  }
6376
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
+
6377
6745
  function closeGitChangesDialog() {
6378
6746
  gitChangesRequestSerial += 1;
6379
6747
  gitChangesUntrackedContentRequests.clear();
6380
- gitChangesState = { ...gitChangesState, loading: false };
6748
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
6381
6749
  if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
6382
6750
  }
6383
6751
 
@@ -8172,6 +8540,29 @@ async function refreshAppRunners(tabContext = activeTabContext()) {
8172
8540
  renderWidgets();
8173
8541
  }
8174
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
+
8175
8566
  async function runAppRunner(runnerId) {
8176
8567
  const tabContext = activeTabContext();
8177
8568
  if (!tabContext.tabId || !runnerId) return;
@@ -8186,7 +8577,12 @@ async function runAppRunner(runnerId) {
8186
8577
  const command = response.data?.activeRun?.displayCommand || "app runner";
8187
8578
  addEvent(`started ${command}`, "info");
8188
8579
  } catch (error) {
8189
- 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");
8190
8586
  }
8191
8587
  }
8192
8588
 
@@ -8267,9 +8663,14 @@ function activeAppRunnerCustomConfig() {
8267
8663
  return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
8268
8664
  }
8269
8665
 
8270
- function resetAppRunnerCustomDraft() {
8666
+ function resetAppRunnerCustomDraft({ clearFeedback = true } = {}) {
8271
8667
  appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
8272
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 || "") };
8273
8674
  }
8274
8675
 
8275
8676
  function appRunnerRelativeDir(filePath) {
@@ -8329,8 +8730,10 @@ async function saveAppRunnerCustomRunner(form) {
8329
8730
  updateAppRunnerCustomDraftFrom(form);
8330
8731
  const payload = appRunnerCustomDraftPayload();
8331
8732
  if (!payload.path) {
8733
+ setAppRunnerCustomFeedback("warning", "Custom app runner path is required.");
8734
+ renderAppRunnerInfoDialog();
8735
+ requestAnimationFrame(() => document.querySelector("#appRunnerCustomPathInput")?.focus());
8332
8736
  addEvent("custom app runner path is required", "warn");
8333
- form?.querySelector("#appRunnerCustomPathInput")?.focus();
8334
8737
  return;
8335
8738
  }
8336
8739
  const tabContext = activeTabContext();
@@ -8338,13 +8741,18 @@ async function saveAppRunnerCustomRunner(form) {
8338
8741
  const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
8339
8742
  if (!isCurrentTabContext(tabContext)) return;
8340
8743
  setAppRunnerData(tabContext.tabId, response.data || {});
8341
- resetAppRunnerCustomDraft();
8744
+ resetAppRunnerCustomDraft({ clearFeedback: false });
8745
+ setAppRunnerCustomFeedback("success", "Saved custom app runner. It should now appear in the Run menu when available.");
8342
8746
  renderAppRunnerControls();
8343
8747
  renderWidgets();
8344
8748
  renderAppRunnerInfoDialog();
8345
8749
  addEvent("saved custom app runner", "info");
8346
8750
  } catch (error) {
8347
- 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");
8348
8756
  }
8349
8757
  }
8350
8758
 
@@ -8354,13 +8762,18 @@ async function deleteAppRunnerCustomRunner(id) {
8354
8762
  const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
8355
8763
  if (!isCurrentTabContext(tabContext)) return;
8356
8764
  setAppRunnerData(tabContext.tabId, response.data || {});
8357
- if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
8765
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft({ clearFeedback: false });
8766
+ setAppRunnerCustomFeedback("success", "Deleted custom app runner.");
8358
8767
  renderAppRunnerControls();
8359
8768
  renderWidgets();
8360
8769
  renderAppRunnerInfoDialog();
8361
8770
  addEvent("deleted custom app runner", "warn");
8362
8771
  } catch (error) {
8363
- 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");
8364
8777
  }
8365
8778
  }
8366
8779
 
@@ -8445,9 +8858,10 @@ function renderAppRunnerCustomSection() {
8445
8858
  existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
8446
8859
  } else {
8447
8860
  for (const runner of customRunners) {
8448
- const row = make("div", "app-runner-custom-item");
8861
+ const row = make("div", `app-runner-custom-item${runner.available === false ? " unavailable" : ""}`);
8449
8862
  const details = make("div", "app-runner-custom-item-details");
8450
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}`));
8451
8865
  const actions = make("div", "app-runner-custom-item-actions");
8452
8866
  const edit = make("button", "", "Edit");
8453
8867
  edit.type = "button";
@@ -8469,6 +8883,13 @@ function renderAppRunnerCustomSection() {
8469
8883
  }
8470
8884
  section.append(existing);
8471
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
+
8472
8893
  const form = make("div", "app-runner-custom-form");
8473
8894
  const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
8474
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." });
@@ -8493,6 +8914,7 @@ function renderAppRunnerCustomSection() {
8493
8914
  reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
8494
8915
  formActions.append(save, reset);
8495
8916
  form.append(formActions);
8917
+ if (appRunnerCustomFeedback.message) form.append(make("div", `app-runner-custom-feedback ${appRunnerCustomFeedback.type || "info"}`, appRunnerCustomFeedback.message));
8496
8918
  const browser = renderAppRunnerFileBrowser();
8497
8919
  if (browser) form.append(browser);
8498
8920
  section.append(form);
@@ -18408,6 +18830,7 @@ window.addEventListener("blur", () => {
18408
18830
  });
18409
18831
 
18410
18832
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
18833
+ elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
18411
18834
  elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
18412
18835
  elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
18413
18836
  elements.gitChangesDialog?.addEventListener("cancel", (event) => {
@@ -18416,7 +18839,7 @@ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
18416
18839
  });
18417
18840
  elements.gitChangesDialog?.addEventListener("close", () => {
18418
18841
  gitChangesRequestSerial += 1;
18419
- gitChangesState = { ...gitChangesState, loading: false };
18842
+ gitChangesState = { ...gitChangesState, loading: false, pulling: false };
18420
18843
  });
18421
18844
 
18422
18845
  elements.refreshCodexUsageButton?.addEventListener("click", () => {
@@ -18549,6 +18972,7 @@ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast
18549
18972
  restoreAgentDoneNotificationsSetting();
18550
18973
  restoreThinkingVisibilitySetting();
18551
18974
  restoreTerminalTabsLayoutSetting();
18975
+ restoreTerminalCustomGroups();
18552
18976
  restoreToolOutputExpansionSetting();
18553
18977
  restoreWorkspaceDashboardState();
18554
18978
  restoreSidePanelSectionState();