@firstpick/pi-package-webui 0.4.5 → 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__:";
@@ -7545,7 +7546,7 @@ const server = createServer(async (req, res) => {
7545
7546
  maybeNameTabForConversation(tab, command);
7546
7547
  markTabWorking(tab);
7547
7548
  }
7548
- const response = await tab.rpc.send(command);
7549
+ const response = await tab.rpc.send(command, PROMPT_REQUEST_TIMEOUT_MS);
7549
7550
  if (response.success === false && startsVisibleWork) markTabIdle(tab);
7550
7551
  sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
7551
7552
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.5",
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,7 +53,7 @@
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",
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 {
File without changes
@@ -943,7 +943,11 @@ assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\
943
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");
944
944
  assert.match(app, /wrapper\.addEventListener\("pointerenter", \(\) => setOpenTerminalTabGroup\(group\.key\)\)/, "terminal tab groups should mark themselves open while hovered");
945
945
  assert.match(app, /if \(openTerminalTabGroupKey\) \{\n\s+scheduleRefreshTabs\(600\);/, "tab polling should defer full tab refreshes while a group menu is open");
946
- assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should only collapse cwd groups when multiple groups are available");
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");
947
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");
948
952
  assert.match(app, /function closeAllTerminalTabs\(\)[\s\S]*?closeTerminalTabs\(tabs\.map\(\(tab\) => tab\.id\)/, "tab header should close all terminal tabs as a batch");
949
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");