@co0ontty/wand 1.18.12 → 1.20.4
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/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +373 -0
- package/dist/process-manager.d.ts +4 -8
- package/dist/process-manager.js +12 -161
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +73 -1
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- package/dist/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +42 -0
- package/dist/web-ui/content/scripts.js +749 -141
- package/dist/web-ui/content/styles.css +334 -3
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
|
@@ -168,6 +168,16 @@
|
|
|
168
168
|
}
|
|
169
169
|
})(),
|
|
170
170
|
topbarMoreOpen: false,
|
|
171
|
+
gitStatus: null,
|
|
172
|
+
gitStatusSessionId: null,
|
|
173
|
+
gitStatusLoading: false,
|
|
174
|
+
gitStatusInflight: null,
|
|
175
|
+
gitStatusLastFetchAt: 0,
|
|
176
|
+
quickCommitOpen: false,
|
|
177
|
+
quickCommitSubmitting: false,
|
|
178
|
+
quickCommitGenerating: false,
|
|
179
|
+
quickCommitError: "",
|
|
180
|
+
quickCommitForm: { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false },
|
|
171
181
|
chatAutoFollow: (function() {
|
|
172
182
|
try {
|
|
173
183
|
var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
|
|
@@ -1023,6 +1033,11 @@
|
|
|
1023
1033
|
syncSessionModalUI();
|
|
1024
1034
|
}
|
|
1025
1035
|
}
|
|
1036
|
+
|
|
1037
|
+
// 初始加载或会话切换后惰性触发 git 状态拉取(loadGitStatus 自带节流)。
|
|
1038
|
+
if (isLoggedIn && state.selectedId && state.gitStatusSessionId !== state.selectedId) {
|
|
1039
|
+
loadGitStatus(state.selectedId);
|
|
1040
|
+
}
|
|
1026
1041
|
}
|
|
1027
1042
|
|
|
1028
1043
|
function renderShortcutKeys() {
|
|
@@ -1225,6 +1240,7 @@
|
|
|
1225
1240
|
'</div>' +
|
|
1226
1241
|
'<div class="topbar-right">' +
|
|
1227
1242
|
(selectedSession && selectedSession.cwd ? '<button id="topbar-file-button" class="topbar-btn square' + (state.filePanelOpen ? ' active' : '') + '" type="button" aria-label="文件" title="文件"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>' : '') +
|
|
1243
|
+
'<span id="topbar-git-slot" class="topbar-git-slot">' + renderTopbarGitBadgeHtml() + '</span>' +
|
|
1228
1244
|
'<div class="topbar-more-wrap">' +
|
|
1229
1245
|
'<button id="topbar-more-button" class="topbar-btn square' + (state.topbarMoreOpen ? ' active' : '') + '" type="button" aria-label="更多" aria-haspopup="menu" aria-expanded="' + (state.topbarMoreOpen ? 'true' : 'false') + '" title="更多"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>' +
|
|
1230
1246
|
'<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
|
|
@@ -1313,6 +1329,15 @@
|
|
|
1313
1329
|
'</div>' +
|
|
1314
1330
|
'</div>' +
|
|
1315
1331
|
'<div class="input-composer">' +
|
|
1332
|
+
'<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
|
|
1333
|
+
'<svg class="prompt-optimize-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
1334
|
+
'<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z" fill="currentColor" opacity="0.25"/>' +
|
|
1335
|
+
'<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z"/>' +
|
|
1336
|
+
'<path d="M19 14l.7 1.9L21.6 17l-1.9.7L19 19.6l-.7-1.9L16.4 17l1.9-.7z" fill="currentColor" opacity="0.35"/>' +
|
|
1337
|
+
'<path d="M5 4l.5 1.4L7 6l-1.5.6L5 8l-.5-1.4L3 6l1.5-.6z" fill="currentColor" opacity="0.35"/>' +
|
|
1338
|
+
'</svg>' +
|
|
1339
|
+
'<span class="prompt-optimize-spinner" aria-hidden="true"></span>' +
|
|
1340
|
+
'</button>' +
|
|
1316
1341
|
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1317
1342
|
'<div id="attachment-preview" class="attachment-preview hidden"></div>' +
|
|
1318
1343
|
'<div class="input-composer-bar">' +
|
|
@@ -1389,7 +1414,322 @@
|
|
|
1389
1414
|
'</section>' +
|
|
1390
1415
|
'</main>' +
|
|
1391
1416
|
'</div>' +
|
|
1392
|
-
'</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal();
|
|
1417
|
+
'</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal() + renderQuickCommitModal();
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function renderTopbarGitBadgeHtml() {
|
|
1421
|
+
if (!state.selectedId || !state.gitStatus || !state.gitStatus.isGit) return "";
|
|
1422
|
+
if (state.gitStatusSessionId !== state.selectedId) return "";
|
|
1423
|
+
var branch = state.gitStatus.branch || "?";
|
|
1424
|
+
var count = state.gitStatus.modifiedCount || 0;
|
|
1425
|
+
var titleText = branch + (count ? " · " + count + " 个文件待提交" : " · 工作区干净");
|
|
1426
|
+
return '<button id="topbar-git-badge" class="topbar-git-badge" type="button" title="' + escapeHtml(titleText) + '" aria-label="快捷提交">'
|
|
1427
|
+
+ '<svg class="topbar-git-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="9" r="2"/><path d="M6 8v8"/><path d="M18 11v1a3 3 0 0 1-3 3H9"/></svg>'
|
|
1428
|
+
+ '<span class="topbar-git-branch">' + escapeHtml(branch) + '</span>'
|
|
1429
|
+
+ (count > 0
|
|
1430
|
+
? '<span class="topbar-git-count">·' + count + '</span>'
|
|
1431
|
+
: '<span class="topbar-git-clean" aria-hidden="true">✓</span>')
|
|
1432
|
+
+ '</button>';
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function updateTopbarGitBadge() {
|
|
1436
|
+
var slot = document.getElementById("topbar-git-slot");
|
|
1437
|
+
if (!slot) return;
|
|
1438
|
+
slot.innerHTML = renderTopbarGitBadgeHtml();
|
|
1439
|
+
var btn = document.getElementById("topbar-git-badge");
|
|
1440
|
+
if (btn) {
|
|
1441
|
+
btn.addEventListener("click", function(e) {
|
|
1442
|
+
e.preventDefault();
|
|
1443
|
+
openQuickCommitModal();
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function loadGitStatus(sessionId, options) {
|
|
1449
|
+
if (!sessionId) return Promise.resolve(null);
|
|
1450
|
+
var force = options && options.force;
|
|
1451
|
+
// Same session, fetched within 1s, and no force → skip.
|
|
1452
|
+
var now = Date.now();
|
|
1453
|
+
if (!force && state.gitStatusSessionId === sessionId && state.gitStatus && (now - state.gitStatusLastFetchAt) < 1000) {
|
|
1454
|
+
return Promise.resolve(state.gitStatus);
|
|
1455
|
+
}
|
|
1456
|
+
if (state.gitStatusInflight && state.gitStatusInflight.sessionId === sessionId) {
|
|
1457
|
+
return state.gitStatusInflight.promise;
|
|
1458
|
+
}
|
|
1459
|
+
state.gitStatusLoading = true;
|
|
1460
|
+
var promise = fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/git-status", {
|
|
1461
|
+
credentials: "same-origin"
|
|
1462
|
+
})
|
|
1463
|
+
.then(function(res) { return res.ok ? res.json() : { isGit: false }; })
|
|
1464
|
+
.then(function(data) {
|
|
1465
|
+
state.gitStatus = data || { isGit: false };
|
|
1466
|
+
state.gitStatusSessionId = sessionId;
|
|
1467
|
+
state.gitStatusLastFetchAt = Date.now();
|
|
1468
|
+
updateTopbarGitBadge();
|
|
1469
|
+
return data;
|
|
1470
|
+
})
|
|
1471
|
+
.catch(function() {
|
|
1472
|
+
state.gitStatus = { isGit: false };
|
|
1473
|
+
state.gitStatusSessionId = sessionId;
|
|
1474
|
+
state.gitStatusLastFetchAt = Date.now();
|
|
1475
|
+
updateTopbarGitBadge();
|
|
1476
|
+
return null;
|
|
1477
|
+
})
|
|
1478
|
+
.finally(function() {
|
|
1479
|
+
state.gitStatusLoading = false;
|
|
1480
|
+
if (state.gitStatusInflight && state.gitStatusInflight.sessionId === sessionId) {
|
|
1481
|
+
state.gitStatusInflight = null;
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
state.gitStatusInflight = { sessionId: sessionId, promise: promise };
|
|
1485
|
+
return promise;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
var quickCommitEscHandler = null;
|
|
1489
|
+
|
|
1490
|
+
function openQuickCommitModal() {
|
|
1491
|
+
if (!state.selectedId) return;
|
|
1492
|
+
state.quickCommitOpen = true;
|
|
1493
|
+
state.quickCommitSubmitting = false;
|
|
1494
|
+
state.quickCommitError = "";
|
|
1495
|
+
state.quickCommitForm = { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
|
|
1496
|
+
closeWorktreeMergeModal();
|
|
1497
|
+
closeSessionModal();
|
|
1498
|
+
closeSettingsModal();
|
|
1499
|
+
rerenderQuickCommitModal();
|
|
1500
|
+
var modal = document.getElementById("quick-commit-modal");
|
|
1501
|
+
if (modal) {
|
|
1502
|
+
modal.classList.remove("hidden");
|
|
1503
|
+
lastFocusedElement = document.activeElement;
|
|
1504
|
+
setupFocusTrap(modal);
|
|
1505
|
+
}
|
|
1506
|
+
if (quickCommitEscHandler) document.removeEventListener("keydown", quickCommitEscHandler);
|
|
1507
|
+
quickCommitEscHandler = function(e) {
|
|
1508
|
+
if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting) {
|
|
1509
|
+
closeQuickCommitModal();
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
document.addEventListener("keydown", quickCommitEscHandler);
|
|
1513
|
+
loadGitStatus(state.selectedId, { force: true }).then(function() {
|
|
1514
|
+
if (!state.quickCommitOpen) return;
|
|
1515
|
+
rerenderQuickCommitModal();
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function closeQuickCommitModal() {
|
|
1520
|
+
state.quickCommitOpen = false;
|
|
1521
|
+
state.quickCommitSubmitting = false;
|
|
1522
|
+
state.quickCommitError = "";
|
|
1523
|
+
var modal = document.getElementById("quick-commit-modal");
|
|
1524
|
+
if (modal) modal.classList.add("hidden");
|
|
1525
|
+
if (focusTrapHandler) {
|
|
1526
|
+
document.removeEventListener("keydown", focusTrapHandler);
|
|
1527
|
+
focusTrapHandler = null;
|
|
1528
|
+
}
|
|
1529
|
+
if (quickCommitEscHandler) {
|
|
1530
|
+
document.removeEventListener("keydown", quickCommitEscHandler);
|
|
1531
|
+
quickCommitEscHandler = null;
|
|
1532
|
+
}
|
|
1533
|
+
if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
|
|
1534
|
+
lastFocusedElement.focus();
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function rerenderQuickCommitModal() {
|
|
1539
|
+
var modal = document.getElementById("quick-commit-modal");
|
|
1540
|
+
if (!modal) return;
|
|
1541
|
+
var html = renderQuickCommitModal();
|
|
1542
|
+
var temp = document.createElement("div");
|
|
1543
|
+
temp.innerHTML = html;
|
|
1544
|
+
var fresh = temp.querySelector("#quick-commit-modal");
|
|
1545
|
+
if (!fresh) return;
|
|
1546
|
+
modal.innerHTML = fresh.innerHTML;
|
|
1547
|
+
attachQuickCommitModalListeners();
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function attachQuickCommitModalListeners() {
|
|
1551
|
+
var closeBtn = document.getElementById("quick-commit-close-btn");
|
|
1552
|
+
if (closeBtn) closeBtn.addEventListener("click", closeQuickCommitModal);
|
|
1553
|
+
var cancelBtn = document.getElementById("quick-commit-cancel-btn");
|
|
1554
|
+
if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
|
|
1555
|
+
var submitBtn = document.getElementById("quick-commit-submit-btn");
|
|
1556
|
+
if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
|
|
1557
|
+
var aiBtn = document.getElementById("quick-commit-ai-btn");
|
|
1558
|
+
if (aiBtn) aiBtn.addEventListener("click", generateCommitMessageAI);
|
|
1559
|
+
var msgEl = document.getElementById("quick-commit-message");
|
|
1560
|
+
if (msgEl) msgEl.addEventListener("input", function() {
|
|
1561
|
+
state.quickCommitForm.customMessage = msgEl.value;
|
|
1562
|
+
});
|
|
1563
|
+
var tagCb = document.getElementById("quick-commit-make-tag");
|
|
1564
|
+
if (tagCb) tagCb.addEventListener("change", function() {
|
|
1565
|
+
state.quickCommitForm.makeTag = tagCb.checked;
|
|
1566
|
+
var row = document.getElementById("quick-commit-tag-row");
|
|
1567
|
+
if (row) row.classList.toggle("hidden", !tagCb.checked);
|
|
1568
|
+
});
|
|
1569
|
+
var tagInput = document.getElementById("quick-commit-tag");
|
|
1570
|
+
if (tagInput) tagInput.addEventListener("input", function() {
|
|
1571
|
+
state.quickCommitForm.tag = tagInput.value;
|
|
1572
|
+
});
|
|
1573
|
+
var pushCb = document.getElementById("quick-commit-push");
|
|
1574
|
+
if (pushCb) pushCb.addEventListener("change", function() {
|
|
1575
|
+
state.quickCommitForm.push = pushCb.checked;
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function generateCommitMessageAI() {
|
|
1580
|
+
if (!state.selectedId || state.quickCommitGenerating) return;
|
|
1581
|
+
var msgEl = document.getElementById("quick-commit-message");
|
|
1582
|
+
if (msgEl) state.quickCommitForm.customMessage = msgEl.value;
|
|
1583
|
+
state.quickCommitGenerating = true;
|
|
1584
|
+
state.quickCommitError = "";
|
|
1585
|
+
rerenderQuickCommitModal();
|
|
1586
|
+
fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/generate-commit-message", {
|
|
1587
|
+
method: "POST",
|
|
1588
|
+
credentials: "same-origin",
|
|
1589
|
+
headers: { "Content-Type": "application/json" },
|
|
1590
|
+
body: JSON.stringify({})
|
|
1591
|
+
})
|
|
1592
|
+
.then(function(res) {
|
|
1593
|
+
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
|
|
1594
|
+
})
|
|
1595
|
+
.then(function(result) {
|
|
1596
|
+
if (!result.ok) throw new Error((result.data && result.data.error) || "AI 生成失败。");
|
|
1597
|
+
state.quickCommitForm.customMessage = (result.data && result.data.message) || "";
|
|
1598
|
+
var currentMsgEl = document.getElementById("quick-commit-message");
|
|
1599
|
+
if (currentMsgEl) currentMsgEl.value = state.quickCommitForm.customMessage;
|
|
1600
|
+
})
|
|
1601
|
+
.catch(function(error) {
|
|
1602
|
+
state.quickCommitError = (error && error.message) || "AI 生成失败。";
|
|
1603
|
+
})
|
|
1604
|
+
.finally(function() {
|
|
1605
|
+
state.quickCommitGenerating = false;
|
|
1606
|
+
if (state.quickCommitOpen) rerenderQuickCommitModal();
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function submitQuickCommit() {
|
|
1611
|
+
if (!state.selectedId || state.quickCommitSubmitting) return;
|
|
1612
|
+
var msgEl = document.getElementById("quick-commit-message");
|
|
1613
|
+
if (msgEl) state.quickCommitForm.customMessage = msgEl.value;
|
|
1614
|
+
var form = state.quickCommitForm || {};
|
|
1615
|
+
var userTag = form.makeTag ? (form.tag || "").trim() : "";
|
|
1616
|
+
var message = (form.customMessage || "").trim();
|
|
1617
|
+
var payload = {
|
|
1618
|
+
autoMessage: false,
|
|
1619
|
+
customMessage: message,
|
|
1620
|
+
tag: userTag,
|
|
1621
|
+
autoTag: form.makeTag && !userTag,
|
|
1622
|
+
push: !!form.push
|
|
1623
|
+
};
|
|
1624
|
+
if (!message) {
|
|
1625
|
+
state.quickCommitError = "请填写 commit message,或点击 AI 生成。";
|
|
1626
|
+
rerenderQuickCommitModal();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
state.quickCommitSubmitting = true;
|
|
1630
|
+
state.quickCommitError = "";
|
|
1631
|
+
rerenderQuickCommitModal();
|
|
1632
|
+
fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/quick-commit", {
|
|
1633
|
+
method: "POST",
|
|
1634
|
+
credentials: "same-origin",
|
|
1635
|
+
headers: { "Content-Type": "application/json" },
|
|
1636
|
+
body: JSON.stringify(payload)
|
|
1637
|
+
})
|
|
1638
|
+
.then(function(res) {
|
|
1639
|
+
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
|
|
1640
|
+
})
|
|
1641
|
+
.then(function(result) {
|
|
1642
|
+
if (!result.ok) throw new Error((result.data && result.data.error) || "快捷提交失败。");
|
|
1643
|
+
var data = result.data || {};
|
|
1644
|
+
var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
|
|
1645
|
+
var tagName = data.tag && data.tag.name ? data.tag.name : "";
|
|
1646
|
+
var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
|
|
1647
|
+
var pushRequested = !!payload.push;
|
|
1648
|
+
if (pushRequested && data.pushError) {
|
|
1649
|
+
var msg = base + ";push 失败:" + data.pushError;
|
|
1650
|
+
if (typeof showToast === "function") showToast(msg, "error");
|
|
1651
|
+
} else {
|
|
1652
|
+
var okMsg = base + (data.pushed ? ",已 push" : "");
|
|
1653
|
+
if (typeof showToast === "function") showToast(okMsg, "success");
|
|
1654
|
+
}
|
|
1655
|
+
closeQuickCommitModal();
|
|
1656
|
+
if (state.selectedId) loadGitStatus(state.selectedId, { force: true });
|
|
1657
|
+
})
|
|
1658
|
+
.catch(function(error) {
|
|
1659
|
+
state.quickCommitError = (error && error.message) || "快捷提交失败。";
|
|
1660
|
+
})
|
|
1661
|
+
.finally(function() {
|
|
1662
|
+
state.quickCommitSubmitting = false;
|
|
1663
|
+
if (state.quickCommitOpen) rerenderQuickCommitModal();
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function renderQuickCommitModal() {
|
|
1668
|
+
var s = state.gitStatus || {};
|
|
1669
|
+
var f = state.quickCommitForm || { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
|
|
1670
|
+
var langValue = (state.config && (state.config.language || "")) || "";
|
|
1671
|
+
var langLabel = langValue ? langValue : "中文";
|
|
1672
|
+
var files = Array.isArray(s.files) ? s.files : [];
|
|
1673
|
+
var fileRows = files.map(function(item) {
|
|
1674
|
+
var status = (item.status || " ").substring(0, 2);
|
|
1675
|
+
var flag = status.trim() || "?";
|
|
1676
|
+
var cls = "qc-flag";
|
|
1677
|
+
if (flag === "A" || status[0] === "A") cls += " qc-flag-add";
|
|
1678
|
+
else if (flag === "D" || status[0] === "D") cls += " qc-flag-del";
|
|
1679
|
+
else if (flag === "M" || status[0] === "M") cls += " qc-flag-mod";
|
|
1680
|
+
else if (flag === "??" || status === "??") cls += " qc-flag-untracked";
|
|
1681
|
+
else if (flag === "R") cls += " qc-flag-ren";
|
|
1682
|
+
var subBadge = "";
|
|
1683
|
+
if (item.isSubmodule) {
|
|
1684
|
+
var st = item.submoduleState || {};
|
|
1685
|
+
var parts = [];
|
|
1686
|
+
if (st.commitChanged) parts.push("新指针");
|
|
1687
|
+
if (st.hasTrackedChanges) parts.push("dirty");
|
|
1688
|
+
if (st.hasUntracked) parts.push("未跟踪");
|
|
1689
|
+
var label = parts.length ? "submodule · " + parts.join(" / ") : "submodule";
|
|
1690
|
+
subBadge = '<span class="qc-submodule-badge">' + escapeHtml(label) + '</span>';
|
|
1691
|
+
}
|
|
1692
|
+
return '<div class="qc-file-row"><span class="' + cls + '">' + escapeHtml(status) + '</span><span class="qc-file-path">' + escapeHtml(item.path || "") + '</span>' + subBadge + '</div>';
|
|
1693
|
+
}).join("");
|
|
1694
|
+
if (!fileRows) fileRows = '<div class="qc-empty">工作区干净,没有可提交的改动。</div>';
|
|
1695
|
+
var hasChanges = (s.modifiedCount || 0) > 0;
|
|
1696
|
+
|
|
1697
|
+
return '<section id="quick-commit-modal" class="modal-backdrop' + (state.quickCommitOpen ? '' : ' hidden') + '">' +
|
|
1698
|
+
'<div class="modal quick-commit-modal" role="dialog" aria-labelledby="quick-commit-title">' +
|
|
1699
|
+
'<div class="modal-header">' +
|
|
1700
|
+
'<div>' +
|
|
1701
|
+
'<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
|
|
1702
|
+
'<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
|
|
1703
|
+
'</div>' +
|
|
1704
|
+
'<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon" type="button" aria-label="关闭">×</button>' +
|
|
1705
|
+
'</div>' +
|
|
1706
|
+
'<div class="modal-body">' +
|
|
1707
|
+
'<div class="qc-files-wrap">' + fileRows + '</div>' +
|
|
1708
|
+
'<div class="qc-message-row" id="quick-commit-message-row">' +
|
|
1709
|
+
'<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
|
|
1710
|
+
'<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
|
|
1711
|
+
'</div>' +
|
|
1712
|
+
'<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成">' + escapeHtml(f.customMessage || "") + '</textarea>' +
|
|
1713
|
+
'</div>' +
|
|
1714
|
+
'<label class="qc-checkbox-row">' +
|
|
1715
|
+
'<input type="checkbox" id="quick-commit-make-tag"' + (f.makeTag ? ' checked' : '') + '>' +
|
|
1716
|
+
'<span>提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</span>' +
|
|
1717
|
+
'</label>' +
|
|
1718
|
+
'<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
|
|
1719
|
+
'<input type="text" id="quick-commit-tag" class="field-input" placeholder="留空自动 bump patch' + (s.suggestedNextTag ? '(如 ' + escapeHtml(s.suggestedNextTag) + ')' : '') + '" value="' + escapeHtml(f.tag || "") + '">' +
|
|
1720
|
+
'</div>' +
|
|
1721
|
+
'<label class="qc-checkbox-row">' +
|
|
1722
|
+
'<input type="checkbox" id="quick-commit-push"' + (f.push ? ' checked' : '') + '>' +
|
|
1723
|
+
'<span>提交后 push 到远端</span>' +
|
|
1724
|
+
'</label>' +
|
|
1725
|
+
'<p id="quick-commit-error" class="error-message' + (state.quickCommitError ? '' : ' hidden') + '">' + escapeHtml(state.quickCommitError || "") + '</p>' +
|
|
1726
|
+
'<div class="worktree-merge-actions">' +
|
|
1727
|
+
'<button id="quick-commit-cancel-btn" class="btn btn-secondary" type="button">取消</button>' +
|
|
1728
|
+
'<button id="quick-commit-submit-btn" class="btn btn-primary" type="button"' + (hasChanges && !state.quickCommitSubmitting ? '' : ' disabled') + '>' + (state.quickCommitSubmitting ? '提交中…' : '执行') + '</button>' +
|
|
1729
|
+
'</div>' +
|
|
1730
|
+
'</div>' +
|
|
1731
|
+
'</div>' +
|
|
1732
|
+
'</section>';
|
|
1393
1733
|
}
|
|
1394
1734
|
|
|
1395
1735
|
function renderWorktreeMergeModal() {
|
|
@@ -3752,6 +4092,11 @@
|
|
|
3752
4092
|
fileInput.value = "";
|
|
3753
4093
|
});
|
|
3754
4094
|
}
|
|
4095
|
+
|
|
4096
|
+
var promptOptimizeBtn = document.getElementById("prompt-optimize-btn");
|
|
4097
|
+
if (promptOptimizeBtn) {
|
|
4098
|
+
promptOptimizeBtn.addEventListener("click", function() { optimizePromptText(); });
|
|
4099
|
+
}
|
|
3755
4100
|
var composer = document.querySelector(".input-composer");
|
|
3756
4101
|
if (composer) {
|
|
3757
4102
|
composer.addEventListener("dragover", function(e) {
|
|
@@ -3939,7 +4284,9 @@
|
|
|
3939
4284
|
location.reload();
|
|
3940
4285
|
return;
|
|
3941
4286
|
}
|
|
3942
|
-
|
|
4287
|
+
softResyncTerminal();
|
|
4288
|
+
resetChatRenderCache();
|
|
4289
|
+
scheduleChatRender(true);
|
|
3943
4290
|
});
|
|
3944
4291
|
var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
|
|
3945
4292
|
if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
|
|
@@ -4335,6 +4682,23 @@
|
|
|
4335
4682
|
});
|
|
4336
4683
|
}
|
|
4337
4684
|
|
|
4685
|
+
var topbarGitBadge = document.getElementById("topbar-git-badge");
|
|
4686
|
+
if (topbarGitBadge) {
|
|
4687
|
+
topbarGitBadge.addEventListener("click", function(e) {
|
|
4688
|
+
e.preventDefault();
|
|
4689
|
+
openQuickCommitModal();
|
|
4690
|
+
});
|
|
4691
|
+
}
|
|
4692
|
+
var quickCommitModal = document.getElementById("quick-commit-modal");
|
|
4693
|
+
if (quickCommitModal) {
|
|
4694
|
+
quickCommitModal.addEventListener("click", function(e) {
|
|
4695
|
+
if (e.target.id === "quick-commit-modal" && !state.quickCommitSubmitting) {
|
|
4696
|
+
closeQuickCommitModal();
|
|
4697
|
+
}
|
|
4698
|
+
});
|
|
4699
|
+
}
|
|
4700
|
+
attachQuickCommitModalListeners();
|
|
4701
|
+
|
|
4338
4702
|
initTerminal();
|
|
4339
4703
|
setupMobileKeyboardHandlers();
|
|
4340
4704
|
setupVisualViewportHandlers();
|
|
@@ -4927,48 +5291,41 @@
|
|
|
4927
5291
|
stripWideFillerForCopy();
|
|
4928
5292
|
|
|
4929
5293
|
function resetTerminal() {
|
|
4930
|
-
if (!state.terminal
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
//
|
|
4934
|
-
//
|
|
4935
|
-
//
|
|
4936
|
-
|
|
4937
|
-
|
|
5294
|
+
if (!state.terminal) return;
|
|
5295
|
+
// 优先走 wterm-entry.js 自定义 WTerm 子类暴露的 reset():它会调用
|
|
5296
|
+
// bridge.init(cols, rows) 让 WASM 重新初始化整个状态机——包含
|
|
5297
|
+
// grid、光标、属性 *和* scrollback。这是跨会话切换时清空旧
|
|
5298
|
+
// scrollback 的唯一可靠方式,避免新会话向上滚还能看到旧会话内容。
|
|
5299
|
+
// 单纯写 ANSI RIS (\x1bc) 在 WASM 实现里只清当前 grid,不动 scrollback。
|
|
5300
|
+
if (typeof state.terminal.reset === "function") {
|
|
5301
|
+
state.terminal.reset();
|
|
5302
|
+
resetWideParserState();
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
5305
|
+
if (typeof state.terminal.write === "function") {
|
|
5306
|
+
state.terminal.write("\x1bc");
|
|
5307
|
+
}
|
|
4938
5308
|
resetWideParserState();
|
|
4939
5309
|
}
|
|
4940
5310
|
|
|
4941
5311
|
// Soft resync terminal: reset WASM grid and replay full output buffer.
|
|
4942
5312
|
// Clears any stale DOM rows left over from CSI cursor-jump sequences
|
|
4943
5313
|
// (e.g. Claude permission menus redrawing in place while user holds arrow keys).
|
|
4944
|
-
|
|
5314
|
+
// Pass { skipFit: true } when the caller knows the grid was just sized
|
|
5315
|
+
// correctly (e.g. wterm.onResize fired this resync — bouncing back into
|
|
5316
|
+
// ensureTerminalFit would just trigger another remeasure → resize → onResize
|
|
5317
|
+
// → softResyncTerminal recursion).
|
|
5318
|
+
function softResyncTerminal(options) {
|
|
4945
5319
|
if (!state.terminal || !state.terminalOutput) return false;
|
|
5320
|
+
var opts = options || {};
|
|
4946
5321
|
resetTerminal();
|
|
4947
5322
|
wandTerminalWrite(state.terminal, state.terminalOutput);
|
|
4948
5323
|
state.lastTerminalResyncAt = Date.now();
|
|
4949
5324
|
maybeScrollTerminalToBottom("output");
|
|
4950
|
-
|
|
4951
|
-
// reset+write, so a stale cols/rows (set at mount time with hidden
|
|
4952
|
-
// container) would survive the refresh and keep wrapping output wrong.
|
|
4953
|
-
// Suppress the auto-replay branch in ensureTerminalFit — we just
|
|
4954
|
-
// replayed, no point doing it again on the next rAF tick.
|
|
4955
|
-
state.suppressFitReplay = true;
|
|
4956
|
-
ensureTerminalFit("refresh");
|
|
5325
|
+
if (!opts.skipFit) ensureTerminalFit("refresh");
|
|
4957
5326
|
return true;
|
|
4958
5327
|
}
|
|
4959
5328
|
|
|
4960
|
-
// Soft refresh the whole current view without losing page state:
|
|
4961
|
-
// - Replays terminal buffer to clear residue
|
|
4962
|
-
// - Clears chat render cache and forces a full rebuild
|
|
4963
|
-
// Used by the refresh button and by automatic triggers
|
|
4964
|
-
// (e.g. permission escalation appearing/disappearing).
|
|
4965
|
-
function softRefreshCurrentView() {
|
|
4966
|
-
softResyncTerminal();
|
|
4967
|
-
if (typeof resetChatRenderCache === "function") resetChatRenderCache();
|
|
4968
|
-
if (typeof scheduleChatRender === "function") scheduleChatRender(true);
|
|
4969
|
-
else if (typeof render === "function") render();
|
|
4970
|
-
}
|
|
4971
|
-
|
|
4972
5329
|
function scheduleSoftResyncTerminal(delayMs) {
|
|
4973
5330
|
if (state.softResyncTimer) clearTimeout(state.softResyncTimer);
|
|
4974
5331
|
state.softResyncTimer = setTimeout(function() {
|
|
@@ -4977,6 +5334,28 @@
|
|
|
4977
5334
|
}, typeof delayMs === "number" ? delayMs : 150);
|
|
4978
5335
|
}
|
|
4979
5336
|
|
|
5337
|
+
// Claude CLI 的 permission 菜单 / 选择列表,在用户按方向键时会
|
|
5338
|
+
// 发送光标定位 (CSI H/f)、光标移动 (CSI A-D)、擦除显示/行 (CSI
|
|
5339
|
+
// J/K) 等序列在原地重绘整块区域。wterm 在这种高频原地重绘下,
|
|
5340
|
+
// DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
|
|
5341
|
+
// 用户体感就是"明明在改菜单,结果跑到最上面去了"。
|
|
5342
|
+
//
|
|
5343
|
+
// 已有的 pendingEscalation/permissionBlocked 状态变化触发的
|
|
5344
|
+
// scheduleSoftResyncTerminal 在这种场景下不会触发(这两个布尔
|
|
5345
|
+
// 在菜单交互过程中不变),health check 的 30s 兜底也太慢,且
|
|
5346
|
+
// 连续按键时 chunkPause 永远不成立(lastChunkAt 一直在刷新)。
|
|
5347
|
+
//
|
|
5348
|
+
// 这里在写 chunk 时被动检测:含上述序列就 schedule 一次 350ms
|
|
5349
|
+
// debounce 的 softResync。连续按键时 timer 反复被重置,仅在
|
|
5350
|
+
// 停顿后真正重放一次 buffer,开销可控。
|
|
5351
|
+
var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
|
|
5352
|
+
function maybeScheduleResyncForChunk(chunk) {
|
|
5353
|
+
if (!chunk || typeof chunk !== "string") return;
|
|
5354
|
+
if (chunk.indexOf("\x1b[") === -1) return;
|
|
5355
|
+
if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
|
|
5356
|
+
scheduleSoftResyncTerminal(350);
|
|
5357
|
+
}
|
|
5358
|
+
|
|
4980
5359
|
function syncTerminalBuffer(sessionId, output, options) {
|
|
4981
5360
|
if (!state.terminal) return false;
|
|
4982
5361
|
var normalizedOutput = normalizeTerminalOutput(output || "");
|
|
@@ -5020,6 +5399,7 @@
|
|
|
5020
5399
|
var delta = normalizedOutput.slice(currentOutput.length);
|
|
5021
5400
|
if (delta) {
|
|
5022
5401
|
wandTerminalWrite(state.terminal, delta);
|
|
5402
|
+
maybeScheduleResyncForChunk(delta);
|
|
5023
5403
|
wrote = true;
|
|
5024
5404
|
}
|
|
5025
5405
|
} else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
|
|
@@ -5059,6 +5439,28 @@
|
|
|
5059
5439
|
state.terminalInitRetries = 0;
|
|
5060
5440
|
state.terminalInitializing = true;
|
|
5061
5441
|
|
|
5442
|
+
// wterm 构造与 init() 内部都通过 getBoundingClientRect 测字符宽高,
|
|
5443
|
+
// 要求容器及祖先链都不是 display:none。.terminal-container 默认
|
|
5444
|
+
// display:none,必须 .active 才变 flex。switchToSessionView 里
|
|
5445
|
+
// initTerminal() 在 applyCurrentView() 之前同步执行——那时容器还是
|
|
5446
|
+
// display:none,_measureCharSize 返回 null → ResizeObserver 不挂
|
|
5447
|
+
// 载、首屏 cols 永远停在硬编码的 120,必须用户刷新/弹键盘/调窗口
|
|
5448
|
+
// 才能恢复。这里在创建 wterm 之前先把 active 类挂上,让容器进入
|
|
5449
|
+
// flex 布局,确保 _measureCharSize 拿到真实字符尺寸。
|
|
5450
|
+
if (state.selectedId) {
|
|
5451
|
+
container.classList.remove("hidden");
|
|
5452
|
+
container.classList.add("active");
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
// 防御式清理:teardownTerminal 已经会移除残留 termWrap,但若有
|
|
5456
|
+
// 调用路径绕过 teardown(比如 outputContainer 被外部 render 重建),
|
|
5457
|
+
// 这里再扫一次确保新会话不会和旧 termWrap 叠在同一位置。
|
|
5458
|
+
var staleWraps = container.querySelectorAll(".terminal-scroll-wrap");
|
|
5459
|
+
for (var i = 0; i < staleWraps.length; i++) {
|
|
5460
|
+
var stale = staleWraps[i];
|
|
5461
|
+
if (stale.parentNode === container) container.removeChild(stale);
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5062
5464
|
var termWrap = document.createElement("div");
|
|
5063
5465
|
termWrap.className = "terminal-scroll-wrap";
|
|
5064
5466
|
container.appendChild(termWrap);
|
|
@@ -5074,18 +5476,15 @@
|
|
|
5074
5476
|
},
|
|
5075
5477
|
onResize: function(cols, rows) {
|
|
5076
5478
|
sendTerminalResize(cols, rows);
|
|
5077
|
-
// wterm
|
|
5078
|
-
// bridge.resize()
|
|
5079
|
-
//
|
|
5080
|
-
//
|
|
5081
|
-
//
|
|
5082
|
-
//
|
|
5083
|
-
//
|
|
5084
|
-
// 的下一帧 render 之后,结果用户先看到一帧空 grid 才看到
|
|
5085
|
-
// replay 完的内容,体感上就是"刷新都没用、动一下窗口才好"。
|
|
5479
|
+
// wterm.resize() just ran renderer.setup() (DOM rows wiped) and
|
|
5480
|
+
// bridge.resize() (WASM grid reflowed). terminalOutput is the
|
|
5481
|
+
// canonical raw byte stream — replay it now so historical lines
|
|
5482
|
+
// and any in-flight CSI sequences re-render at the new width.
|
|
5483
|
+
// skipFit: wterm already did the sizing work; calling
|
|
5484
|
+
// ensureTerminalFit again here would just cycle back through
|
|
5485
|
+
// remeasure → resize → onResize → softResyncTerminal.
|
|
5086
5486
|
if (state.terminal && state.terminalOutput) {
|
|
5087
|
-
|
|
5088
|
-
softResyncTerminal();
|
|
5487
|
+
softResyncTerminal({ skipFit: true });
|
|
5089
5488
|
}
|
|
5090
5489
|
}
|
|
5091
5490
|
});
|
|
@@ -5152,7 +5551,7 @@
|
|
|
5152
5551
|
// Container may have been hidden / zero-width at construction
|
|
5153
5552
|
// time (hard-coded 120x36). Remeasure against the real container
|
|
5154
5553
|
// so wterm reflows the just-written history to the correct cols.
|
|
5155
|
-
ensureTerminalFit("mount");
|
|
5554
|
+
ensureTerminalFit("mount", { forceReplay: true });
|
|
5156
5555
|
}).catch(function(err) {
|
|
5157
5556
|
state.terminalInitializing = false;
|
|
5158
5557
|
console.error("[wand] wterm init failed:", err);
|
|
@@ -5812,6 +6211,9 @@
|
|
|
5812
6211
|
}
|
|
5813
6212
|
}
|
|
5814
6213
|
updateShellChrome();
|
|
6214
|
+
if (state.selectedId && state.gitStatusSessionId !== state.selectedId) {
|
|
6215
|
+
loadGitStatus(state.selectedId);
|
|
6216
|
+
}
|
|
5815
6217
|
|
|
5816
6218
|
var reloadPromise = Promise.resolve();
|
|
5817
6219
|
if (!opts.skipSelectedOutputReload && state.selectedId) {
|
|
@@ -5973,10 +6375,25 @@
|
|
|
5973
6375
|
updateShellChrome();
|
|
5974
6376
|
|
|
5975
6377
|
if (state.terminal && id === state.selectedId && data.output !== undefined) {
|
|
5976
|
-
|
|
5977
|
-
//
|
|
5978
|
-
//
|
|
5979
|
-
|
|
6378
|
+
// ws 在线时不要在这里写终端:HTTP 这边返回的是 PTY transcript
|
|
6379
|
+
// 完整磁盘文件(可达数十 MB),ws 订阅 init 拿到的是内存 ring
|
|
6380
|
+
// buffer 末尾窗口(约 200KB),二者长度+起点都不同。两路都
|
|
6381
|
+
// syncTerminalBuffer 时,append 模式的前缀检查必然失败,
|
|
6382
|
+
// 落到 else 分支的 reset+全量重写,与 ws init 的 reset+
|
|
6383
|
+
// 写入交叠,造成首屏「两份内容错位重叠」。
|
|
6384
|
+
// 设计原则:terminal 写入只走 ws init 与 chunk hot-path 两条
|
|
6385
|
+
// 权威路径——参见 case "init" 的 replace 写入与 onmessage
|
|
6386
|
+
// chunk 处理。这里只在 ws 离线兜底时才 append 写入。
|
|
6387
|
+
if (!state.wsConnected) {
|
|
6388
|
+
syncTerminalBuffer(id, data.output, { mode: "append" });
|
|
6389
|
+
// 离线兜底路径自己负责 fit + replay,否则尺寸不对。
|
|
6390
|
+
ensureTerminalFit("session-switch", { forceReplay: true });
|
|
6391
|
+
} else {
|
|
6392
|
+
// ws 在线场景:仅校准列宽,不重 replay(init 的
|
|
6393
|
+
// ensureTerminalFitWithRetry("init") 会负责按真实
|
|
6394
|
+
// 宽度的全量基线写入)。
|
|
6395
|
+
ensureTerminalFit("session-switch");
|
|
6396
|
+
}
|
|
5980
6397
|
}
|
|
5981
6398
|
|
|
5982
6399
|
var selectedSession = state.sessions.find(function(s) { return s.id === id; });
|
|
@@ -5991,6 +6408,9 @@
|
|
|
5991
6408
|
if (!foundSession) {
|
|
5992
6409
|
return;
|
|
5993
6410
|
}
|
|
6411
|
+
if (state.selectedId !== id) {
|
|
6412
|
+
teardownTerminal();
|
|
6413
|
+
}
|
|
5994
6414
|
state.selectedId = id;
|
|
5995
6415
|
persistSelectedId();
|
|
5996
6416
|
state.toolContentCache = {};
|
|
@@ -6021,6 +6441,11 @@
|
|
|
6021
6441
|
}
|
|
6022
6442
|
loadOutput(id).then(function() { focusInputBox(true); });
|
|
6023
6443
|
subscribeToSession(id);
|
|
6444
|
+
// 切换会话时清掉旧 git 状态,再异步刷新
|
|
6445
|
+
state.gitStatus = null;
|
|
6446
|
+
state.gitStatusSessionId = null;
|
|
6447
|
+
updateTopbarGitBadge();
|
|
6448
|
+
loadGitStatus(id, { force: true });
|
|
6024
6449
|
}
|
|
6025
6450
|
|
|
6026
6451
|
function updatePinState() {
|
|
@@ -7914,6 +8339,148 @@
|
|
|
7914
8339
|
}
|
|
7915
8340
|
}
|
|
7916
8341
|
|
|
8342
|
+
function createOptimizeShimmer(inputBox, text) {
|
|
8343
|
+
var composer = inputBox.closest(".input-composer");
|
|
8344
|
+
if (!composer) return null;
|
|
8345
|
+
// 用 mirror 元素同步 textarea 的文字 + 字体 + 排版参数,叠在
|
|
8346
|
+
// textarea 之上。mirror 自身 color: transparent + background-clip:
|
|
8347
|
+
// text,让动画渐变只在文字字符形状内显示——空白处完全透明,所以
|
|
8348
|
+
// 视觉上只有"几个字"被光扫过,像魔法橡皮擦掠过文字本身,而不是
|
|
8349
|
+
// 整个输入框背景在闪。
|
|
8350
|
+
var rect = inputBox.getBoundingClientRect();
|
|
8351
|
+
var composerRect = composer.getBoundingClientRect();
|
|
8352
|
+
var style = window.getComputedStyle(inputBox);
|
|
8353
|
+
var mirror = document.createElement("div");
|
|
8354
|
+
mirror.className = "prompt-optimize-shimmer-overlay";
|
|
8355
|
+
mirror.textContent = text;
|
|
8356
|
+
// 与 textarea 同坐标同尺寸(composer 是 position:relative,所以
|
|
8357
|
+
// 用相对 composer 的 offset;不直接用 inputBox.offsetTop 是因为
|
|
8358
|
+
// textarea 可能有变换/包裹元素,getBoundingClientRect 更稳)。
|
|
8359
|
+
mirror.style.top = (rect.top - composerRect.top) + "px";
|
|
8360
|
+
mirror.style.left = (rect.left - composerRect.left) + "px";
|
|
8361
|
+
mirror.style.width = rect.width + "px";
|
|
8362
|
+
mirror.style.height = rect.height + "px";
|
|
8363
|
+
// 复制 textarea 的字符排版,确保 mirror 上的字符与 textarea 渲染
|
|
8364
|
+
// 的字符像素级对齐(错位会让"光从文字扫过"看起来糊掉)。
|
|
8365
|
+
mirror.style.fontFamily = style.fontFamily;
|
|
8366
|
+
mirror.style.fontSize = style.fontSize;
|
|
8367
|
+
mirror.style.fontWeight = style.fontWeight;
|
|
8368
|
+
mirror.style.fontStyle = style.fontStyle;
|
|
8369
|
+
mirror.style.lineHeight = style.lineHeight;
|
|
8370
|
+
mirror.style.letterSpacing = style.letterSpacing;
|
|
8371
|
+
mirror.style.wordSpacing = style.wordSpacing;
|
|
8372
|
+
mirror.style.textAlign = style.textAlign;
|
|
8373
|
+
mirror.style.textIndent = style.textIndent;
|
|
8374
|
+
mirror.style.paddingTop = style.paddingTop;
|
|
8375
|
+
mirror.style.paddingRight = style.paddingRight;
|
|
8376
|
+
mirror.style.paddingBottom = style.paddingBottom;
|
|
8377
|
+
mirror.style.paddingLeft = style.paddingLeft;
|
|
8378
|
+
mirror.style.boxSizing = style.boxSizing;
|
|
8379
|
+
composer.appendChild(mirror);
|
|
8380
|
+
return mirror;
|
|
8381
|
+
}
|
|
8382
|
+
|
|
8383
|
+
var promptOptimizeInFlight = false;
|
|
8384
|
+
function optimizePromptText() {
|
|
8385
|
+
if (promptOptimizeInFlight) return;
|
|
8386
|
+
var inputBox = document.getElementById("input-box");
|
|
8387
|
+
var btn = document.getElementById("prompt-optimize-btn");
|
|
8388
|
+
var composer = document.querySelector(".input-composer");
|
|
8389
|
+
if (!inputBox) return;
|
|
8390
|
+
var raw = (inputBox.value || "").trim();
|
|
8391
|
+
if (!raw) {
|
|
8392
|
+
if (typeof showToast === "function") showToast("请先输入要优化的内容。", "info");
|
|
8393
|
+
inputBox.focus();
|
|
8394
|
+
return;
|
|
8395
|
+
}
|
|
8396
|
+
promptOptimizeInFlight = true;
|
|
8397
|
+
if (btn) {
|
|
8398
|
+
btn.classList.add("is-loading");
|
|
8399
|
+
btn.disabled = true;
|
|
8400
|
+
btn.setAttribute("title", "正在优化…");
|
|
8401
|
+
}
|
|
8402
|
+
if (composer) composer.classList.add("is-optimizing");
|
|
8403
|
+
var shimmerOverlay = createOptimizeShimmer(inputBox, raw);
|
|
8404
|
+
inputBox.setAttribute("aria-busy", "true");
|
|
8405
|
+
var prevReadOnly = inputBox.readOnly;
|
|
8406
|
+
inputBox.readOnly = true;
|
|
8407
|
+
|
|
8408
|
+
var payload = { text: raw };
|
|
8409
|
+
if (state && state.selectedId) payload.sessionId = state.selectedId;
|
|
8410
|
+
|
|
8411
|
+
fetch("/api/optimize-prompt", {
|
|
8412
|
+
method: "POST",
|
|
8413
|
+
credentials: "same-origin",
|
|
8414
|
+
headers: { "Content-Type": "application/json" },
|
|
8415
|
+
body: JSON.stringify(payload)
|
|
8416
|
+
})
|
|
8417
|
+
.then(function(res) {
|
|
8418
|
+
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
|
|
8419
|
+
})
|
|
8420
|
+
.then(function(result) {
|
|
8421
|
+
if (!result.ok) throw new Error((result.data && result.data.error) || "提示词优化失败。");
|
|
8422
|
+
var optimized = (result.data && result.data.optimized) || "";
|
|
8423
|
+
if (!optimized) throw new Error("Claude 返回为空。");
|
|
8424
|
+
animateOptimizedReplace(inputBox, optimized);
|
|
8425
|
+
})
|
|
8426
|
+
.catch(function(error) {
|
|
8427
|
+
if (typeof showToast === "function") showToast((error && error.message) || "提示词优化失败。", "error");
|
|
8428
|
+
if (btn) {
|
|
8429
|
+
btn.classList.remove("is-loading");
|
|
8430
|
+
btn.classList.add("is-shake");
|
|
8431
|
+
setTimeout(function() { if (btn) btn.classList.remove("is-shake"); }, 400);
|
|
8432
|
+
}
|
|
8433
|
+
})
|
|
8434
|
+
.finally(function() {
|
|
8435
|
+
promptOptimizeInFlight = false;
|
|
8436
|
+
if (btn) {
|
|
8437
|
+
btn.classList.remove("is-loading");
|
|
8438
|
+
btn.disabled = false;
|
|
8439
|
+
btn.setAttribute("title", "提示词优化(AI)");
|
|
8440
|
+
}
|
|
8441
|
+
if (composer) composer.classList.remove("is-optimizing");
|
|
8442
|
+
if (shimmerOverlay && shimmerOverlay.parentNode) {
|
|
8443
|
+
shimmerOverlay.parentNode.removeChild(shimmerOverlay);
|
|
8444
|
+
}
|
|
8445
|
+
inputBox.removeAttribute("aria-busy");
|
|
8446
|
+
inputBox.readOnly = prevReadOnly;
|
|
8447
|
+
});
|
|
8448
|
+
}
|
|
8449
|
+
|
|
8450
|
+
function animateOptimizedReplace(inputBox, finalText) {
|
|
8451
|
+
if (!inputBox) return;
|
|
8452
|
+
// Typewriter-style fill so user sees the replacement happen
|
|
8453
|
+
var chars = Array.from(finalText);
|
|
8454
|
+
var total = chars.length;
|
|
8455
|
+
if (total === 0) {
|
|
8456
|
+
inputBox.value = "";
|
|
8457
|
+
setDraftValue("", true);
|
|
8458
|
+
autoResizeInput(inputBox);
|
|
8459
|
+
return;
|
|
8460
|
+
}
|
|
8461
|
+
var totalDuration = Math.min(700, Math.max(220, total * 8));
|
|
8462
|
+
var stepCount = Math.min(total, 60);
|
|
8463
|
+
var charsPerStep = Math.ceil(total / stepCount);
|
|
8464
|
+
var stepDelay = totalDuration / stepCount;
|
|
8465
|
+
var i = 0;
|
|
8466
|
+
inputBox.value = "";
|
|
8467
|
+
autoResizeInput(inputBox);
|
|
8468
|
+
function tick() {
|
|
8469
|
+
i = Math.min(total, i + charsPerStep);
|
|
8470
|
+
inputBox.value = chars.slice(0, i).join("");
|
|
8471
|
+
autoResizeInput(inputBox);
|
|
8472
|
+
if (i < total) {
|
|
8473
|
+
setTimeout(tick, stepDelay);
|
|
8474
|
+
} else {
|
|
8475
|
+
setDraftValue(finalText, true);
|
|
8476
|
+
try { inputBox.setSelectionRange(finalText.length, finalText.length); } catch (e) { /* ignore */ }
|
|
8477
|
+
inputBox.classList.add("optimize-flash");
|
|
8478
|
+
setTimeout(function() { inputBox.classList.remove("optimize-flash"); }, 900);
|
|
8479
|
+
}
|
|
8480
|
+
}
|
|
8481
|
+
tick();
|
|
8482
|
+
}
|
|
8483
|
+
|
|
7917
8484
|
function autoResizeInput(el) {
|
|
7918
8485
|
if (!el) return;
|
|
7919
8486
|
var minHeight = 36;
|
|
@@ -8352,7 +8919,7 @@
|
|
|
8352
8919
|
// Container just flipped from hidden -> visible (or geometry changed
|
|
8353
8920
|
// because chat/terminal panels swapped). Refit now so the terminal
|
|
8354
8921
|
// picks up the real cols/rows instead of keeping the stale ones.
|
|
8355
|
-
if (!structured) ensureTerminalFit("view-switch");
|
|
8922
|
+
if (!structured) ensureTerminalFit("view-switch", { forceReplay: true });
|
|
8356
8923
|
}
|
|
8357
8924
|
|
|
8358
8925
|
|
|
@@ -8413,7 +8980,8 @@
|
|
|
8413
8980
|
|
|
8414
8981
|
return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
|
|
8415
8982
|
if (!readySession) {
|
|
8416
|
-
|
|
8983
|
+
// ensureSessionReadyForInput / resumeClaudeSessionById 已经在失败路径里
|
|
8984
|
+
// 自行 toast,这里不再重复提示,避免叠两条消息。
|
|
8417
8985
|
return null;
|
|
8418
8986
|
}
|
|
8419
8987
|
var submitView = state.currentView;
|
|
@@ -8646,7 +9214,10 @@
|
|
|
8646
9214
|
}
|
|
8647
9215
|
|
|
8648
9216
|
function canAutoResumeSession(session) {
|
|
8649
|
-
|
|
9217
|
+
// 只要是 Claude provider + 非运行中 + 有 claudeSessionId,
|
|
9218
|
+
// 就允许在用户发送时静默触发恢复。不再要求 messages 里同时
|
|
9219
|
+
// 有 user + assistant 文本(slim 列表/截断历史会让该判断失真)。
|
|
9220
|
+
return !!(session && session.provider === "claude" && session.status !== "running" && session.claudeSessionId);
|
|
8650
9221
|
}
|
|
8651
9222
|
|
|
8652
9223
|
function ensureSessionReadyForInput(session, errorEl) {
|
|
@@ -8663,7 +9234,7 @@
|
|
|
8663
9234
|
return Promise.resolve(null);
|
|
8664
9235
|
}
|
|
8665
9236
|
|
|
8666
|
-
|
|
9237
|
+
// 静默恢复:不再弹 "正在恢复历史会话…" 提示,让用户发送动作看起来无缝。
|
|
8667
9238
|
return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
|
|
8668
9239
|
if (!data) return null;
|
|
8669
9240
|
updateSessionSnapshot(data);
|
|
@@ -9016,17 +9587,20 @@
|
|
|
9016
9587
|
}
|
|
9017
9588
|
}
|
|
9018
9589
|
var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
|
|
9590
|
+
// 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
|
|
9591
|
+
// 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
|
|
9592
|
+
var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
|
|
9019
9593
|
if (composer) {
|
|
9020
9594
|
composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
|
|
9021
|
-
composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
|
|
9595
|
+
composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
|
|
9022
9596
|
composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
|
|
9023
9597
|
}
|
|
9024
9598
|
var sendBtn = document.getElementById("send-input-button");
|
|
9025
9599
|
if (sendBtn) {
|
|
9026
|
-
sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
|
|
9600
|
+
sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
|
|
9027
9601
|
sendBtn.setAttribute("title", isCodex
|
|
9028
9602
|
? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
|
|
9029
|
-
: (structured ? "发送" : (!selectedSession || isRunning ? "发送" : "会话已结束")));
|
|
9603
|
+
: (structured ? "发送" : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
|
|
9030
9604
|
}
|
|
9031
9605
|
var container = document.getElementById("output");
|
|
9032
9606
|
if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
|
|
@@ -9462,6 +10036,10 @@
|
|
|
9462
10036
|
|
|
9463
10037
|
function activateSession(data) {
|
|
9464
10038
|
if (!data || !data.id) return Promise.resolve();
|
|
10039
|
+
state.selectedId = data.id;
|
|
10040
|
+
persistSelectedId();
|
|
10041
|
+
state.currentMessages = [];
|
|
10042
|
+
teardownTerminal();
|
|
9465
10043
|
resetChatRenderCache();
|
|
9466
10044
|
switchToSessionView(data.id);
|
|
9467
10045
|
updateSessionSnapshot(data);
|
|
@@ -9652,15 +10230,15 @@
|
|
|
9652
10230
|
}
|
|
9653
10231
|
|
|
9654
10232
|
function updateInputPanelViewportSpacing() {
|
|
10233
|
+
// 旧实现给 input-panel 加底部 padding = 键盘高度,意图是腾出键盘
|
|
10234
|
+
// 空间。但 input-panel 本身位置由 flex 决定,padding 增大只是把
|
|
10235
|
+
// panel 自身撑高、内部底部多出空白,textarea(panel 顶部)反而
|
|
10236
|
+
// 被往上推、离键盘更远。新方案改为让 body 高度跟随 visualViewport
|
|
10237
|
+
// 收缩(见 syncAppViewportHeight),input-panel 自然贴键盘上沿。
|
|
10238
|
+
// 这里清掉旧 keyboard-offset,避免新旧双重补偿。
|
|
9655
10239
|
var inputPanel = document.querySelector('.input-panel');
|
|
9656
10240
|
if (!inputPanel) return;
|
|
9657
|
-
|
|
9658
|
-
inputPanel.style.removeProperty('--keyboard-offset');
|
|
9659
|
-
return;
|
|
9660
|
-
}
|
|
9661
|
-
var vv = window.visualViewport;
|
|
9662
|
-
var offsetBottom = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
|
9663
|
-
inputPanel.style.setProperty('--keyboard-offset', offsetBottom + 'px');
|
|
10241
|
+
inputPanel.style.removeProperty('--keyboard-offset');
|
|
9664
10242
|
}
|
|
9665
10243
|
|
|
9666
10244
|
function resetInputPanelViewportSpacing() {
|
|
@@ -9713,7 +10291,7 @@
|
|
|
9713
10291
|
// The container height restores but terminal needs time to
|
|
9714
10292
|
// fill the expanded space, and the scroll position needs resetting.
|
|
9715
10293
|
if (isTouchDevice()) {
|
|
9716
|
-
ensureTerminalFit();
|
|
10294
|
+
ensureTerminalFit("keyboard-blur", { forceReplay: true });
|
|
9717
10295
|
maybeScrollTerminalToBottom("force");
|
|
9718
10296
|
}
|
|
9719
10297
|
}, 100);
|
|
@@ -10391,14 +10969,14 @@
|
|
|
10391
10969
|
var terminalContainer = document.querySelector('.terminal-container');
|
|
10392
10970
|
|
|
10393
10971
|
// Virtual Keyboard API (Chrome/Edge)
|
|
10972
|
+
// 不再给 input-panel 直接 setPaddingBottom——新方案通过
|
|
10973
|
+
// syncAppViewportHeight 让 body 跟随可见视口收缩,input-panel
|
|
10974
|
+
// 自然上移。这里只把事件留作未来钩子,避免和新方案双重补偿。
|
|
10394
10975
|
if ('virtualKeyboard' in navigator) {
|
|
10395
10976
|
var vk = navigator.virtualKeyboard;
|
|
10396
|
-
|
|
10397
10977
|
vk.addEventListener('geometrychange', function() {
|
|
10398
10978
|
if (!inputPanel) return;
|
|
10399
|
-
|
|
10400
|
-
var kbHeight = rect ? rect.height : 0;
|
|
10401
|
-
inputPanel.style.paddingBottom = kbHeight > 0 ? kbHeight + 'px' : '';
|
|
10979
|
+
inputPanel.style.removeProperty('padding-bottom');
|
|
10402
10980
|
});
|
|
10403
10981
|
}
|
|
10404
10982
|
|
|
@@ -10421,6 +10999,26 @@
|
|
|
10421
10999
|
}
|
|
10422
11000
|
}
|
|
10423
11001
|
|
|
11002
|
+
// 把 body / .app-container 的高度从 100dvh 切换为可见视口高度,
|
|
11003
|
+
// 这样键盘弹起时整个 flex column 自动收缩,input-panel 跟着上移到
|
|
11004
|
+
// 键盘上沿。Android targetSdk 36 在 edge-to-edge 默认开启时,
|
|
11005
|
+
// adjustResize 不再自动 resize WebView 内容;同时仅给 input-panel
|
|
11006
|
+
// 加 padding-bottom 只是把 panel 内部底部撑空,并不会让 panel 自身
|
|
11007
|
+
// 上移。这里通过 CSS 变量驱动整层高度,是跨 WebView/Chrome/PWA 的
|
|
11008
|
+
// 统一兜底。仅在视口比窗口明显变小时(典型 = 软键盘弹起)覆盖,
|
|
11009
|
+
// 桌面与无键盘场景维持 100dvh 不抖。
|
|
11010
|
+
function syncAppViewportHeight() {
|
|
11011
|
+
var vv = window.visualViewport;
|
|
11012
|
+
if (!vv) return;
|
|
11013
|
+
var diff = window.innerHeight - vv.height - vv.offsetTop;
|
|
11014
|
+
var root = document.documentElement;
|
|
11015
|
+
if (diff > 50) {
|
|
11016
|
+
root.style.setProperty('--app-viewport-height', vv.height + 'px');
|
|
11017
|
+
} else {
|
|
11018
|
+
root.style.removeProperty('--app-viewport-height');
|
|
11019
|
+
}
|
|
11020
|
+
}
|
|
11021
|
+
|
|
10424
11022
|
// Visual viewport handling for better mobile keyboard support
|
|
10425
11023
|
function setupVisualViewportHandlers() {
|
|
10426
11024
|
if (!('visualViewport' in window)) return;
|
|
@@ -10436,6 +11034,10 @@
|
|
|
10436
11034
|
var isKeyboardOpen = offsetBottom > 50;
|
|
10437
11035
|
var heightChanged = Math.abs(vv.height - lastHeight) > 8;
|
|
10438
11036
|
|
|
11037
|
+
// 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
|
|
11038
|
+
// 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
|
|
11039
|
+
syncAppViewportHeight();
|
|
11040
|
+
|
|
10439
11041
|
if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
|
|
10440
11042
|
syncInputBoxScroll(inputBox);
|
|
10441
11043
|
}
|
|
@@ -10445,14 +11047,14 @@
|
|
|
10445
11047
|
// Without an immediate refit, any chunk arriving while the keyboard
|
|
10446
11048
|
// animates in renders against the old grid and tears the screen.
|
|
10447
11049
|
if (!keyboardOpen && isKeyboardOpen) {
|
|
10448
|
-
ensureTerminalFit("keyboard-open");
|
|
11050
|
+
ensureTerminalFit("keyboard-open", { forceReplay: true });
|
|
10449
11051
|
}
|
|
10450
11052
|
|
|
10451
11053
|
// Keyboard just closed — force terminal refit and scroll to bottom
|
|
10452
11054
|
// after a delay so the keyboard dismiss animation and layout settle.
|
|
10453
11055
|
if (keyboardOpen && !isKeyboardOpen) {
|
|
10454
11056
|
setTimeout(function() {
|
|
10455
|
-
ensureTerminalFit("keyboard-close");
|
|
11057
|
+
ensureTerminalFit("keyboard-close", { forceReplay: true });
|
|
10456
11058
|
maybeScrollTerminalToBottom("force");
|
|
10457
11059
|
}, 200);
|
|
10458
11060
|
}
|
|
@@ -10586,11 +11188,11 @@
|
|
|
10586
11188
|
// Page returning from background: container dimensions may have
|
|
10587
11189
|
// drifted (PWA standalone, tab switch, iOS address-bar toggle).
|
|
10588
11190
|
state.visibilityHandler = function() {
|
|
10589
|
-
if (!document.hidden) ensureTerminalFit("visibility");
|
|
11191
|
+
if (!document.hidden) ensureTerminalFit("visibility", { forceReplay: true });
|
|
10590
11192
|
};
|
|
10591
11193
|
document.addEventListener("visibilitychange", state.visibilityHandler);
|
|
10592
11194
|
// Mobile device rotation — large geometry change.
|
|
10593
|
-
state.orientationHandler = function() { ensureTerminalFit("orientation"); };
|
|
11195
|
+
state.orientationHandler = function() { ensureTerminalFit("orientation", { forceReplay: true }); };
|
|
10594
11196
|
window.addEventListener("orientationchange", state.orientationHandler);
|
|
10595
11197
|
requestAnimationFrame(function() { scheduleTerminalResize(true); });
|
|
10596
11198
|
}
|
|
@@ -10686,6 +11288,18 @@
|
|
|
10686
11288
|
state.terminal.destroy();
|
|
10687
11289
|
state.terminal = null;
|
|
10688
11290
|
}
|
|
11291
|
+
// wterm.destroy() 只把 termWrap.innerHTML 置空,节点本身还挂在
|
|
11292
|
+
// #output 上。多次会话切换会让 N 个 .terminal-scroll-wrap 叠在
|
|
11293
|
+
// 同一 inset:0 位置;新 init 又 appendChild 一个新 termWrap,
|
|
11294
|
+
// 旧节点的 DOM 行虽被清空,但 scroll/层叠状态可能造成跨会话视觉
|
|
11295
|
+
// 污染。这里把残留节点彻底移除。
|
|
11296
|
+
if (output) {
|
|
11297
|
+
var staleWraps = output.querySelectorAll(".terminal-scroll-wrap");
|
|
11298
|
+
for (var i = 0; i < staleWraps.length; i++) {
|
|
11299
|
+
var wrap = staleWraps[i];
|
|
11300
|
+
if (wrap.parentNode === output) output.removeChild(wrap);
|
|
11301
|
+
}
|
|
11302
|
+
}
|
|
10689
11303
|
state.terminalSessionId = null;
|
|
10690
11304
|
state.terminalOutput = "";
|
|
10691
11305
|
state.terminalAutoFollow = true;
|
|
@@ -10709,24 +11323,71 @@
|
|
|
10709
11323
|
}
|
|
10710
11324
|
}
|
|
10711
11325
|
|
|
10712
|
-
// Unified entry point for re-fitting the
|
|
10713
|
-
//
|
|
10714
|
-
//
|
|
10715
|
-
//
|
|
10716
|
-
//
|
|
10717
|
-
//
|
|
10718
|
-
//
|
|
10719
|
-
//
|
|
10720
|
-
//
|
|
10721
|
-
//
|
|
10722
|
-
//
|
|
10723
|
-
//
|
|
10724
|
-
//
|
|
10725
|
-
//
|
|
10726
|
-
//
|
|
10727
|
-
|
|
10728
|
-
|
|
11326
|
+
// Unified entry point for re-fitting the wterm grid to its container.
|
|
11327
|
+
//
|
|
11328
|
+
// wterm's internal ResizeObserver only fires when newCols/newRows
|
|
11329
|
+
// actually differ from the current values. So a "soft refresh" path
|
|
11330
|
+
// (refresh button, ws-reconnect, view-switch — container size unchanged)
|
|
11331
|
+
// never reaches wterm.resize() on its own; we have to drive replay
|
|
11332
|
+
// explicitly via { forceReplay: true }.
|
|
11333
|
+
//
|
|
11334
|
+
// When cols *do* change in the rAF body, our remeasure() calls
|
|
11335
|
+
// wterm.resize() which synchronously fires the onResize callback —
|
|
11336
|
+
// and that callback already runs softResyncTerminal({ skipFit: true }).
|
|
11337
|
+
// So the rAF body must NOT replay again in that case (would flicker /
|
|
11338
|
+
// double-scroll). The two outcomes are mutually exclusive: either
|
|
11339
|
+
// remeasure resized and onResize replayed, or cols stayed put and we
|
|
11340
|
+
// honor forceReplay.
|
|
11341
|
+
function ensureTerminalFit(reason, options) {
|
|
11342
|
+
if (!state.terminal) return false;
|
|
11343
|
+
var opts = options || {};
|
|
11344
|
+
var forceReplay = opts.forceReplay === true;
|
|
11345
|
+
var el = document.getElementById("output");
|
|
11346
|
+
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
|
|
11347
|
+
// Container has no visible size yet (hidden, mid-transition,
|
|
11348
|
+
// pre-keyboard layout frame, Android WebView resume). Defer to
|
|
11349
|
+
// the retry loop; without it, a missed fit means PTY chunks keep
|
|
11350
|
+
// wrapping at the wrong width until the next external trigger
|
|
11351
|
+
// (rotation, keyboard toggle), and content piles at the top.
|
|
11352
|
+
ensureTerminalFitWithRetry(reason || "fit-retry", { forceReplay: forceReplay });
|
|
11353
|
+
return false;
|
|
11354
|
+
}
|
|
11355
|
+
var prevCols = state.terminal.cols;
|
|
11356
|
+
var prevRows = state.terminal.rows;
|
|
11357
|
+
requestAnimationFrame(function() {
|
|
11358
|
+
requestAnimationFrame(function() {
|
|
11359
|
+
if (!state.terminal) return;
|
|
11360
|
+
if (typeof state.terminal.remeasure === "function") {
|
|
11361
|
+
// remeasure → wterm.resize (if cols changed) → onResize →
|
|
11362
|
+
// softResyncTerminal({ skipFit: true }). Replay happens there.
|
|
11363
|
+
state.terminal.remeasure();
|
|
11364
|
+
}
|
|
11365
|
+
sendTerminalResize(state.terminal.cols, state.terminal.rows);
|
|
11366
|
+
var didResize = state.terminal.cols !== prevCols
|
|
11367
|
+
|| state.terminal.rows !== prevRows;
|
|
11368
|
+
// Mutex: didResize already replayed via onResize; otherwise the
|
|
11369
|
+
// caller may still demand a replay (e.g. ws-reconnect, refresh
|
|
11370
|
+
// button — DOM may be stale even at the same cols).
|
|
11371
|
+
if (!didResize && forceReplay && state.terminalOutput) {
|
|
11372
|
+
softResyncTerminal({ skipFit: true });
|
|
11373
|
+
}
|
|
11374
|
+
if (state.terminalAutoFollow || isTerminalNearBottom()) {
|
|
11375
|
+
maybeScrollTerminalToBottom("resize");
|
|
11376
|
+
}
|
|
11377
|
+
});
|
|
11378
|
+
});
|
|
11379
|
+
return true;
|
|
11380
|
+
}
|
|
11381
|
+
|
|
11382
|
+
// Same as ensureTerminalFit but spins through requestAnimationFrame /
|
|
11383
|
+
// setTimeout up to ~8 frames waiting for a non-zero container size
|
|
11384
|
+
// (Android WebView.onResume, keyboard transitions, hidden→visible
|
|
11385
|
+
// panel flips). Forwards forceReplay so the caller's intent is
|
|
11386
|
+
// preserved when the container finally settles.
|
|
11387
|
+
function ensureTerminalFitWithRetry(reason, options) {
|
|
10729
11388
|
if (!state.terminal) return;
|
|
11389
|
+
var opts = options || {};
|
|
11390
|
+
var forceReplay = opts.forceReplay !== false; // default true: retry path implies "may be stale"
|
|
10730
11391
|
var attempts = 0;
|
|
10731
11392
|
var maxAttempts = 8;
|
|
10732
11393
|
function tryFit() {
|
|
@@ -10738,16 +11399,7 @@
|
|
|
10738
11399
|
void el.offsetHeight;
|
|
10739
11400
|
}
|
|
10740
11401
|
if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
|
|
10741
|
-
ensureTerminalFit(reason);
|
|
10742
|
-
// After fit, force a buffer replay: even when cols didn't
|
|
10743
|
-
// change, the WASM grid state may be inconsistent after a
|
|
10744
|
-
// long suspend (DOM rows clipped, scroll position lost).
|
|
10745
|
-
// softResyncTerminal is cheap because terminalOutput is
|
|
10746
|
-
// already in memory.
|
|
10747
|
-
if (state.terminalOutput) {
|
|
10748
|
-
state.suppressFitReplay = true;
|
|
10749
|
-
softResyncTerminal();
|
|
10750
|
-
}
|
|
11402
|
+
ensureTerminalFit(reason, { forceReplay: forceReplay });
|
|
10751
11403
|
return;
|
|
10752
11404
|
}
|
|
10753
11405
|
if (++attempts >= maxAttempts) return;
|
|
@@ -10763,51 +11415,6 @@
|
|
|
10763
11415
|
tryFit();
|
|
10764
11416
|
}
|
|
10765
11417
|
|
|
10766
|
-
function ensureTerminalFit(reason) {
|
|
10767
|
-
if (!state.terminal) return false;
|
|
10768
|
-
var el = document.getElementById("output");
|
|
10769
|
-
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
|
|
10770
|
-
// 容器暂时没有可见尺寸(hidden、动画过渡、键盘弹起前的 layout
|
|
10771
|
-
// 中间帧、Android WebView resume 头几帧),不要静默放弃——
|
|
10772
|
-
// 改成丢给 ensureTerminalFitWithRetry 兜底,等容器有了真实
|
|
10773
|
-
// 尺寸再 fit + replay。否则一旦错过这一次,只能等下一次外部
|
|
10774
|
-
// 触发(旋转屏幕、开关键盘等),中间的输出就会一直按错误
|
|
10775
|
-
// 宽度堆在视图上方,看起来像"中间一大段都没有显示"。
|
|
10776
|
-
ensureTerminalFitWithRetry(reason || "fit-retry");
|
|
10777
|
-
return false;
|
|
10778
|
-
}
|
|
10779
|
-
var prevCols = state.terminal.cols;
|
|
10780
|
-
var prevRows = state.terminal.rows;
|
|
10781
|
-
requestAnimationFrame(function() {
|
|
10782
|
-
requestAnimationFrame(function() {
|
|
10783
|
-
if (!state.terminal) return;
|
|
10784
|
-
if (typeof state.terminal.remeasure === "function") {
|
|
10785
|
-
state.terminal.remeasure();
|
|
10786
|
-
}
|
|
10787
|
-
sendTerminalResize(state.terminal.cols, state.terminal.rows);
|
|
10788
|
-
// Cache the container width that produced this cols/rows so the
|
|
10789
|
-
// hot-path chunk writer can detect drift cheaply (avoids running
|
|
10790
|
-
// a full remeasure on every WebSocket message).
|
|
10791
|
-
state.lastFitContainerWidth = el.offsetWidth;
|
|
10792
|
-
state.lastFitContainerHeight = el.offsetHeight;
|
|
10793
|
-
// If cols actually changed, the previously written buffer was
|
|
10794
|
-
// wrapped to the old width. Replay the full buffer so historical
|
|
10795
|
-
// lines and any in-flight CSI cursor sequences re-render against
|
|
10796
|
-
// the new grid — this is what fixes the "torn" screens users see
|
|
10797
|
-
// after rotating, opening the keyboard, or resizing the panel.
|
|
10798
|
-
var skipReplay = state.suppressFitReplay === true;
|
|
10799
|
-
state.suppressFitReplay = false;
|
|
10800
|
-
if (!skipReplay && (state.terminal.cols !== prevCols || state.terminal.rows !== prevRows)) {
|
|
10801
|
-
if (state.terminalOutput) softResyncTerminal();
|
|
10802
|
-
}
|
|
10803
|
-
if (state.terminalAutoFollow || isTerminalNearBottom()) {
|
|
10804
|
-
maybeScrollTerminalToBottom("resize");
|
|
10805
|
-
}
|
|
10806
|
-
});
|
|
10807
|
-
});
|
|
10808
|
-
return true;
|
|
10809
|
-
}
|
|
10810
|
-
|
|
10811
11418
|
function scheduleTerminalResize(immediate) {
|
|
10812
11419
|
if (state.resizeTimer) {
|
|
10813
11420
|
clearTimeout(state.resizeTimer);
|
|
@@ -11046,6 +11653,7 @@
|
|
|
11046
11653
|
// 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
|
|
11047
11654
|
// wterm 内部 ResizeObserver 独占 cols 跟踪职责。
|
|
11048
11655
|
wandTerminalWrite(state.terminal, msg.data.chunk);
|
|
11656
|
+
maybeScheduleResyncForChunk(msg.data.chunk);
|
|
11049
11657
|
state.terminalSessionId = msg.sessionId;
|
|
11050
11658
|
if (msg.data.output) {
|
|
11051
11659
|
state.terminalOutput = normalizeTerminalOutput(msg.data.output);
|