@co0ontty/wand 1.18.1 → 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.
@@ -167,6 +167,17 @@
167
167
  return false;
168
168
  }
169
169
  })(),
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 },
170
181
  chatAutoFollow: (function() {
171
182
  try {
172
183
  var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
@@ -182,6 +193,8 @@
182
193
  chatScrollHandler: null,
183
194
  lastForegroundSyncAt: 0,
184
195
  foregroundSyncTimer: null,
196
+ wsReconnectAttempts: 0,
197
+ wsReconnectTimer: null,
185
198
  currentMessages: [],
186
199
  lastRenderedHash: 0,
187
200
  lastRenderedMsgCount: 0,
@@ -802,14 +815,11 @@
802
815
 
803
816
  function updateInstallPrompt() {
804
817
  // 显示或隐藏菜单栏中的安装按钮
818
+ var visible = !!(state.showInstallPrompt && state.deferredPrompt);
805
819
  var installBtn = document.getElementById('pwa-install-button');
806
- if (installBtn) {
807
- if (state.showInstallPrompt && state.deferredPrompt) {
808
- installBtn.classList.remove('hidden');
809
- } else {
810
- installBtn.classList.add('hidden');
811
- }
812
- }
820
+ if (installBtn) installBtn.classList.toggle('hidden', !visible);
821
+ var topbarInstallItem = document.getElementById('topbar-install-item');
822
+ if (topbarInstallItem) topbarInstallItem.classList.toggle('hidden', !visible);
813
823
  }
814
824
 
815
825
  function renderBootLoading() {
@@ -824,36 +834,48 @@
824
834
  '</div>';
825
835
  }
826
836
 
827
- function scheduleForegroundSync(reason) {
837
+ function scheduleForegroundSync(reason, opts) {
828
838
  if (!state.config) return;
829
839
  if (document.hidden) return;
840
+ var immediate = opts && opts.immediate === true;
830
841
  var now = Date.now();
831
- if (now - state.lastForegroundSyncAt < 1500) return;
842
+ // 节流只是为了防止 visibilitychange/focus/pageshow 在前台切换时
843
+ // 连珠炮式触发同一份重连工作,不再借此延迟实际同步——之前用
844
+ // 80ms 兜延迟的版本会在前台事件后再去 loadOutput 全量重写
845
+ // terminal,但 wterm cols 那时还没被 ResizeObserver 自适应到,
846
+ // 写进去的全是按错列宽排版的内容,结果"切回前台/刷新页面 →
847
+ // 中间一大段都看不到"反而成了常态。
848
+ if (!immediate && now - state.lastForegroundSyncAt < 1500) return;
832
849
  state.lastForegroundSyncAt = now;
833
850
  if (state.foregroundSyncTimer) {
834
851
  clearTimeout(state.foregroundSyncTimer);
835
- }
836
- state.foregroundSyncTimer = setTimeout(function() {
837
852
  state.foregroundSyncTimer = null;
838
- syncOnForeground(reason);
839
- }, 80);
853
+ }
854
+ syncOnForeground(reason, immediate);
840
855
  }
841
856
 
842
- function syncOnForeground(reason) {
857
+ function syncOnForeground(reason, force) {
843
858
  if (!state.config) return Promise.resolve();
844
859
  if (document.hidden) return Promise.resolve();
845
- if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
860
+ // On Android resume the previous WS may still report OPEN/CONNECTING
861
+ // for a few seconds because the close frame hasn't been delivered
862
+ // yet (TCP keepalive / Doze suspended the network stack). Force a
863
+ // fresh socket so we don't sit on a zombie connection.
864
+ if (force) {
865
+ forceReconnectWebSocket("resume-force");
866
+ } else if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
846
867
  initWebSocket();
847
868
  }
848
869
  if (state.claudeHistoryLoaded) {
849
870
  loadClaudeHistory();
850
871
  }
851
- return loadSessions({ skipSelectedOutputReload: true }).then(function() {
852
- if (state.selectedId) {
853
- return loadOutput(state.selectedId);
854
- }
855
- scheduleChatRender(true);
856
- }).catch(function(e) {
872
+ // 不再 loadOutput 当前会话——WS 重连后服务端会主动推一条 init
873
+ // 消息,那条路径已经走 ensureTerminalFitWithRetry 强制按真实
874
+ // cols 重排 history,足够覆盖前台恢复时的同步需求。这里多加
875
+ // 一次 fetch + syncTerminalBuffer 反而会在 ws/http 两路的 output
876
+ // 之间来回 reset,导致 alt-screen 中正在绘制的 Claude TUI 被
877
+ // 中途清掉。只把会话列表刷一下,保证状态条/会话名等元数据是新的。
878
+ return loadSessions({ skipSelectedOutputReload: true }).catch(function(e) {
857
879
  console.error("[wand] foreground sync failed:", reason, e);
858
880
  });
859
881
  }
@@ -863,8 +885,15 @@
863
885
  window.__wandForegroundSyncBound = true;
864
886
 
865
887
  document.addEventListener("visibilitychange", function() {
866
- if (!document.hidden) {
888
+ if (document.hidden) {
889
+ // Stop the reconnect backoff while hidden — the OS may freeze
890
+ // timers and then deliver them in a burst when we resume,
891
+ // creating a thundering-herd of connect attempts. The resume
892
+ // event will trigger one decisive reconnect instead.
893
+ cancelWsReconnect();
894
+ } else {
867
895
  scheduleForegroundSync("visibility");
896
+ ensureTerminalFitWithRetry("visibility");
868
897
  }
869
898
  });
870
899
 
@@ -879,6 +908,18 @@
879
908
  window.addEventListener("resume", function() {
880
909
  scheduleForegroundSync("resume");
881
910
  });
911
+
912
+ // Bridge from Android WebView host: MainActivity.onResume() calls
913
+ // evaluateJavascript to dispatch this event, which is the only
914
+ // reliable foreground signal once Doze/process-suspension has
915
+ // frozen page-level events (visibilitychange/focus/pageshow may
916
+ // fire late or not at all after a long suspend). Force-reconnect
917
+ // and force-refit immediately rather than waiting for the
918
+ // throttled scheduleForegroundSync path.
919
+ window.addEventListener("wand-android-resume", function() {
920
+ scheduleForegroundSync("android-resume", { immediate: true });
921
+ ensureTerminalFitWithRetry("android-resume");
922
+ });
882
923
  }
883
924
 
884
925
  function restoreLoginSession() {
@@ -992,6 +1033,11 @@
992
1033
  syncSessionModalUI();
993
1034
  }
994
1035
  }
1036
+
1037
+ // 初始加载或会话切换后惰性触发 git 状态拉取(loadGitStatus 自带节流)。
1038
+ if (isLoggedIn && state.selectedId && state.gitStatusSessionId !== state.selectedId) {
1039
+ loadGitStatus(state.selectedId);
1040
+ }
995
1041
  }
996
1042
 
997
1043
  function renderShortcutKeys() {
@@ -1172,12 +1218,39 @@
1172
1218
  '</aside>' +
1173
1219
  '<main class="main-content">' +
1174
1220
  '<div class="main-header-row">' +
1175
- '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1176
- '<span class="hamburger-icon">' +
1177
- '<span></span><span></span><span></span>' +
1178
- '</span>' +
1179
- '</button>' +
1180
- '<span class="current-task hidden" id="current-task"></span>' +
1221
+ '<div class="topbar-left">' +
1222
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1223
+ '<span class="hamburger-icon">' +
1224
+ '<span></span><span></span><span></span>' +
1225
+ '</span>' +
1226
+ '</button>' +
1227
+ '<span class="topbar-brand" aria-hidden="true">W</span>' +
1228
+ '</div>' +
1229
+ '<div class="topbar-center">' +
1230
+ (selectedSession
1231
+ ? (
1232
+ '<span class="topbar-session-title" title="' + escapeHtml(selectedSession.command || "") + '">' + escapeHtml(shortCommand(selectedSession.command)) + '</span>' +
1233
+ '<span class="session-status-pill ' + getSessionStatusClass(selectedSession) + '" title="' + escapeHtml(getSessionStatusLabel(selectedSession)) + '"><span class="session-status-dot"></span><span class="session-status-text">' + escapeHtml(getSessionStatusLabel(selectedSession)) + '</span></span>' +
1234
+ '<span class="current-task hidden" id="current-task"></span>' +
1235
+ (selectedSession.cwd ? '<span class="topbar-cwd" id="topbar-cwd" title="' + escapeHtml(selectedSession.cwd) + '" role="button" tabindex="0">' + escapeHtml(selectedSession.cwd) + '</span>' : '')
1236
+ )
1237
+ : '<span class="topbar-tagline">Wand 控制台</span>' +
1238
+ '<span class="current-task hidden" id="current-task"></span>'
1239
+ ) +
1240
+ '</div>' +
1241
+ '<div class="topbar-right">' +
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>' +
1244
+ '<div class="topbar-more-wrap">' +
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>' +
1246
+ '<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
1247
+ '<button class="topbar-more-item" data-action="settings" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span>设置</span></button>' +
1248
+ '<button class="topbar-more-item" data-action="refresh" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>刷新</span></button>' +
1249
+ '<button class="topbar-more-item' + (state.showInstallPrompt && state.deferredPrompt ? '' : ' hidden') + '" id="topbar-install-item" data-action="install" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>安装应用</span></button>' +
1250
+ '<button class="topbar-more-item topbar-more-item-danger" data-action="logout" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg><span>退出</span></button>' +
1251
+ '</div>' +
1252
+ '</div>' +
1253
+ '</div>' +
1181
1254
  '</div>' +
1182
1255
  // File panel backdrop (mobile)
1183
1256
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
@@ -1256,6 +1329,15 @@
1256
1329
  '</div>' +
1257
1330
  '</div>' +
1258
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>' +
1259
1341
  '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1260
1342
  '<div id="attachment-preview" class="attachment-preview hidden"></div>' +
1261
1343
  '<div class="input-composer-bar">' +
@@ -1332,7 +1414,322 @@
1332
1414
  '</section>' +
1333
1415
  '</main>' +
1334
1416
  '</div>' +
1335
- '</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>';
1336
1733
  }
1337
1734
 
1338
1735
  function renderWorktreeMergeModal() {
@@ -1821,7 +2218,7 @@
1821
2218
  }
1822
2219
 
1823
2220
  function renderSessions() {
1824
- var activeSessions = state.sessions.filter(function(session) { return !session.archived && !session.resumedToSessionId; });
2221
+ var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
1825
2222
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
1826
2223
  var groups = [];
1827
2224
  groups.push(renderSessionManageBar());
@@ -1994,9 +2391,7 @@
1994
2391
  }
1995
2392
 
1996
2393
  function getSelectableSessions() {
1997
- return state.sessions.filter(function(session) {
1998
- return session.archived || !session.resumedToSessionId;
1999
- });
2394
+ return state.sessions.slice();
2000
2395
  }
2001
2396
 
2002
2397
  function countSelectableItems() {
@@ -2743,10 +3138,8 @@
2743
3138
  var statusMap = {
2744
3139
  "stopped": "已停止",
2745
3140
  "running": "运行中",
2746
- "idle": "空闲",
2747
- "thinking": "思考中",
2748
- "waiting-input": "等待输入",
2749
- "initializing": "启动中"
3141
+ "exited": "已退出",
3142
+ "failed": "已失败"
2750
3143
  };
2751
3144
  return statusMap[session.status] || session.status;
2752
3145
  }
@@ -3699,6 +4092,11 @@
3699
4092
  fileInput.value = "";
3700
4093
  });
3701
4094
  }
