@firstpick/pi-package-webui 0.4.4 → 0.4.6

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
@@ -58,6 +58,7 @@ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout
58
58
  const DEFAULT_HOST = "127.0.0.1";
59
59
  const DEFAULT_PORT = 31415;
60
60
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
61
+ const PROMPT_REQUEST_TIMEOUT_MS = Math.max(REQUEST_TIMEOUT_MS, Number.parseInt(process.env.PI_WEBUI_PROMPT_TIMEOUT_MS || "7200000", 10) || 7200000);
61
62
  const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
62
63
  const WEBUI_HELPER_COMMAND = "webui-helper";
63
64
  const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
@@ -4369,6 +4370,22 @@ function isNodeScriptCommand(command) {
4369
4370
  return [".cjs", ".js", ".mjs"].includes(path.extname(String(command || "")).toLowerCase());
4370
4371
  }
4371
4372
 
4373
+ async function resolvedPiCliScript() {
4374
+ const packagePathParts = PI_CODING_AGENT_PACKAGE.split("/").filter(Boolean);
4375
+ const searchRoots = require.resolve.paths(PI_CODING_AGENT_PACKAGE) || [];
4376
+ for (const nodeModulesRoot of searchRoots) {
4377
+ const cliPath = path.join(nodeModulesRoot, ...packagePathParts, "dist", "cli.js");
4378
+ try {
4379
+ await access(cliPath);
4380
+ return cliPath;
4381
+ } catch {
4382
+ // Continue through Node's resolution roots; global npm installs can hoist
4383
+ // pi-coding-agent beside pi-package-webui instead of nesting it.
4384
+ }
4385
+ }
4386
+ return "";
4387
+ }
4388
+
4372
4389
  async function resolvePiCommand(piArgs) {
4373
4390
  if (options.piBinExplicit) {
4374
4391
  if (isNodeScriptCommand(options.piBin)) {
@@ -4381,17 +4398,16 @@ async function resolvePiCommand(piArgs) {
4381
4398
  return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
4382
4399
  }
4383
4400
 
4384
- const bundledCli = path.join(packageRoot, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
4385
- try {
4386
- await access(bundledCli);
4401
+ const bundledCli = await resolvedPiCliScript();
4402
+ if (bundledCli) {
4387
4403
  return {
4388
4404
  command: process.execPath,
4389
4405
  args: [bundledCli, ...piArgs],
4390
4406
  displayCommand: `${process.execPath} ${bundledCli} ${piArgs.join(" ")}`,
4391
4407
  };
4392
- } catch {
4393
- return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
4394
4408
  }
4409
+
4410
+ return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
4395
4411
  }
4396
4412
 
4397
4413
  const tabs = new Map();
@@ -5079,7 +5095,8 @@ async function getUpdateStatus({ force = false } = {}) {
5079
5095
 
5080
5096
  async function resolvePiUpdateCommand() {
5081
5097
  if (options.piBinExplicit) {
5082
- return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
5098
+ const command = await resolvePiCommand(["update"]);
5099
+ return { ...command, label: "Pi CLI and configured packages" };
5083
5100
  }
5084
5101
 
5085
5102
  const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
@@ -7529,7 +7546,7 @@ const server = createServer(async (req, res) => {
7529
7546
  maybeNameTabForConversation(tab, command);
7530
7547
  markTabWorking(tab);
7531
7548
  }
7532
- const response = await tab.rpc.send(command);
7549
+ const response = await tab.rpc.send(command, PROMPT_REQUEST_TIMEOUT_MS);
7533
7550
  if (response.success === false && startsVisibleWork) markTabIdle(tab);
7534
7551
  sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
7535
7552
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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,19 +53,19 @@
53
53
  "test": "node tests/run-all.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@earendil-works/pi-coding-agent": "^0.79.3"
56
+ "@earendil-works/pi-coding-agent": "^0.79.5"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "@firstpick/pi-extension-btw": "^0.1.0",
60
60
  "@firstpick/pi-extension-git-footer-status": "^0.3.3",
61
61
  "@firstpick/pi-extension-release-aur": "^0.1.6",
62
62
  "@firstpick/pi-extension-release-npm": "^0.4.0",
63
- "@firstpick/pi-extension-workflows": "^0.1.0",
64
63
  "@firstpick/pi-extension-safety-guard": "^0.2.3",
65
64
  "@firstpick/pi-extension-setup-skills": "^0.1.8",
66
65
  "@firstpick/pi-extension-stats": "^0.2.6",
67
66
  "@firstpick/pi-extension-todo-progress": "^0.2.4",
68
67
  "@firstpick/pi-extension-tools": "^0.1.6",
68
+ "@firstpick/pi-extension-workflows": "^0.1.0",
69
69
  "@firstpick/pi-package-remote-webui": "^0.1.0",
70
70
  "@firstpick/pi-prompts-git-pr": "^0.1.2",
71
71
  "@firstpick/pi-themes-bundle": "^0.1.4"
package/public/app.js CHANGED
@@ -276,6 +276,9 @@ let pathFastPicksReady = false;
276
276
  let pathFastPicksLoadPromise = null;
277
277
  let mobileTabsExpanded = false;
278
278
  let openTerminalTabGroupKey = null;
279
+ let terminalCustomGroups = new Map();
280
+ let terminalCustomGroupSerial = 1;
281
+ let terminalTabDragId = null;
279
282
  let newTabMenuOpen = false;
280
283
  let nativeCommandMenuOpen = false;
281
284
  let appRunnerMenuOpen = false;
@@ -409,6 +412,8 @@ const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
409
412
  const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
410
413
  const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
411
414
  const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
415
+ const TERMINAL_CUSTOM_GROUPS_STORAGE_KEY = "pi-webui-terminal-custom-groups-v1";
416
+ const TERMINAL_TAB_DRAG_MIME = "application/x-pi-terminal-tab-id";
412
417
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
413
418
  const THEME_STORAGE_KEY = "pi-webui-theme";
414
419
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
@@ -1245,6 +1250,252 @@ function restoreTerminalTabsLayoutSetting() {
1245
1250
  setTerminalTabsLayout(readStoredTerminalTabsLayout(), { persist: false });
1246
1251
  }
1247
1252
 
1253
+ function normalizeTerminalCustomGroupTitle(value, fallback = "Custom group") {
1254
+ const title = String(value || "").replace(/\s+/g, " ").trim();
1255
+ return (title || fallback).slice(0, 40);
1256
+ }
1257
+
1258
+ function normalizeTerminalCustomGroupTabIds(value) {
1259
+ const ids = [];
1260
+ const seen = new Set();
1261
+ for (const item of Array.isArray(value) ? value : []) {
1262
+ const id = String(item || "").trim();
1263
+ if (!id || seen.has(id)) continue;
1264
+ seen.add(id);
1265
+ ids.push(id);
1266
+ }
1267
+ return ids;
1268
+ }
1269
+
1270
+ function nextTerminalCustomGroupId() {
1271
+ const id = `group-${Date.now().toString(36)}-${terminalCustomGroupSerial.toString(36)}`;
1272
+ terminalCustomGroupSerial += 1;
1273
+ return id;
1274
+ }
1275
+
1276
+ function restoreTerminalCustomGroups() {
1277
+ terminalCustomGroups = new Map();
1278
+ terminalCustomGroupSerial = 1;
1279
+ let parsed = null;
1280
+ try {
1281
+ parsed = JSON.parse(localStorage.getItem(TERMINAL_CUSTOM_GROUPS_STORAGE_KEY) || "null");
1282
+ } catch {
1283
+ return;
1284
+ }
1285
+ const records = Array.isArray(parsed?.groups) ? parsed.groups : Array.isArray(parsed) ? parsed : [];
1286
+ const usedTabIds = new Set();
1287
+ for (const record of records) {
1288
+ const rawId = String(record?.id || "").trim();
1289
+ const id = rawId && !terminalCustomGroups.has(rawId) ? rawId : nextTerminalCustomGroupId();
1290
+ const title = normalizeTerminalCustomGroupTitle(record?.title, `Group ${terminalCustomGroups.size + 1}`);
1291
+ const tabIds = normalizeTerminalCustomGroupTabIds(record?.tabIds).filter((tabId) => {
1292
+ if (usedTabIds.has(tabId)) return false;
1293
+ usedTabIds.add(tabId);
1294
+ return true;
1295
+ });
1296
+ if (tabIds.length < 2) continue;
1297
+ terminalCustomGroups.set(id, { id, title, tabIds });
1298
+ const serialMatch = /^Group\s+(\d+)$/i.exec(title);
1299
+ if (serialMatch) terminalCustomGroupSerial = Math.max(terminalCustomGroupSerial, Number(serialMatch[1]) + 1);
1300
+ }
1301
+ }
1302
+
1303
+ function persistTerminalCustomGroups() {
1304
+ try {
1305
+ const groups = [...terminalCustomGroups.values()].map((group) => ({
1306
+ id: group.id,
1307
+ title: normalizeTerminalCustomGroupTitle(group.title),
1308
+ tabIds: normalizeTerminalCustomGroupTabIds(group.tabIds),
1309
+ })).filter((group) => group.tabIds.length >= 2);
1310
+ localStorage.setItem(TERMINAL_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify({ version: 1, groups }));
1311
+ } catch {
1312
+ // Ignore storage failures; custom tab groups still work for this page load.
1313
+ }
1314
+ }
1315
+
1316
+ function terminalCustomGroupByTabId() {
1317
+ const map = new Map();
1318
+ for (const [groupId, group] of terminalCustomGroups) {
1319
+ for (const tabId of group.tabIds || []) {
1320
+ if (!map.has(tabId)) map.set(tabId, groupId);
1321
+ }
1322
+ }
1323
+ return map;
1324
+ }
1325
+
1326
+ function terminalCustomGroupIdForTab(tabId) {
1327
+ const id = String(tabId || "").trim();
1328
+ if (!id) return null;
1329
+ for (const [groupId, group] of terminalCustomGroups) {
1330
+ if (group.tabIds?.includes(id)) return groupId;
1331
+ }
1332
+ return null;
1333
+ }
1334
+
1335
+ function syncTerminalCustomGroupsWithTabs(currentTabs = tabs, { persist = true } = {}) {
1336
+ const validTabIds = new Set((currentTabs || []).map((tab) => tab.id).filter(Boolean));
1337
+ const claimedTabIds = new Set();
1338
+ let changed = false;
1339
+ for (const [groupId, group] of [...terminalCustomGroups]) {
1340
+ const filtered = [];
1341
+ for (const tabId of normalizeTerminalCustomGroupTabIds(group.tabIds)) {
1342
+ if (!validTabIds.has(tabId) || claimedTabIds.has(tabId)) {
1343
+ changed = true;
1344
+ continue;
1345
+ }
1346
+ claimedTabIds.add(tabId);
1347
+ filtered.push(tabId);
1348
+ }
1349
+ if (filtered.length < 2) {
1350
+ terminalCustomGroups.delete(groupId);
1351
+ changed = true;
1352
+ continue;
1353
+ }
1354
+ if (filtered.length !== group.tabIds.length || filtered.some((tabId, index) => tabId !== group.tabIds[index])) {
1355
+ group.tabIds = filtered;
1356
+ changed = true;
1357
+ }
1358
+ }
1359
+ if (changed && persist) persistTerminalCustomGroups();
1360
+ return changed;
1361
+ }
1362
+
1363
+ function removeTabsFromOtherTerminalCustomGroups(tabIds, keepGroupId = null) {
1364
+ const moving = new Set(normalizeTerminalCustomGroupTabIds(tabIds));
1365
+ if (!moving.size) return false;
1366
+ let changed = false;
1367
+ for (const [groupId, group] of [...terminalCustomGroups]) {
1368
+ if (groupId === keepGroupId) continue;
1369
+ const filtered = normalizeTerminalCustomGroupTabIds(group.tabIds).filter((tabId) => !moving.has(tabId));
1370
+ if (filtered.length === group.tabIds.length) continue;
1371
+ changed = true;
1372
+ if (filtered.length < 2) terminalCustomGroups.delete(groupId);
1373
+ else group.tabIds = filtered;
1374
+ }
1375
+ return changed;
1376
+ }
1377
+
1378
+ function createTerminalCustomGroup(tabIds) {
1379
+ const validTabIds = new Set(tabs.map((tab) => tab.id).filter(Boolean));
1380
+ const unique = normalizeTerminalCustomGroupTabIds(tabIds).filter((tabId) => validTabIds.has(tabId));
1381
+ if (unique.length < 2) return null;
1382
+ removeTabsFromOtherTerminalCustomGroups(unique);
1383
+ const title = `Group ${terminalCustomGroupSerial}`;
1384
+ const id = nextTerminalCustomGroupId();
1385
+ const group = { id, title, tabIds: unique };
1386
+ terminalCustomGroups.set(id, group);
1387
+ syncTerminalCustomGroupsWithTabs(tabs, { persist: false });
1388
+ persistTerminalCustomGroups();
1389
+ return terminalCustomGroups.get(id) || group;
1390
+ }
1391
+
1392
+ function addTabsToTerminalCustomGroup(groupId, tabIds) {
1393
+ const group = terminalCustomGroups.get(groupId);
1394
+ if (!group) return null;
1395
+ const validTabIds = new Set(tabs.map((tab) => tab.id).filter(Boolean));
1396
+ const unique = normalizeTerminalCustomGroupTabIds(tabIds).filter((tabId) => validTabIds.has(tabId) && !group.tabIds.includes(tabId));
1397
+ if (!unique.length) return group;
1398
+ removeTabsFromOtherTerminalCustomGroups(unique, groupId);
1399
+ group.tabIds = normalizeTerminalCustomGroupTabIds([...group.tabIds, ...unique]);
1400
+ syncTerminalCustomGroupsWithTabs(tabs, { persist: false });
1401
+ persistTerminalCustomGroups();
1402
+ return terminalCustomGroups.get(groupId) || null;
1403
+ }
1404
+
1405
+ function terminalTabById(tabId) {
1406
+ return tabs.find((tab) => tab.id === tabId) || null;
1407
+ }
1408
+
1409
+ function terminalTabDragIdFromEvent(event) {
1410
+ return event?.dataTransfer?.getData?.(TERMINAL_TAB_DRAG_MIME) || terminalTabDragId || "";
1411
+ }
1412
+
1413
+ function canDropTerminalTabOnTarget(sourceTabId, target) {
1414
+ if (!sourceTabId || !terminalTabById(sourceTabId) || !target) return false;
1415
+ if (target.type === "group") {
1416
+ const groupTabs = target.group?.tabs || [];
1417
+ return groupTabs.length > 0 && !groupTabs.some((tab) => tab.id === sourceTabId);
1418
+ }
1419
+ return Boolean(target.tabId && target.tabId !== sourceTabId && terminalTabById(target.tabId));
1420
+ }
1421
+
1422
+ function clearTerminalTabDragState() {
1423
+ terminalTabDragId = null;
1424
+ document.body.classList.remove("terminal-tab-dragging");
1425
+ elements.tabBar?.querySelectorAll(".terminal-tab-dragging, .terminal-tab-drag-over").forEach((item) => {
1426
+ item.classList.remove("terminal-tab-dragging", "terminal-tab-drag-over");
1427
+ });
1428
+ }
1429
+
1430
+ function bindTerminalTabDragAndDrop(element, { sourceTabId = "", target = null } = {}) {
1431
+ if (!element) return;
1432
+ if (sourceTabId) {
1433
+ element.draggable = true;
1434
+ element.dataset.dragTabId = sourceTabId;
1435
+ element.addEventListener("dragstart", (event) => {
1436
+ if (event.target?.closest?.(".terminal-tab-close, .terminal-tab-group-add")) {
1437
+ event.preventDefault();
1438
+ return;
1439
+ }
1440
+ terminalTabDragId = sourceTabId;
1441
+ document.body.classList.add("terminal-tab-dragging");
1442
+ element.classList.add("terminal-tab-dragging");
1443
+ try {
1444
+ event.dataTransfer?.setData(TERMINAL_TAB_DRAG_MIME, sourceTabId);
1445
+ event.dataTransfer?.setData("text/plain", terminalTabById(sourceTabId)?.title || sourceTabId);
1446
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
1447
+ } catch {
1448
+ // Ignore browser drag payload restrictions; same-page state still carries the tab id.
1449
+ }
1450
+ });
1451
+ element.addEventListener("dragend", clearTerminalTabDragState);
1452
+ }
1453
+ if (!target) return;
1454
+ element.addEventListener("dragover", (event) => {
1455
+ const sourceTabIdFromEvent = terminalTabDragIdFromEvent(event);
1456
+ if (!canDropTerminalTabOnTarget(sourceTabIdFromEvent, target)) return;
1457
+ event.preventDefault();
1458
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
1459
+ element.classList.add("terminal-tab-drag-over");
1460
+ if (target.type === "group") setOpenTerminalTabGroup(target.group?.key);
1461
+ });
1462
+ element.addEventListener("dragleave", (event) => {
1463
+ if (event.relatedTarget && element.contains(event.relatedTarget)) return;
1464
+ element.classList.remove("terminal-tab-drag-over");
1465
+ });
1466
+ element.addEventListener("drop", (event) => {
1467
+ const sourceTabIdFromEvent = terminalTabDragIdFromEvent(event);
1468
+ element.classList.remove("terminal-tab-drag-over");
1469
+ if (!canDropTerminalTabOnTarget(sourceTabIdFromEvent, target)) return;
1470
+ event.preventDefault();
1471
+ event.stopPropagation();
1472
+ handleTerminalTabDrop(sourceTabIdFromEvent, target);
1473
+ });
1474
+ }
1475
+
1476
+ function handleTerminalTabDrop(sourceTabId, target) {
1477
+ const sourceTab = terminalTabById(sourceTabId);
1478
+ if (!sourceTab) return;
1479
+ let group = null;
1480
+ if (target.type === "group" && target.group) {
1481
+ if (target.group.custom && target.group.customGroupId) {
1482
+ group = addTabsToTerminalCustomGroup(target.group.customGroupId, [sourceTabId]);
1483
+ } else {
1484
+ group = createTerminalCustomGroup([...target.group.tabs.map((tab) => tab.id), sourceTabId]);
1485
+ }
1486
+ } else if (target.tabId) {
1487
+ const targetTab = terminalTabById(target.tabId);
1488
+ if (!targetTab || targetTab.id === sourceTabId) return;
1489
+ const targetGroupId = terminalCustomGroupIdForTab(targetTab.id);
1490
+ group = targetGroupId ? addTabsToTerminalCustomGroup(targetGroupId, [sourceTabId]) : createTerminalCustomGroup([targetTab.id, sourceTabId]);
1491
+ }
1492
+ if (!group) return;
1493
+ clearOpenTerminalTabGroup(null, { force: true });
1494
+ clearTerminalTabDragState();
1495
+ renderTabs();
1496
+ addEvent(`added ${sourceTab.title || "tab"} to ${normalizeTerminalCustomGroupTitle(group.title).toLowerCase()}`, "info");
1497
+ }
1498
+
1248
1499
  function removeStreamingThinkingBubble() {
1249
1500
  streamThinkingBubble?.remove();
1250
1501
  streamThinkingBubble = null;
@@ -4261,13 +4512,39 @@ function tabCwdGroupKey(tab) {
4261
4512
  }
4262
4513
 
4263
4514
  function tabCwdGroups() {
4515
+ syncTerminalCustomGroupsWithTabs(tabs);
4264
4516
  const groups = [];
4265
4517
  const byKey = new Map();
4518
+ const customByTab = terminalCustomGroupByTabId();
4519
+ const customGroups = new Map([...terminalCustomGroups.values()].map((group) => [group.id, {
4520
+ key: `custom:${group.id}`,
4521
+ custom: true,
4522
+ customGroupId: group.id,
4523
+ title: normalizeTerminalCustomGroupTitle(group.title),
4524
+ tabs: [],
4525
+ cwd: "",
4526
+ }]));
4527
+
4528
+ for (const tab of tabs) {
4529
+ const customGroupId = customByTab.get(tab.id);
4530
+ if (customGroupId) customGroups.get(customGroupId)?.tabs.push(tab);
4531
+ }
4532
+
4533
+ const emittedCustomGroups = new Set();
4266
4534
  for (const tab of tabs) {
4535
+ const customGroupId = customByTab.get(tab.id);
4536
+ if (customGroupId) {
4537
+ const customGroup = customGroups.get(customGroupId);
4538
+ if (customGroup && !emittedCustomGroups.has(customGroupId)) {
4539
+ groups.push(customGroup);
4540
+ emittedCustomGroups.add(customGroupId);
4541
+ }
4542
+ continue;
4543
+ }
4267
4544
  const key = tabCwdGroupKey(tab);
4268
4545
  let group = byKey.get(key);
4269
4546
  if (!group) {
4270
- group = { key, cwd: tab.cwd || "", tabs: [] };
4547
+ group = { key, custom: false, cwd: tab.cwd || "", tabs: [] };
4271
4548
  byKey.set(key, group);
4272
4549
  groups.push(group);
4273
4550
  }
@@ -4282,6 +4559,18 @@ function tabGroupTitle(cwd, fallback = "cwd") {
4282
4559
  return leaf.length > 26 ? `…${leaf.slice(-25)}` : leaf;
4283
4560
  }
4284
4561
 
4562
+ function terminalDisplayGroupTitle(group, fallback = "group") {
4563
+ return group?.custom ? normalizeTerminalCustomGroupTitle(group.title, "Custom group") : tabGroupTitle(group?.cwd, fallback);
4564
+ }
4565
+
4566
+ function terminalDisplayGroupDetail(group, fallback = "group") {
4567
+ if (!group?.custom) return normalizeDisplayPath(group?.cwd || fallback);
4568
+ const cwdLabels = [...new Set((group.tabs || []).map((tab) => normalizeDisplayPath(tab.cwd || "")).filter(Boolean))];
4569
+ if (!cwdLabels.length) return "custom group";
4570
+ if (cwdLabels.length === 1) return cwdLabels[0];
4571
+ return `${cwdLabels[0]} + ${cwdLabels.length - 1} cwd${cwdLabels.length === 2 ? "" : "s"}`;
4572
+ }
4573
+
4285
4574
  function terminalTabMeta(tab, indicator) {
4286
4575
  return tab.running ? `${indicator.meta} · pid ${tab.pid || "…"}` : "stopped";
4287
4576
  }
@@ -4299,12 +4588,15 @@ function renderTerminalTab(tab) {
4299
4588
  const isActive = tab.id === activeTabId;
4300
4589
  const indicator = tabIndicator(tab);
4301
4590
  const wrapper = make("div", `terminal-tab activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
4591
+ wrapper.dataset.tabId = tab.id;
4592
+ bindTerminalTabDragAndDrop(wrapper, { sourceTabId: tab.id, target: { type: "tab", tabId: tab.id } });
4302
4593
  const button = make("button", "terminal-tab-button");
4303
4594
  button.type = "button";
4595
+ button.draggable = false;
4304
4596
  button.setAttribute("role", "tab");
4305
4597
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4306
4598
  button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
4307
- button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
4599
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"} · drag onto another tab or group to group`;
4308
4600
  appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
4309
4601
  button.addEventListener("click", () => switchTab(tab.id));
4310
4602
  wrapper.append(button);
@@ -4312,6 +4604,7 @@ function renderTerminalTab(tab) {
4312
4604
  if (tabs.length > 1) {
4313
4605
  const close = make("button", "terminal-tab-close", "×");
4314
4606
  close.type = "button";
4607
+ close.draggable = false;
4315
4608
  close.title = `Close ${tab.title}`;
4316
4609
  close.setAttribute("aria-label", `Close ${tab.title}`);
4317
4610
  close.addEventListener("click", (event) => {
@@ -4324,16 +4617,19 @@ function renderTerminalTab(tab) {
4324
4617
  return wrapper;
4325
4618
  }
4326
4619
 
4327
- function renderTerminalTabGroupItem(tab) {
4620
+ function renderTerminalTabGroupItem(tab, group) {
4328
4621
  const isActive = tab.id === activeTabId;
4329
4622
  const indicator = tabIndicator(tab);
4330
4623
  const item = make("div", `terminal-tab-group-item activity-${indicator.state}${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
4624
+ item.dataset.tabId = tab.id;
4625
+ bindTerminalTabDragAndDrop(item, { sourceTabId: tab.id, target: group?.custom ? { type: "group", group } : { type: "tab", tabId: tab.id } });
4331
4626
  const button = make("button", "terminal-tab-button terminal-tab-group-item-button");
4332
4627
  button.type = "button";
4628
+ button.draggable = false;
4333
4629
  button.setAttribute("role", "tab");
4334
4630
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4335
4631
  button.setAttribute("aria-label", `${tab.title}: ${indicator.label}`);
4336
- button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
4632
+ button.title = `${tab.title} · ${indicator.label}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"} · drag onto another tab or group to group`;
4337
4633
  appendTerminalTabContent(button, { title: tab.title, indicator, meta: terminalTabMeta(tab, indicator) });
4338
4634
  button.addEventListener("click", (event) => {
4339
4635
  event.stopPropagation();
@@ -4344,6 +4640,7 @@ function renderTerminalTabGroupItem(tab) {
4344
4640
  if (tabs.length > 1) {
4345
4641
  const close = make("button", "terminal-tab-close terminal-tab-group-item-close", "×");
4346
4642
  close.type = "button";
4643
+ close.draggable = false;
4347
4644
  close.title = `Close ${tab.title}`;
4348
4645
  close.setAttribute("aria-label", `Close ${tab.title}`);
4349
4646
  close.addEventListener("click", (event) => {
@@ -4357,6 +4654,7 @@ function renderTerminalTabGroupItem(tab) {
4357
4654
  }
4358
4655
 
4359
4656
  function shouldRenderTerminalTabGroup(group, groupCount) {
4657
+ if (group.custom) return group.tabs.length > 1;
4360
4658
  return groupCount > 1 && group.tabs.length > 1 && Boolean(group.cwd);
4361
4659
  }
4362
4660
 
@@ -4366,11 +4664,13 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4366
4664
  const isActive = groupTabs.some((tab) => tab.id === activeTabId);
4367
4665
  const isStopped = groupTabs.every((tab) => !tab.running);
4368
4666
  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" : ""}`);
4667
+ const groupTitle = terminalDisplayGroupTitle(group, activeGroupTab?.title || "group");
4668
+ const activeTitle = activeGroupTab?.title || groupTitle;
4669
+ const groupDetail = terminalDisplayGroupDetail(group, groupTitle);
4670
+ const wrapper = make("div", `terminal-tab terminal-tab-group${group.custom ? " terminal-tab-custom-group" : ""} activity-${indicator.state}${isActive ? " active" : ""}${isStopped ? " stopped" : ""}`);
4373
4671
  wrapper.dataset.groupKey = group.key;
4672
+ if (group.customGroupId) wrapper.dataset.customGroupId = group.customGroupId;
4673
+ bindTerminalTabDragAndDrop(wrapper, { sourceTabId: activeGroupTab?.id || "", target: { type: "group", group } });
4374
4674
  wrapper.addEventListener("pointerenter", () => setOpenTerminalTabGroup(group.key));
4375
4675
  wrapper.addEventListener("pointerleave", () => clearOpenTerminalTabGroup(group.key));
4376
4676
  wrapper.addEventListener("focusin", () => setOpenTerminalTabGroup(group.key));
@@ -4381,21 +4681,23 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4381
4681
  });
4382
4682
  const button = make("button", "terminal-tab-button terminal-tab-group-button");
4383
4683
  button.type = "button";
4684
+ button.draggable = false;
4384
4685
  button.setAttribute("role", "tab");
4385
4686
  button.setAttribute("aria-selected", isActive ? "true" : "false");
4386
4687
  button.setAttribute("aria-haspopup", "true");
4387
4688
  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 });
4689
+ button.setAttribute("aria-label", `${groupTitle} ${group.custom ? "custom" : "cwd"} group: ${groupTabs.length} tabs, ${indicator.label}. Active ${activeTitle}`);
4690
+ button.title = `${activeTitle} · ${groupTitle} · ${groupDetail} · ${groupTabs.length} tabs · ${indicator.label} · drop tabs here to add to group`;
4691
+ appendTerminalTabContent(button, { title: activeTitle, indicator, meta: `${groupTitle} · ${indicator.meta}`, count: groupTabs.length });
4391
4692
  button.addEventListener("click", () => switchTab(activeGroupTab.id));
4392
4693
  wrapper.append(button);
4393
4694
 
4394
- if (groupCount > 1) {
4695
+ if (groupCount > 1 || group.custom) {
4395
4696
  const close = make("button", "terminal-tab-close terminal-tab-group-close", "×");
4396
4697
  close.type = "button";
4397
- close.title = `Close ${displayCwd} group`;
4398
- close.setAttribute("aria-label", `Close ${displayCwd} group`);
4698
+ close.draggable = false;
4699
+ close.title = `Close ${groupTitle} group`;
4700
+ close.setAttribute("aria-label", `Close ${groupTitle} group`);
4399
4701
  close.addEventListener("click", (event) => {
4400
4702
  event.stopPropagation();
4401
4703
  closeTerminalTabGroup(group);
@@ -4405,16 +4707,17 @@ function renderTerminalTabGroup(group, groupCount = 1) {
4405
4707
 
4406
4708
  const menu = make("div", "terminal-tab-group-menu");
4407
4709
  menu.setAttribute("role", "group");
4408
- menu.setAttribute("aria-label", `${displayCwd} tabs`);
4409
- for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab));
4710
+ menu.setAttribute("aria-label", `${groupTitle} tabs`);
4711
+ for (const tab of groupTabs) menu.append(renderTerminalTabGroupItem(tab, group));
4410
4712
 
4411
4713
  const add = make("button", "terminal-tab-group-add", "+ Tab");
4412
4714
  add.type = "button";
4413
- add.title = `Add tab in ${displayCwd}`;
4414
- add.setAttribute("aria-label", `Add tab in ${displayCwd}`);
4715
+ add.draggable = false;
4716
+ add.title = `Add tab in ${groupTitle}`;
4717
+ add.setAttribute("aria-label", `Add tab in ${groupTitle}`);
4415
4718
  add.addEventListener("click", (event) => {
4416
4719
  event.stopPropagation();
4417
- createTerminalTab(group.cwd, { triggerButton: add });
4720
+ createTerminalTab(activeGroupTab?.cwd || group.cwd || currentDirectoryForNewTab(), { triggerButton: add, customGroupId: group.customGroupId || null });
4418
4721
  });
4419
4722
  menu.append(add);
4420
4723
  wrapper.append(menu);
@@ -4543,7 +4846,7 @@ function currentDirectoryForNewTab() {
4543
4846
  return latestWorkspace?.cwd || activeTab()?.cwd || "";
4544
4847
  }
4545
4848
 
4546
- async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton } = {}) {
4849
+ async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton, customGroupId = null } = {}) {
4547
4850
  setMobileTabsExpanded(false);
4548
4851
  setNewTabMenuOpen(false);
4549
4852
  const resolvedCwd = cwd || currentDirectoryForNewTab();
@@ -4558,6 +4861,7 @@ async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerBut
4558
4861
  tabs = response.data?.tabs || tabs;
4559
4862
  syncTabMetadata(tabs);
4560
4863
  const tab = response.data?.tab;
4864
+ if (tab?.id && customGroupId && terminalCustomGroups.has(customGroupId)) addTabsToTerminalCustomGroup(customGroupId, [tab.id]);
4561
4865
  renderTabs();
4562
4866
  if (tab?.id) {
4563
4867
  await switchTab(tab.id);
@@ -4657,6 +4961,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
4657
4961
  appRunnerDataByTab.delete(id);
4658
4962
  tabMessagesCache.delete(id);
4659
4963
  }
4964
+ syncTerminalCustomGroupsWithTabs(tabs);
4660
4965
  clearOpenTerminalTabGroup(null, { force: true });
4661
4966
 
4662
4967
  const activeTabNeedsFallback = closedIds.includes(activeTabId) || !tabs.some((item) => item.id === activeTabId);
@@ -4689,7 +4994,7 @@ async function closeTerminalTab(tabId) {
4689
4994
  }
4690
4995
 
4691
4996
  async function closeTerminalTabGroup(group) {
4692
- const title = tabGroupTitle(group.cwd, group.tabs[0]?.title || "cwd");
4997
+ const title = terminalDisplayGroupTitle(group, group.tabs[0]?.title || "group");
4693
4998
  await closeTerminalTabs(group.tabs.map((tab) => tab.id), { label: `${title} group` });
4694
4999
  }
4695
5000
 
@@ -18549,6 +18854,7 @@ initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast
18549
18854
  restoreAgentDoneNotificationsSetting();
18550
18855
  restoreThinkingVisibilitySetting();
18551
18856
  restoreTerminalTabsLayoutSetting();
18857
+ restoreTerminalCustomGroups();
18552
18858
  restoreToolOutputExpansionSetting();
18553
18859
  restoreWorkspaceDashboardState();
18554
18860
  restoreSidePanelSectionState();
package/public/styles.css CHANGED
@@ -1307,6 +1307,24 @@ body.side-panel-collapsed .terminal-tabs-shell {
1307
1307
  isolation: isolate;
1308
1308
  background: var(--ctp-crust);
1309
1309
  }
1310
+ .terminal-tab[draggable="true"],
1311
+ .terminal-tab-group-item[draggable="true"] {
1312
+ cursor: grab;
1313
+ }
1314
+ .terminal-tab.terminal-tab-dragging,
1315
+ .terminal-tab-group-item.terminal-tab-dragging {
1316
+ cursor: grabbing;
1317
+ opacity: 0.68;
1318
+ transform: scale(0.985);
1319
+ }
1320
+ .terminal-tab-custom-group {
1321
+ border-color: rgba(203, 166, 247, 0.34);
1322
+ }
1323
+ .terminal-tab-custom-group .terminal-tab-group-count {
1324
+ color: var(--ctp-mauve);
1325
+ border-color: rgba(203, 166, 247, 0.30);
1326
+ background: rgba(203, 166, 247, 0.12);
1327
+ }
1310
1328
  .terminal-tab-group:hover,
1311
1329
  .terminal-tab-group:focus-within {
1312
1330
  z-index: 100;
@@ -1432,6 +1450,15 @@ body.side-panel-collapsed .terminal-tabs-shell {
1432
1450
  border-color: rgba(166, 227, 161, 0.52);
1433
1451
  box-shadow: 0 0 1rem rgba(166, 227, 161, 0.16), inset 0 1px 0 rgba(255,255,255,0.045);
1434
1452
  }
1453
+ .terminal-tab.terminal-tab-drag-over,
1454
+ .terminal-tab-group-item.terminal-tab-drag-over {
1455
+ border-color: rgba(137, 180, 250, 0.82);
1456
+ box-shadow: 0 0 0 2px rgba(137, 180, 250, 0.34), 0 0 1.2rem rgba(137, 180, 250, 0.28), inset 0 1px 0 rgba(255,255,255,0.06);
1457
+ }
1458
+ .terminal-tab.terminal-tab-drag-over > .terminal-tab-button,
1459
+ .terminal-tab-group-item.terminal-tab-drag-over > .terminal-tab-button {
1460
+ background: linear-gradient(120deg, rgba(137, 180, 250, 0.18), rgba(203, 166, 247, 0.12));
1461
+ }
1435
1462
  .terminal-tab-button,
1436
1463
  .terminal-tab-close,
1437
1464
  .terminal-new-tab-button {
@@ -450,6 +450,9 @@ assert.match(server, /--cwd does not exist:/, "server should report nonexistent
450
450
  assert.match(server, /options\.cwd = await validateStartupCwd\(options\.cwd\)/, "server should fail fast for invalid startup cwd paths");
451
451
  assert.match(server, /cwdExplicit: false/, "server should track whether startup cwd was explicitly requested");
452
452
  assert.match(server, /return options\.cwdExplicit \? \[await createTab\(\)\] : \[\]/, "server should wait for UI cwd selection when no --cwd is supplied");
453
+ assert.match(server, /async function resolvedPiCliScript\(\)[\s\S]*require\.resolve\.paths\(PI_CODING_AGENT_PACKAGE\)[\s\S]*nodeModulesRoot[\s\S]*dist[\s\S]*cli\.js/, "server should resolve the bundled Pi CLI through Node resolution roots so hoisted global installs can spawn RPC tabs");
454
+ assert.match(server, /const bundledCli = await resolvedPiCliScript\(\)/, "standalone server should prefer the resolved Pi CLI script before falling back to PATH pi");
455
+ assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(\["update"\]\)/, "explicit --pi JavaScript launchers should also work for update commands");
453
456
  assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
454
457
  assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
455
458
  assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
@@ -940,7 +943,11 @@ assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\
940
943
  assert.match(app, /appendTerminalTabContent\(button, \{ title: activeTitle,[\s\S]*?count: groupTabs\.length \}\)/, "group buttons should show the active terminal name instead of only the cwd label");
941
944
  assert.match(app, /wrapper\.addEventListener\("pointerenter", \(\) => setOpenTerminalTabGroup\(group\.key\)\)/, "terminal tab groups should mark themselves open while hovered");
942
945
  assert.match(app, /if \(openTerminalTabGroupKey\) \{\n\s+scheduleRefreshTabs\(600\);/, "tab polling should defer full tab refreshes while a group menu is open");
943
- assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should only collapse cwd groups when multiple groups are available");
946
+ assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+if \(group\.custom\) return group\.tabs\.length > 1;\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should always render custom groups while only collapsing cwd groups when multiple groups are available");
947
+ assert.match(app, /TERMINAL_CUSTOM_GROUPS_STORAGE_KEY/, "frontend should persist custom terminal tab groups in browser storage");
948
+ assert.match(app, /function bindTerminalTabDragAndDrop\(/, "terminal tabs should bind drag-and-drop grouping behavior");
949
+ assert.match(app, /function handleTerminalTabDrop\(sourceTabId, target\)[\s\S]*?createTerminalCustomGroup/, "dropping a terminal tab onto another tab or group should create or update a custom group");
950
+ assert.match(css, /\.terminal-tab\.terminal-tab-drag-over,[\s\S]*?\.terminal-tab-group-item\.terminal-tab-drag-over/, "terminal tab drop targets should show drag-over affordance");
944
951
  assert.match(app, /function closeTerminalTabGroup\(group\)[\s\S]*?closeTerminalTabs\(group\.tabs\.map\(\(tab\) => tab\.id\)/, "terminal tab groups should be closable as a batch");
945
952
  assert.match(app, /function closeAllTerminalTabs\(\)[\s\S]*?closeTerminalTabs\(tabs\.map\(\(tab\) => tab\.id\)/, "tab header should close all terminal tabs as a batch");
946
953
  assert.match(app, /WARNING: \$\{activeAgentTabs\.length\}[\s\S]*?still running or waiting for input/, "tab close confirmations should warn when agents are still running");