@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.
@@ -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="关闭">&times;</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
- softRefreshCurrentView();
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 || typeof state.terminal.write !== "function") return;
4931
- // @wterm/dom WTerm 类没有暴露 reset() 方法(grep 全包零匹配),
4932
- // 所以早期的 state.terminal.reset() 调用是 no-op——softResyncTerminal
4933
- // 实际只做了"再写一份 terminalOutput 追加",旧 grid 不会被清空,
4934
- // 这就是"点刷新按钮没用、只有窗口尺寸变化才修"的根因。
4935
- // 改用 ANSI RIS (Reset to Initial State, ESC c) 让 WASM 状态机自己
4936
- // 重置 grid / 光标 / 属性 / scrollback,所有 VT 实现都支持这个序列。
4937
- state.terminal.write("\x1bc");
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
- function softResyncTerminal() {
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
- // Remeasure against real container: the refresh button used to only
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 自身 ResizeObserver 在容器尺寸变化时主动调 resize()
5078
- // bridge.resize() grid 按新 cols 重排,但 scrollback 仍是
5079
- // 按旧 cols 写入 WASM 的、新 grid 又被清空到干净状态。wand
5080
- // 这层缓存的 terminalOutput 才是完整原始字节流,必须按新
5081
- // cols 重放一次,grid + scrollback 才会和实际历史对齐。
5082
- // 同步立即重放——不要走 setTimeout(0):移动端 WebView
5083
- // 前后台切换或键盘动画期间,macrotask 经常被推迟到 wterm
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
- state.suppressFitReplay = true;
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
- syncTerminalBuffer(id, data.output, { mode: "append" });
5977
- // Session-switch / history replay: force a real fit so wterm
5978
- // reflows the just-written output against the real container.
5979
- ensureTerminalFit("session-switch");
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
- showToast("会话未就绪,将稍后重试。", "info");
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
- return !!(session && session.provider === "claude" && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
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
- showToast("正在恢复历史会话…", "info");
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
- if (!('visualViewport' in window) || !isTouchDevice()) {
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
- var rect = vk.boundingRect;
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 xterm grid to its container.
10713
- // Why: wterm's `autoResize` ResizeObserver only fires on subsequent
10714
- // container size changes. If the terminal is mounted or written to
10715
- // while the container is hidden/zero-width, cols/rows stay wrong and
10716
- // new output renders with broken wrapping (content visually piles at
10717
- // the top). Call this after any layout change that might have altered
10718
- // container geometry (mount, session switch, view switch, refresh).
10719
- // Same as ensureTerminalFit, but if the container is currently 0×0
10720
- // (typical right after Android WebView.onResume the page hasn't
10721
- // re-laid-out yet), keep retrying through requestAnimationFrame /
10722
- // setTimeout up to ~5 frames. Each attempt forces a layout read
10723
- // (offsetHeight) so the browser has to flush styles.
10724
- // Without this, the very first ensureTerminalFit silently fails,
10725
- // cols/rows stay at the pre-suspend values, and freshly arriving
10726
- // PTY chunks wrap against the wrong width — that's exactly the
10727
- // "content piles at the top after resuming the app" bug.
10728
- function ensureTerminalFitWithRetry(reason) {
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);