4095
+
4096
+ var promptOptimizeBtn = document.getElementById("prompt-optimize-btn");
4097
+ if (promptOptimizeBtn) {
4098
+ promptOptimizeBtn.addEventListener("click", function() { optimizePromptText(); });
4099
+ }
3702
4100
  var composer = document.querySelector(".input-composer");
3703
4101
  if (composer) {
3704
4102
  composer.addEventListener("dragover", function(e) {
@@ -3790,6 +4188,88 @@
3790
4188
  var filePanelBackdrop = document.getElementById("file-panel-backdrop");
3791
4189
  if (filePanelBackdrop) filePanelBackdrop.addEventListener("click", closeFilePanel);
3792
4190
 
4191
+ // Topbar: file button (mirrors toggleFilePanel)
4192
+ var topbarFileBtn = document.getElementById("topbar-file-button");
4193
+ if (topbarFileBtn) topbarFileBtn.addEventListener("click", toggleFilePanel);
4194
+
4195
+ // Topbar: cwd click → open file panel
4196
+ var topbarCwdEl = document.getElementById("topbar-cwd");
4197
+ if (topbarCwdEl) {
4198
+ topbarCwdEl.addEventListener("click", function() {
4199
+ if (!state.filePanelOpen) toggleFilePanel();
4200
+ });
4201
+ topbarCwdEl.addEventListener("keydown", function(e) {
4202
+ if (e.key === "Enter" || e.key === " ") {
4203
+ e.preventDefault();
4204
+ if (!state.filePanelOpen) toggleFilePanel();
4205
+ }
4206
+ });
4207
+ }
4208
+
4209
+ // Topbar: more menu
4210
+ var topbarMoreBtn = document.getElementById("topbar-more-button");
4211
+ var topbarMoreMenu = document.getElementById("topbar-more-menu");
4212
+ if (topbarMoreBtn && topbarMoreMenu) {
4213
+ topbarMoreBtn.addEventListener("click", function(e) {
4214
+ e.stopPropagation();
4215
+ state.topbarMoreOpen = !state.topbarMoreOpen;
4216
+ topbarMoreMenu.classList.toggle("hidden", !state.topbarMoreOpen);
4217
+ topbarMoreBtn.classList.toggle("active", state.topbarMoreOpen);
4218
+ topbarMoreBtn.setAttribute("aria-expanded", state.topbarMoreOpen ? "true" : "false");
4219
+ });
4220
+ topbarMoreMenu.addEventListener("click", function(e) {
4221
+ var btn = e.target && e.target.closest ? e.target.closest(".topbar-more-item") : null;
4222
+ if (!btn) return;
4223
+ var action = btn.getAttribute("data-action");
4224
+ // Close menu first regardless of action
4225
+ state.topbarMoreOpen = false;
4226
+ topbarMoreMenu.classList.add("hidden");
4227
+ topbarMoreBtn.classList.remove("active");
4228
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
4229
+ switch (action) {
4230
+ case "settings":
4231
+ openSettingsModal();
4232
+ break;
4233
+ case "refresh":
4234
+ window.location.reload();
4235
+ break;
4236
+ case "install":
4237
+ if (state.deferredPrompt) {
4238
+ state.deferredPrompt.prompt();
4239
+ state.deferredPrompt.userChoice.then(function() {
4240
+ state.deferredPrompt = null;
4241
+ state.showInstallPrompt = false;
4242
+ updateInstallPrompt();
4243
+ });
4244
+ }
4245
+ break;
4246
+ case "logout":
4247
+ logout();
4248
+ break;
4249
+ }
4250
+ });
4251
+ // Close on outside click
4252
+ document.addEventListener("click", function(e) {
4253
+ if (!state.topbarMoreOpen) return;
4254
+ var wrap = topbarMoreMenu.parentElement;
4255
+ if (wrap && !wrap.contains(e.target)) {
4256
+ state.topbarMoreOpen = false;
4257
+ topbarMoreMenu.classList.add("hidden");
4258
+ topbarMoreBtn.classList.remove("active");
4259
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
4260
+ }
4261
+ });
4262
+ // Close on ESC
4263
+ document.addEventListener("keydown", function(e) {
4264
+ if (e.key === "Escape" && state.topbarMoreOpen) {
4265
+ state.topbarMoreOpen = false;
4266
+ topbarMoreMenu.classList.add("hidden");
4267
+ topbarMoreBtn.classList.remove("active");
4268
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
4269
+ }
4270
+ });
4271
+ }
4272
+
3793
4273
  // Terminal scale controls (topbar)
3794
4274
  var scaleDownBtn = document.getElementById("terminal-scale-down-top");
3795
4275
  var scaleUpBtn = document.getElementById("terminal-scale-up-top");
@@ -3804,7 +4284,9 @@
3804
4284
  location.reload();
3805
4285
  return;
3806
4286
  }
3807
- softRefreshCurrentView();
4287
+ softResyncTerminal();
4288
+ resetChatRenderCache();
4289
+ scheduleChatRender(true);
3808
4290
  });
