@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 +2 -1
- package/package.json +2 -2
- package/public/app.js +327 -21
- package/public/styles.css +27 -0
- package/tests/fixtures/fake-pi.mjs +0 -0
- package/tests/mobile-static.test.mjs +5 -1
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.
|
|
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.
|
|
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
|
|
4370
|
-
const activeTitle = activeGroupTab?.title ||
|
|
4371
|
-
const
|
|
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", `${
|
|
4389
|
-
button.title = `${activeTitle} · ${
|
|
4390
|
-
appendTerminalTabContent(button, { title: activeTitle, indicator, meta: `${
|
|
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.
|
|
4398
|
-
close.
|
|
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", `${
|
|
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.
|
|
4414
|
-
add.
|
|
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 =
|
|
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
|
|
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");
|