3809
4291
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
3810
4292
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
@@ -4200,6 +4682,23 @@
4200
4682
  });
4201
4683
  }
4202
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
+
4203
4702
  initTerminal();
4204
4703
  setupMobileKeyboardHandlers();
4205
4704
  setupVisualViewportHandlers();
@@ -4659,42 +5158,174 @@
4659
5158
  requestSyncScrollbar();
4660
5159
  }
4661
5160
 
5161
+ // ──────── East-Asian-Wide padding for wterm WASM ────────
5162
+ //
5163
+ // wterm's WASM grid (as of @wterm/core 0.1.8/0.1.9) treats every
5164
+ // codepoint as occupying exactly 1 cell, while node-pty's backend
5165
+ // and Claude Code's TUI emit cursor-positioning sequences that
5166
+ // assume CJK / fullwidth / emoji codepoints occupy 2 columns
5167
+ // (Unicode TR11 East-Asian-Width = W or F). The mismatch makes
5168
+ // every CSI cursor move after CJK output drift by N/2 columns,
5169
+ // causing in-place rewrites (thinking spinner, todo list,
5170
+ // permission menus) to leave torn residue like "替替换换".
5171
+ //
5172
+ // Fix: insert U+2060 (Word Joiner — zero-width, unbreakable) after
5173
+ // each wide codepoint before handing the byte stream to the WASM
5174
+ // grid. The WJ takes one cell, so wide chars now occupy 2 cells —
5175
+ // matching the backend's column accounting exactly. The browser
5176
+ // renders WJ at zero width, so the visual layout stays correct.
5177
+ //
5178
+ // The scanner is ANSI-aware: it tracks ESC / CSI / OSC / DCS
5179
+ // / PM / APC state across chunk boundaries so wide codepoints
5180
+ // inside escape sequences (e.g. OSC window-title payloads) are
5181
+ // not padded — that would break sequence parsing.
5182
+ function isEastAsianWide(cp) {
5183
+ if (cp < 0x1100) return false;
5184
+ return (
5185
+ (cp >= 0x1100 && cp <= 0x115f) ||
5186
+ (cp >= 0x2329 && cp <= 0x232a) ||
5187
+ (cp >= 0x2e80 && cp <= 0x303e) ||
5188
+ (cp >= 0x3041 && cp <= 0x33ff) ||
5189
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
5190
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
5191
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
5192
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
5193
+ (cp >= 0xf900 && cp <= 0xfaff) ||
5194
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
5195
+ (cp >= 0xff00 && cp <= 0xff60) ||
5196
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
5197
+ (cp >= 0x1f000 && cp <= 0x1f9ff) ||
5198
+ (cp >= 0x20000 && cp <= 0x2fffd) ||
5199
+ (cp >= 0x30000 && cp <= 0x3fffd)
5200
+ );
5201
+ }
5202
+
5203
+ var WAND_WIDE_FILLER = "\u2060";
5204
+
5205
+ function createWideParserState() { return { mode: "normal" }; }
5206
+
5207
+ function widePadAnsi(data, st) {
5208
+ if (!data) return "";
5209
+ var s = String(data);
5210
+ var out = "";
5211
+ for (var i = 0; i < s.length; i++) {
5212
+ var code = s.charCodeAt(i);
5213
+ var cp = code;
5214
+ var consumed = 1;
5215
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
5216
+ var lo = s.charCodeAt(i + 1);
5217
+ if (lo >= 0xdc00 && lo <= 0xdfff) {
5218
+ cp = (code - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
5219
+ consumed = 2;
5220
+ }
5221
+ }
5222
+ var ch = consumed === 2 ? s.substr(i, 2) : s.charAt(i);
5223
+ switch (st.mode) {
5224
+ case "normal":
5225
+ if (cp === 0x1b) { st.mode = "esc"; out += ch; }
5226
+ else if (cp === 0x9b) { st.mode = "csi"; out += ch; }
5227
+ else if (cp === 0x9d || cp === 0x90 || cp === 0x9e || cp === 0x9f) {
5228
+ st.mode = "string"; out += ch;
5229
+ } else {
5230
+ out += ch;
5231
+ if (isEastAsianWide(cp)) out += WAND_WIDE_FILLER;
5232
+ }
5233
+ break;
5234
+ case "esc":
5235
+ out += ch;
5236
+ if (cp === 0x5b) st.mode = "csi";
5237
+ else if (cp === 0x5d || cp === 0x50 || cp === 0x58 ||
5238
+ cp === 0x5e || cp === 0x5f) st.mode = "string";
5239
+ else st.mode = "normal";
5240
+ break;
5241
+ case "csi":
5242
+ out += ch;
5243
+ if (cp >= 0x40 && cp <= 0x7e) st.mode = "normal";
5244
+ break;
5245
+ case "string":
5246
+ out += ch;
5247
+ if (cp === 0x07 || cp === 0x9c) st.mode = "normal";
5248
+ else if (cp === 0x1b) st.mode = "string-esc";
5249
+ break;
5250
+ case "string-esc":
5251
+ out += ch;
5252
+ if (cp === 0x5c) st.mode = "normal";
5253
+ else st.mode = "string";
5254
+ break;
5255
+ }
5256
+ i += consumed - 1;
5257
+ }
5258
+ return out;
5259
+ }
5260
+
5261
+ function wandTerminalWrite(terminal, data) {
5262
+ if (!terminal || data == null) return;
5263
+ if (!state.wideParserState) state.wideParserState = createWideParserState();
5264
+ terminal.write(widePadAnsi(data, state.wideParserState));
5265
+ }
5266
+
5267
+ function resetWideParserState() {
5268
+ state.wideParserState = createWideParserState();
5269
+ }
5270
+
5271
+ // Strip the wide-pad filler from copied text so users pasting
5272
+ // selected terminal output don't get hidden U+2060 sprinkled
5273
+ // through every CJK string.
5274
+ function stripWideFillerForCopy() {
5275
+ if (typeof document === "undefined") return;
5276
+ document.addEventListener("copy", function(e) {
5277
+ var sel = window.getSelection && window.getSelection();
5278
+ if (!sel || sel.isCollapsed) return;
5279
+ var anchor = sel.anchorNode;
5280
+ var node = anchor && anchor.nodeType === 3 ? anchor.parentNode : anchor;
5281
+ var output = document.getElementById("output");
5282
+ if (!output || !node || !output.contains(node)) return;
5283
+ var text = sel.toString();
5284
+ if (text.indexOf(WAND_WIDE_FILLER) === -1) return;
5285
+ if (e.clipboardData) {
5286
+ e.clipboardData.setData("text/plain", text.split(WAND_WIDE_FILLER).join(""));
5287
+ e.preventDefault();
5288
+ }
5289
+ });
5290
+ }
5291
+ stripWideFillerForCopy();
5292
+
4662
5293
  function resetTerminal() {
4663
- if (!state.terminal || typeof state.terminal.reset !== "function") return;
4664
- state.terminal.reset();
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
+ }
5308
+ resetWideParserState();
4665
5309
  }
4666
5310
 
4667
5311
  // Soft resync terminal: reset WASM grid and replay full output buffer.
4668
5312
  // Clears any stale DOM rows left over from CSI cursor-jump sequences
4669
5313
  // (e.g. Claude permission menus redrawing in place while user holds arrow keys).
4670
- 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) {
4671
5319
  if (!state.terminal || !state.terminalOutput) return false;
5320
+ var opts = options || {};
4672
5321
  resetTerminal();
4673
- state.terminal.write(state.terminalOutput);
5322
+ wandTerminalWrite(state.terminal, state.terminalOutput);
4674
5323
  state.lastTerminalResyncAt = Date.now();
4675
5324
  maybeScrollTerminalToBottom("output");
4676
- // Remeasure against real container: the refresh button used to only
4677
- // reset+write, so a stale cols/rows (set at mount time with hidden
4678
- // container) would survive the refresh and keep wrapping output wrong.
4679
- // Suppress the auto-replay branch in ensureTerminalFit — we just
4680
- // replayed, no point doing it again on the next rAF tick.
4681
- state.suppressFitReplay = true;
4682
- ensureTerminalFit("refresh");
5325
+ if (!opts.skipFit) ensureTerminalFit("refresh");
4683
5326
  return true;
4684
5327
  }
4685
5328
 
4686
- // Soft refresh the whole current view without losing page state:
4687
- // - Replays terminal buffer to clear residue
4688
- // - Clears chat render cache and forces a full rebuild
4689
- // Used by the refresh button and by automatic triggers
4690
- // (e.g. permission escalation appearing/disappearing).
4691
- function softRefreshCurrentView() {
4692
- softResyncTerminal();
4693
- if (typeof resetChatRenderCache === "function") resetChatRenderCache();
4694
- if (typeof scheduleChatRender === "function") scheduleChatRender(true);
4695
- else if (typeof render === "function") render();
4696
- }
4697
-
4698
5329
  function scheduleSoftResyncTerminal(delayMs) {
4699
5330
  if (state.softResyncTimer) clearTimeout(state.softResyncTimer);
4700
5331
  state.softResyncTimer = setTimeout(function() {
@@ -4703,6 +5334,28 @@
4703
5334
  }, typeof delayMs === "number" ? delayMs : 150);
4704
5335
  }
4705
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
+
4706
5359
  function syncTerminalBuffer(sessionId, output, options) {
4707
5360
  if (!state.terminal) return false;
4708
5361
  var normalizedOutput = normalizeTerminalOutput(output || "");
@@ -4734,7 +5387,7 @@
4734
5387
  if (normalizedOutput !== currentOutput) {
4735
5388
  resetTerminal();
4736
5389
  if (normalizedOutput) {
4737
- state.terminal.write(normalizedOutput);
5390
+ wandTerminalWrite(state.terminal, normalizedOutput);
4738
5391
  }
4739
5392
  wrote = true;
4740
5393
  }
@@ -4745,7 +5398,8 @@
4745
5398
  } else if (normalizedOutput.startsWith(currentOutput)) {
4746
5399
  var delta = normalizedOutput.slice(currentOutput.length);
4747
5400
  if (delta) {
4748
- state.terminal.write(delta);
5401
+ wandTerminalWrite(state.terminal, delta);
5402
+ maybeScheduleResyncForChunk(delta);
4749
5403
  wrote = true;
4750
5404
  }
4751
5405
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -4753,7 +5407,7 @@
4753
5407
  } else {
4754
5408
  resetTerminal();
4755
5409
  if (normalizedOutput) {
4756
- state.terminal.write(normalizedOutput);
5410
+ wandTerminalWrite(state.terminal, normalizedOutput);
4757
5411
  }
4758
5412
  wrote = true;
4759
5413
  }
@@ -4785,6 +5439,28 @@
4785
5439
  state.terminalInitRetries = 0;
4786
5440
  state.terminalInitializing = true;
4787
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
+
4788
5464
  var termWrap = document.createElement("div");
4789
5465
  termWrap.className = "terminal-scroll-wrap";
4790
5466
  container.appendChild(termWrap);
@@ -4800,6 +5476,16 @@
4800
5476
  },
4801
5477
  onResize: function(cols, rows) {
4802
5478
  sendTerminalResize(cols, rows);
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.
5486
+ if (state.terminal && state.terminalOutput) {
5487
+ softResyncTerminal({ skipFit: true });
5488
+ }
4803
5489
  }
4804
5490
  });
4805
5491
 
@@ -4853,7 +5539,7 @@
4853
5539
  syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4854
5540
  }
4855
5541
  } else {
4856
- term.write("点击上方「新对话」开始你的第一次对话。\r\n");
5542
+ wandTerminalWrite(term, "点击上方「新对话」开始你的第一次对话。\r\n");
4857
5543
  }
4858
5544
 
4859
5545
  state.terminalClickHandler = focusInputBox;
@@ -4865,7 +5551,7 @@
4865
5551
  // Container may have been hidden / zero-width at construction
4866
5552
  // time (hard-coded 120x36). Remeasure against the real container
4867
5553
  // so wterm reflows the just-written history to the correct cols.
4868
- ensureTerminalFit("mount");
5554
+ ensureTerminalFit("mount", { forceReplay: true });
4869
5555
  }).catch(function(err) {
4870
5556
  state.terminalInitializing = false;
4871
5557
  console.error("[wand] wterm init failed:", err);
@@ -4994,7 +5680,7 @@
4994
5680
  return "会话已结束,无法继续发送";
4995
5681
  }
4996
5682
  return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
4997
- ? "思考中,可继续发送,消息会自动排队"
5683
+ ? "思考中 · 发送新消息将中断当前回复"
4998
5684
  : "输入消息...";
4999
5685
  }
5000
5686
 
@@ -5525,6 +6211,9 @@
5525
6211
  }
5526
6212
  }
5527
6213
  updateShellChrome();
6214
+ if (state.selectedId && state.gitStatusSessionId !== state.selectedId) {
6215
+ loadGitStatus(state.selectedId);
6216
+ }
5528
6217
 
5529
6218
  var reloadPromise = Promise.resolve();
5530
6219
  if (!opts.skipSelectedOutputReload && state.selectedId) {
@@ -5623,12 +6312,17 @@
5623
6312
  initTerminal();
5624
6313
  }
5625
6314
 
5626
- if (selectedSession && state.terminal) {
5627
- syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "append", scroll: false });
5628
- } else if (!selectedSession) {
6315
+ if (!selectedSession) {
5629
6316
  state.terminalSessionId = null;
5630
6317
  state.terminalOutput = "";
5631
6318
  }
6319
+ // 之前这里会用 selectedSession.output 再 syncTerminalBuffer 一次。
6320
+ // 但 updateShellChrome 在 updateSessionsList、status 推送、init
6321
+ // 等多个高频路径都会被调,每次都拿"可能不带 output 的 slim 快照"
6322
+ // 兜回来 sync 一遍:要么早返回浪费判断,要么 prefix 不匹配触发
6323
+ // reset+全量重写、把 alt-screen 中正在绘制的 Claude TUI 切走。
6324
+ // terminal 写入应当只走 chunk hot-path 与 ws init 这两条权威路径,
6325
+ // 这里不再插手,避免引入二次覆盖。
5632
6326
 
5633
6327
  if (state.terminal && selectedSession && state.currentView === "terminal") {
5634
6328
  maybeScrollTerminalToBottom("view");
@@ -5681,10 +6375,25 @@
5681
6375
  updateShellChrome();
5682
6376
 
5683
6377
  if (state.terminal && id === state.selectedId && data.output !== undefined) {
5684
- syncTerminalBuffer(id, data.output, { mode: "append" });
5685
- // Session-switch / history replay: force a real fit so wterm
5686
- // reflows the just-written output against the real container.
5687
- 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
+ }
5688
6397
  }
5689
6398
 
5690
6399
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
@@ -5699,6 +6408,9 @@
5699
6408
  if (!foundSession) {
5700
6409
  return;
5701
6410
  }
6411
+ if (state.selectedId !== id) {
6412
+ teardownTerminal();
6413
+ }
5702
6414
  state.selectedId = id;
5703
6415
  persistSelectedId();
5704
6416
  state.toolContentCache = {};
@@ -5729,6 +6441,11 @@
5729
6441
  }
5730
6442
  loadOutput(id).then(function() { focusInputBox(true); });
5731
6443
  subscribeToSession(id);
6444
+ // 切换会话时清掉旧 git 状态,再异步刷新
6445
+ state.gitStatus = null;
6446
+ state.gitStatusSessionId = null;
6447
+ updateTopbarGitBadge();
6448
+ loadGitStatus(id, { force: true });
5732
6449
  }
5733
6450
 
5734
6451
  function updatePinState() {
@@ -7622,6 +8339,148 @@
7622
8339
  }
7623
8340
  }
7624
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
+
7625
8484
  function autoResizeInput(el) {
7626
8485
  if (!el) return;
7627
8486
  var minHeight = 36;
@@ -8060,7 +8919,7 @@
8060
8919
  // Container just flipped from hidden -> visible (or geometry changed
8061
8920
  // because chat/terminal panels swapped). Refit now so the terminal
8062
8921
  // picks up the real cols/rows instead of keeping the stale ones.
8063
- if (!structured) ensureTerminalFit("view-switch");
8922
+ if (!structured) ensureTerminalFit("view-switch", { forceReplay: true });
8064
8923
  }
8065
8924
 
8066
8925
 
@@ -8121,7 +8980,8 @@
8121
8980
 
8122
8981
  return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
8123
8982
  if (!readySession) {
8124
- showToast("会话未就绪,将稍后重试。", "info");
8983
+ // ensureSessionReadyForInput / resumeClaudeSessionById 已经在失败路径里
8984
+ // 自行 toast,这里不再重复提示,避免叠两条消息。
8125
8985
  return null;
8126
8986
  }
8127
8987
  var submitView = state.currentView;
@@ -8155,29 +9015,25 @@
8155
9015
  return Promise.resolve();
8156
9016
  }
8157
9017
 
8158
- var isQueueing = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
8159
- if (!isQueueing) {
8160
- // Immediately render user message with thinking indicator
8161
- var userTurn = { role: "user", content: [{ type: "text", text: input }] };
8162
- var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
8163
- userMsgs.push(userTurn);
8164
- var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
8165
- // Write optimistic user turn into session.messages so WS updates
8166
- // that arrive before the HTTP response don't erase it.
8167
- updateSessionSnapshot({
8168
- id: session.id,
8169
- status: "running",
8170
- messages: userMsgs,
8171
- structuredState: optimisticStructuredState,
8172
- });
8173
- state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
8174
- status: "running",
8175
- messages: userMsgs,
8176
- structuredState: optimisticStructuredState,
8177
- }), userMsgs);
8178
- updateInputHint("思考中…");
8179
- renderChat(true);
8180
- }
9018
+ var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
9019
+ // Immediately render user message with thinking indicator
9020
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
9021
+ var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
9022
+ userMsgs.push(userTurn);
9023
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
9024
+ updateSessionSnapshot({
9025
+ id: session.id,
9026
+ status: "running",
9027
+ messages: userMsgs,
9028
+ structuredState: optimisticStructuredState,
9029
+ });
9030
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
9031
+ status: "running",
9032
+ messages: userMsgs,
9033
+ structuredState: optimisticStructuredState,
9034
+ }), userMsgs);
9035
+ updateInputHint("思考中…");
9036
+ renderChat(true);
8181
9037
 
8182
9038
  if (inputBox) {
8183
9039
  inputBox.value = "";
@@ -8194,17 +9050,21 @@
8194
9050
  method: "POST",
8195
9051
  headers: { "Content-Type": "application/json" },
8196
9052
  credentials: "same-origin",
8197
- body: JSON.stringify({ input: input })
9053
+ body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
9054
+ })
9055
+ .then(function(res) {
9056
+ if (!res.ok) {
9057
+ return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
9058
+ throw new Error((payload && payload.error) || "无法发送结构化消息。");
9059
+ });
9060
+ }
9061
+ return res.json();
8198
9062
  })
8199
- .then(function(res) { return res.json(); })
8200
9063
  .then(function(snapshot) {
8201
9064
  if (snapshot && snapshot.error) {
8202
9065
  throw new Error(snapshot.error);
8203
9066
  }
8204
9067
  if (snapshot && snapshot.id) {
8205
- // If a WS update has already bumped the queue epoch, the HTTP
8206
- // response's queuedMessages is stale — drop it to avoid
8207
- // re-introducing already-dequeued items.
8208
9068
  if (state.queueEpoch > epochBeforePost && snapshot.queuedMessages) {
8209
9069
  delete snapshot.queuedMessages;
8210
9070
  }
@@ -8212,13 +9072,8 @@
8212
9072
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
8213
9073
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
8214
9074
  renderChat(true);
8215
- if (isQueueing) {
8216
- var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
8217
- if (queuedCount > 0) {
8218
- showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
8219
- }
8220
- } else {
8221
- updateInputHint("Enter 发送 · Shift+Enter 换行");
9075
+ if (isInterrupting) {
9076
+ showToast("已中断上一条回复,正在处理新消息…", "info");
8222
9077
  }
8223
9078
  }
8224
9079
  })
@@ -8359,7 +9214,10 @@
8359
9214
  }
8360
9215
 
8361
9216
  function canAutoResumeSession(session) {
8362
- 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);
8363
9221
  }
8364
9222
 
8365
9223
  function ensureSessionReadyForInput(session, errorEl) {
@@ -8376,7 +9234,7 @@
8376
9234
  return Promise.resolve(null);
8377
9235
  }
8378
9236
 
8379
- showToast("正在恢复历史会话…", "info");
9237
+ // 静默恢复:不再弹 "正在恢复历史会话…" 提示,让用户发送动作看起来无缝。
8380
9238
  return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
8381
9239
  if (!data) return null;
8382
9240
  updateSessionSnapshot(data);
@@ -8729,17 +9587,20 @@
8729
9587
  }
8730
9588
  }
8731
9589
  var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
9590
+ // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
9591
+ // 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9592
+ var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
8732
9593
  if (composer) {
8733
9594
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
8734
- composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
9595
+ composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
8735
9596
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
8736
9597
  }
8737
9598
  var sendBtn = document.getElementById("send-input-button");
8738
9599
  if (sendBtn) {
8739
- sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
9600
+ sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
8740
9601
  sendBtn.setAttribute("title", isCodex
8741
9602
  ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
8742
- : (structured ? "发送" : (!selectedSession || isRunning ? "发送" : "会话已结束")));
9603
+ : (structured ? "发送" : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
8743
9604
  }
8744
9605
  var container = document.getElementById("output");
8745
9606
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -8902,6 +9763,12 @@
8902
9763
  function flushPendingMessages() {
8903
9764
  if (state.pendingMessages.length === 0) return;
8904
9765
 
9766
+ var selectedSession = getSelectedSession();
9767
+ if (isStructuredSession(selectedSession)) {
9768
+ state.pendingMessages = [];
9769
+ return;
9770
+ }
9771
+
8905
9772
  // Send queued messages in order, bypassing the session-running check
8906
9773
  // since our local state may be stale right after reconnect
8907
9774
  var queue = state.pendingMessages.slice();
@@ -9169,6 +10036,10 @@
9169
10036
 
9170
10037
  function activateSession(data) {
9171
10038
  if (!data || !data.id) return Promise.resolve();
10039
+ state.selectedId = data.id;
10040
+ persistSelectedId();
10041
+ state.currentMessages = [];
10042
+ teardownTerminal();
9172
10043
  resetChatRenderCache();
9173
10044
  switchToSessionView(data.id);
9174
10045
  updateSessionSnapshot(data);
@@ -9359,15 +10230,15 @@
9359
10230
  }
9360
10231
 
9361
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,避免新旧双重补偿。
9362
10239
  var inputPanel = document.querySelector('.input-panel');
9363
10240
  if (!inputPanel) return;
9364
- if (!('visualViewport' in window) || !isTouchDevice()) {
9365
- inputPanel.style.removeProperty('--keyboard-offset');
9366
- return;
9367
- }
9368
- var vv = window.visualViewport;
9369
- var offsetBottom = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
9370
- inputPanel.style.setProperty('--keyboard-offset', offsetBottom + 'px');
10241
+ inputPanel.style.removeProperty('--keyboard-offset');
9371
10242
  }
9372
10243
 
9373
10244
  function resetInputPanelViewportSpacing() {
@@ -9420,7 +10291,7 @@
9420
10291
  // The container height restores but terminal needs time to
9421
10292
  // fill the expanded space, and the scroll position needs resetting.
9422
10293
  if (isTouchDevice()) {
9423
- ensureTerminalFit();
10294
+ ensureTerminalFit("keyboard-blur", { forceReplay: true });
9424
10295
  maybeScrollTerminalToBottom("force");
9425
10296
  }
9426
10297
  }, 100);
@@ -10098,14 +10969,14 @@
10098
10969
  var terminalContainer = document.querySelector('.terminal-container');
10099
10970
 
10100
10971
  // Virtual Keyboard API (Chrome/Edge)
10972
+ // 不再给 input-panel 直接 setPaddingBottom——新方案通过
10973
+ // syncAppViewportHeight 让 body 跟随可见视口收缩,input-panel
10974
+ // 自然上移。这里只把事件留作未来钩子,避免和新方案双重补偿。
10101
10975
  if ('virtualKeyboard' in navigator) {
10102
10976
  var vk = navigator.virtualKeyboard;
10103
-
10104
10977
  vk.addEventListener('geometrychange', function() {
10105
10978
  if (!inputPanel) return;
10106
- var rect = vk.boundingRect;
10107
- var kbHeight = rect ? rect.height : 0;
10108
- inputPanel.style.paddingBottom = kbHeight > 0 ? kbHeight + 'px' : '';
10979
+ inputPanel.style.removeProperty('padding-bottom');
10109
10980
  });
10110
10981
  }
10111
10982
 
@@ -10128,6 +10999,26 @@
10128
10999
  }
10129
11000
  }
10130
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
+
10131
11022
  // Visual viewport handling for better mobile keyboard support
10132
11023
  function setupVisualViewportHandlers() {
10133
11024
  if (!('visualViewport' in window)) return;
@@ -10143,6 +11034,10 @@
10143
11034
  var isKeyboardOpen = offsetBottom > 50;
10144
11035
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
10145
11036
 
11037
+ // 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
11038
+ // 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
11039
+ syncAppViewportHeight();
11040
+
10146
11041
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
10147
11042
  syncInputBoxScroll(inputBox);
10148
11043
  }
@@ -10152,14 +11047,14 @@
10152
11047
  // Without an immediate refit, any chunk arriving while the keyboard
10153
11048
  // animates in renders against the old grid and tears the screen.
10154
11049
  if (!keyboardOpen && isKeyboardOpen) {
10155
- ensureTerminalFit("keyboard-open");
11050
+ ensureTerminalFit("keyboard-open", { forceReplay: true });
10156
11051
  }
10157
11052
 
10158
11053
  // Keyboard just closed — force terminal refit and scroll to bottom
10159
11054
  // after a delay so the keyboard dismiss animation and layout settle.
10160
11055
  if (keyboardOpen && !isKeyboardOpen) {
10161
11056
  setTimeout(function() {
10162
- ensureTerminalFit("keyboard-close");
11057
+ ensureTerminalFit("keyboard-close", { forceReplay: true });
10163
11058
  maybeScrollTerminalToBottom("force");
10164
11059
  }, 200);
10165
11060
  }
@@ -10293,11 +11188,11 @@
10293
11188
  // Page returning from background: container dimensions may have
10294
11189
  // drifted (PWA standalone, tab switch, iOS address-bar toggle).
10295
11190
  state.visibilityHandler = function() {
10296
- if (!document.hidden) ensureTerminalFit("visibility");
11191
+ if (!document.hidden) ensureTerminalFit("visibility", { forceReplay: true });
10297
11192
  };
10298
11193
  document.addEventListener("visibilitychange", state.visibilityHandler);
10299
11194
  // Mobile device rotation — large geometry change.
10300
- state.orientationHandler = function() { ensureTerminalFit("orientation"); };
11195
+ state.orientationHandler = function() { ensureTerminalFit("orientation", { forceReplay: true }); };
10301
11196
  window.addEventListener("orientationchange", state.orientationHandler);
10302
11197
  requestAnimationFrame(function() { scheduleTerminalResize(true); });
10303
11198
  }
@@ -10393,6 +11288,18 @@
10393
11288
  state.terminal.destroy();
10394
11289
  state.terminal = null;
10395
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
+ }
10396
11303
  state.terminalSessionId = null;
10397
11304
  state.terminalOutput = "";
10398
11305
  state.terminalAutoFollow = true;
@@ -10416,40 +11323,53 @@
10416
11323
  }
10417
11324
  }
10418
11325
 
10419
- // Unified entry point for re-fitting the xterm grid to its container.
10420
- // Why: wterm's `autoResize` ResizeObserver only fires on subsequent
10421
- // container size changes. If the terminal is mounted or written to
10422
- // while the container is hidden/zero-width, cols/rows stay wrong and
10423
- // new output renders with broken wrapping (content visually piles at
10424
- // the top). Call this after any layout change that might have altered
10425
- // container geometry (mount, session switch, view switch, refresh).
10426
- function ensureTerminalFit(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) {
10427
11342
  if (!state.terminal) return false;
11343
+ var opts = options || {};
11344
+ var forceReplay = opts.forceReplay === true;
10428
11345
  var el = document.getElementById("output");
10429
- if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return false;
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
+ }
10430
11355
  var prevCols = state.terminal.cols;
10431
11356
  var prevRows = state.terminal.rows;
10432
11357
  requestAnimationFrame(function() {
10433
11358
  requestAnimationFrame(function() {
10434
11359
  if (!state.terminal) return;
10435
11360
  if (typeof state.terminal.remeasure === "function") {
11361
+ // remeasure → wterm.resize (if cols changed) → onResize →
11362
+ // softResyncTerminal({ skipFit: true }). Replay happens there.
10436
11363
  state.terminal.remeasure();
10437
11364
  }
10438
11365
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
10439
- // Cache the container width that produced this cols/rows so the
10440
- // hot-path chunk writer can detect drift cheaply (avoids running
10441
- // a full remeasure on every WebSocket message).
10442
- state.lastFitContainerWidth = el.offsetWidth;
10443
- state.lastFitContainerHeight = el.offsetHeight;
10444
- // If cols actually changed, the previously written buffer was
10445
- // wrapped to the old width. Replay the full buffer so historical
10446
- // lines and any in-flight CSI cursor sequences re-render against
10447
- // the new grid — this is what fixes the "torn" screens users see
10448
- // after rotating, opening the keyboard, or resizing the panel.
10449
- var skipReplay = state.suppressFitReplay === true;
10450
- state.suppressFitReplay = false;
10451
- if (!skipReplay && (state.terminal.cols !== prevCols || state.terminal.rows !== prevRows)) {
10452
- if (state.terminalOutput) softResyncTerminal();
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 });
10453
11373
  }
10454
11374
  if (state.terminalAutoFollow || isTerminalNearBottom()) {
10455
11375
  maybeScrollTerminalToBottom("resize");
@@ -10459,41 +11379,40 @@
10459
11379
  return true;
10460
11380
  }
10461
11381
 
10462
- // Cheap cols/rows drift check call before writing a new PTY chunk so
10463
- // the chunk renders against the correct grid even if ResizeObserver
10464
- // hasn't fired yet (e.g. mobile keyboard mid-animation, iOS PWA address
10465
- // bar collapse, panel drag in progress). Only runs a real remeasure
10466
- // when the container width changed since the last fit; otherwise it is
10467
- // effectively a single offsetWidth read.
10468
- function maybeRefitTerminal() {
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) {
10469
11388
  if (!state.terminal) return;
10470
- var el = document.getElementById("output");
10471
- if (!el) return;
10472
- var w = el.offsetWidth;
10473
- var h = el.offsetHeight;
10474
- if (w === 0 || h === 0) return;
10475
- // First call: just record the baseline and let ensureTerminalFit
10476
- // (called from initTerminal/mount) own the initial sync.
10477
- if (state.lastFitContainerWidth === undefined) {
10478
- state.lastFitContainerWidth = w;
10479
- state.lastFitContainerHeight = h;
10480
- return;
10481
- }
10482
- if (w === state.lastFitContainerWidth && h === state.lastFitContainerHeight) return;
10483
- state.lastFitContainerWidth = w;
10484
- state.lastFitContainerHeight = h;
10485
- var prevCols = state.terminal.cols;
10486
- var prevRows = state.terminal.rows;
10487
- if (typeof state.terminal.remeasure === "function") {
10488
- state.terminal.remeasure();
10489
- }
10490
- if (state.terminal.cols !== prevCols || state.terminal.rows !== prevRows) {
10491
- sendTerminalResize(state.terminal.cols, state.terminal.rows);
10492
- // Don't replay here: the caller is about to write a fresh chunk and
10493
- // a softResync would race with it. The chunk itself will reach the
10494
- // correct grid; older buffer drift is repaired by the next
10495
- // ensureTerminalFit / health check / manual refresh.
11389
+ var opts = options || {};
11390
+ var forceReplay = opts.forceReplay !== false; // default true: retry path implies "may be stale"
11391
+ var attempts = 0;
11392
+ var maxAttempts = 8;
11393
+ function tryFit() {
11394
+ if (!state.terminal) return;
11395
+ var el = document.getElementById("output");
11396
+ if (el) {
11397
+ // Force a layout flush so offsetWidth reflects the post-resume
11398
+ // container size, not a stale 0 from the suspended frame.
11399
+ void el.offsetHeight;
11400
+ }
11401
+ if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
11402
+ ensureTerminalFit(reason, { forceReplay: forceReplay });
11403
+ return;
11404
+ }
11405
+ if (++attempts >= maxAttempts) return;
11406
+ // Mix rAF and timeout: some Android WebView versions skip rAF
11407
+ // during the first frame after resume, so falling back to a
11408
+ // 16ms timer guarantees forward progress.
11409
+ if (attempts <= 4) {
11410
+ requestAnimationFrame(tryFit);
11411
+ } else {
11412
+ setTimeout(tryFit, 32);
11413
+ }
10496
11414
  }
11415
+ tryFit();
10497
11416
  }
10498
11417
 
10499
11418
  function scheduleTerminalResize(immediate) {
@@ -10535,7 +11454,54 @@
10535
11454
  if (timeEls.length > 0) scheduleSessionListUpdate();
10536
11455
  }, 30000);
10537
11456
 
10538
- function initWebSocket() {
11457
+ function cancelWsReconnect() {
11458
+ if (state.wsReconnectTimer) {
11459
+ clearTimeout(state.wsReconnectTimer);
11460
+ state.wsReconnectTimer = null;
11461
+ }
11462
+ }
11463
+
11464
+ // Drop any in-flight socket and start a new one *now* — used by the
11465
+ // Android resume bridge to recover from zombie connections (socket
11466
+ // still says OPEN, but the TCP path was torn down by Doze). Skips
11467
+ // the backoff timer; the caller has already decided this is urgent.
11468
+ function forceReconnectWebSocket(reason) {
11469
+ cancelWsReconnect();
11470
+ if (state.ws) {
11471
+ var stale = state.ws;
11472
+ // Detach handlers so the imminent close doesn't trigger another
11473
+ // reconnect path while we're already starting a fresh one.
11474
+ try { stale.onclose = null; } catch (e) { /* ignore */ }
11475
+ try { stale.onerror = null; } catch (e) { /* ignore */ }
11476
+ try { stale.close(); } catch (e) { /* ignore */ }
11477
+ state.ws = null;
11478
+ }
11479
+ state.wsConnected = false;
11480
+ state.wsReconnectAttempts = 0;
11481
+ initWebSocket(reason);
11482
+ }
11483
+
11484
+ function scheduleWsReconnect() {
11485
+ if (state.wsReconnectTimer) return;
11486
+ // Don't burn battery reconnecting while hidden — the resume
11487
+ // listener will kick a fresh connect when we're foreground.
11488
+ if (document.hidden) return;
11489
+ var attempt = state.wsReconnectAttempts || 0;
11490
+ // 0.5s, 1s, 2s, 4s, then capped at 8s. Faster than the old
11491
+ // fixed 2s on the first retry (matters for transient blips)
11492
+ // and bounded so a flapping server doesn't get hammered.
11493
+ var delays = [500, 1000, 2000, 4000, 8000];
11494
+ var delay = delays[attempt < delays.length ? attempt : delays.length - 1];
11495
+ state.wsReconnectAttempts = attempt + 1;
11496
+ state.wsReconnectTimer = setTimeout(function() {
11497
+ state.wsReconnectTimer = null;
11498
+ if (state.config && !state.ws && !document.hidden) {
11499
+ initWebSocket("backoff");
11500
+ }
11501
+ }, delay);
11502
+ }
11503
+
11504
+ function initWebSocket(reason) {
10539
11505
  if (!window.WebSocket) return false;
10540
11506
 
10541
11507
  // Prevent duplicate connections
@@ -10553,6 +11519,10 @@
10553
11519
  ws.onopen = function() {
10554
11520
  state.ws = ws;
10555
11521
  state.wsConnected = true;
11522
+ // Reset backoff on a successful connect so the next disconnect
11523
+ // starts the ladder from 500ms again.
11524
+ state.wsReconnectAttempts = 0;
11525
+ cancelWsReconnect();
10556
11526
  // Subscribe to current session if any
10557
11527
  subscribeToSession(state.selectedId);
10558
11528
  // Flush pending messages after reconnection
@@ -10560,7 +11530,10 @@
10560
11530
  // Re-fit terminal on reconnect — the viewport may have changed
10561
11531
  // while disconnected, so remeasure against real container size
10562
11532
  // rather than sending stale cols/rows from before the disconnect.
10563
- ensureTerminalFit("ws-reconnect");
11533
+ // Use the retry variant: when the reconnect is triggered by
11534
+ // Android resume, the WebView container may still be 0×0 for
11535
+ // the first 1–2 frames while layout settles.
11536
+ ensureTerminalFitWithRetry("ws-reconnect");
10564
11537
  };
10565
11538
 
10566
11539
  ws.onmessage = function(event) {
@@ -10575,12 +11548,7 @@
10575
11548
  ws.onclose = function() {
10576
11549
  state.ws = null;
10577
11550
  state.wsConnected = false;
10578
- // Reconnect after 2 seconds
10579
- setTimeout(function() {
10580
- if (state.config && !state.ws) {
10581
- initWebSocket();
10582
- }
10583
- }, 2000);
11551
+ scheduleWsReconnect();
10584
11552
  };
10585
11553
 
10586
11554
  ws.onerror = function() {
@@ -10589,6 +11557,9 @@
10589
11557
 
10590
11558
  return true;
10591
11559
  } catch (e) {
11560
+ // Constructor threw (rare — bad URL, blocked scheme). Try again
11561
+ // through the backoff path so we don't get stuck.
11562
+ scheduleWsReconnect();
10592
11563
  return false;
10593
11564
  }
10594
11565
  }
@@ -10675,11 +11646,14 @@
10675
11646
  // Fast path: write chunk directly to avoid full-output comparison.
10676
11647
  state.lastChunkAt = Date.now();
10677
11648
  state.terminalLiveStreamSessions[msg.sessionId] = true;
10678
- // Detect cheap container-width drift before applying the chunk
10679
- // so absolute-cursor CSI sequences in the chunk land on the
10680
- // right grid (otherwise content tears or stacks at the top).
10681
- maybeRefitTerminal();
10682
- state.terminal.write(msg.data.chunk);
11649
+ // 不再在 hot-path maybeRefitTerminal/remeasure。它会偷偷把
11650
+ // wterm this.cols 改成新值,让 wterm 自己的 ResizeObserver
11651
+ // 误判 newCols === this.cols 而跳过 wterm.resize() —— 那条路径
11652
+ // 才会真正调 Renderer.setup() 重建 DOM 行。绕过它就让容器尺寸
11653
+ // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
11654
+ // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
11655
+ wandTerminalWrite(state.terminal, msg.data.chunk);
11656
+ maybeScheduleResyncForChunk(msg.data.chunk);
10683
11657
  state.terminalSessionId = msg.sessionId;
10684
11658
  if (msg.data.output) {
10685
11659
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
@@ -10813,14 +11787,19 @@
10813
11787
  renderChat(true);
10814
11788
  updateTaskDisplay();
10815
11789
  updateApprovalStats();
10816
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
10817
- // Ensure terminal is properly fitted after receiving initial data
10818
- scheduleTerminalResize(true);
10819
- if (state.terminal && state.terminal.remeasure) {
10820
- requestAnimationFrame(function() {
10821
- if (state.terminal) state.terminal.remeasure();
10822
- });
10823
- }
11790
+ // ws 重新订阅时拿到的是服务端 ring buffer 的最新窗口(最多
11791
+ // 120KB);客户端缓存的 terminalOutput 可能早于服务端窗口
11792
+ // 的起点。append 模式有 prefix 检查,prefix 不匹配就 reset+
11793
+ // 全量重写、全等就直接 return false——前者会把 alt-screen
11794
+ // 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
11795
+ // 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
11796
+ // 这是订阅时唯一可信的全量基线。
11797
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
11798
+ // 紧接着等容器有真实尺寸再 fit + softResync:wterm 启动
11799
+ // 硬编码 cols=120,replace 写入也可能落在错的列宽上,
11800
+ // ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
11801
+ // 一次,确保最终一定按真实宽度重排。
11802
+ ensureTerminalFitWithRetry("init");
10824
11803
  }
10825
11804
  break;
10826
11805
  case 'usage':