@co0ontty/wand 1.18.1 → 1.18.12

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,7 @@
167
167
  return false;
168
168
  }
169
169
  })(),
170
+ topbarMoreOpen: false,
170
171
  chatAutoFollow: (function() {
171
172
  try {
172
173
  var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
@@ -182,6 +183,8 @@
182
183
  chatScrollHandler: null,
183
184
  lastForegroundSyncAt: 0,
184
185
  foregroundSyncTimer: null,
186
+ wsReconnectAttempts: 0,
187
+ wsReconnectTimer: null,
185
188
  currentMessages: [],
186
189
  lastRenderedHash: 0,
187
190
  lastRenderedMsgCount: 0,
@@ -802,14 +805,11 @@
802
805
 
803
806
  function updateInstallPrompt() {
804
807
  // 显示或隐藏菜单栏中的安装按钮
808
+ var visible = !!(state.showInstallPrompt && state.deferredPrompt);
805
809
  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
- }
810
+ if (installBtn) installBtn.classList.toggle('hidden', !visible);
811
+ var topbarInstallItem = document.getElementById('topbar-install-item');
812
+ if (topbarInstallItem) topbarInstallItem.classList.toggle('hidden', !visible);
813
813
  }
814
814
 
815
815
  function renderBootLoading() {
@@ -824,36 +824,48 @@
824
824
  '</div>';
825
825
  }
826
826
 
827
- function scheduleForegroundSync(reason) {
827
+ function scheduleForegroundSync(reason, opts) {
828
828
  if (!state.config) return;
829
829
  if (document.hidden) return;
830
+ var immediate = opts && opts.immediate === true;
830
831
  var now = Date.now();
831
- if (now - state.lastForegroundSyncAt < 1500) return;
832
+ // 节流只是为了防止 visibilitychange/focus/pageshow 在前台切换时
833
+ // 连珠炮式触发同一份重连工作,不再借此延迟实际同步——之前用
834
+ // 80ms 兜延迟的版本会在前台事件后再去 loadOutput 全量重写
835
+ // terminal,但 wterm cols 那时还没被 ResizeObserver 自适应到,
836
+ // 写进去的全是按错列宽排版的内容,结果"切回前台/刷新页面 →
837
+ // 中间一大段都看不到"反而成了常态。
838
+ if (!immediate && now - state.lastForegroundSyncAt < 1500) return;
832
839
  state.lastForegroundSyncAt = now;
833
840
  if (state.foregroundSyncTimer) {
834
841
  clearTimeout(state.foregroundSyncTimer);
835
- }
836
- state.foregroundSyncTimer = setTimeout(function() {
837
842
  state.foregroundSyncTimer = null;
838
- syncOnForeground(reason);
839
- }, 80);
843
+ }
844
+ syncOnForeground(reason, immediate);
840
845
  }
841
846
 
842
- function syncOnForeground(reason) {
847
+ function syncOnForeground(reason, force) {
843
848
  if (!state.config) return Promise.resolve();
844
849
  if (document.hidden) return Promise.resolve();
845
- if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
850
+ // On Android resume the previous WS may still report OPEN/CONNECTING
851
+ // for a few seconds because the close frame hasn't been delivered
852
+ // yet (TCP keepalive / Doze suspended the network stack). Force a
853
+ // fresh socket so we don't sit on a zombie connection.
854
+ if (force) {
855
+ forceReconnectWebSocket("resume-force");
856
+ } else if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
846
857
  initWebSocket();
847
858
  }
848
859
  if (state.claudeHistoryLoaded) {
849
860
  loadClaudeHistory();
850
861
  }
851
- return loadSessions({ skipSelectedOutputReload: true }).then(function() {
852
- if (state.selectedId) {
853
- return loadOutput(state.selectedId);
854
- }
855
- scheduleChatRender(true);
856
- }).catch(function(e) {
862
+ // 不再 loadOutput 当前会话——WS 重连后服务端会主动推一条 init
863
+ // 消息,那条路径已经走 ensureTerminalFitWithRetry 强制按真实
864
+ // cols 重排 history,足够覆盖前台恢复时的同步需求。这里多加
865
+ // 一次 fetch + syncTerminalBuffer 反而会在 ws/http 两路的 output
866
+ // 之间来回 reset,导致 alt-screen 中正在绘制的 Claude TUI 被
867
+ // 中途清掉。只把会话列表刷一下,保证状态条/会话名等元数据是新的。
868
+ return loadSessions({ skipSelectedOutputReload: true }).catch(function(e) {
857
869
  console.error("[wand] foreground sync failed:", reason, e);
858
870
  });
859
871
  }
@@ -863,8 +875,15 @@
863
875
  window.__wandForegroundSyncBound = true;
864
876
 
865
877
  document.addEventListener("visibilitychange", function() {
866
- if (!document.hidden) {
878
+ if (document.hidden) {
879
+ // Stop the reconnect backoff while hidden — the OS may freeze
880
+ // timers and then deliver them in a burst when we resume,
881
+ // creating a thundering-herd of connect attempts. The resume
882
+ // event will trigger one decisive reconnect instead.
883
+ cancelWsReconnect();
884
+ } else {
867
885
  scheduleForegroundSync("visibility");
886
+ ensureTerminalFitWithRetry("visibility");
868
887
  }
869
888
  });
870
889
 
@@ -879,6 +898,18 @@
879
898
  window.addEventListener("resume", function() {
880
899
  scheduleForegroundSync("resume");
881
900
  });
901
+
902
+ // Bridge from Android WebView host: MainActivity.onResume() calls
903
+ // evaluateJavascript to dispatch this event, which is the only
904
+ // reliable foreground signal once Doze/process-suspension has
905
+ // frozen page-level events (visibilitychange/focus/pageshow may
906
+ // fire late or not at all after a long suspend). Force-reconnect
907
+ // and force-refit immediately rather than waiting for the
908
+ // throttled scheduleForegroundSync path.
909
+ window.addEventListener("wand-android-resume", function() {
910
+ scheduleForegroundSync("android-resume", { immediate: true });
911
+ ensureTerminalFitWithRetry("android-resume");
912
+ });
882
913
  }
883
914
 
884
915
  function restoreLoginSession() {
@@ -1172,12 +1203,38 @@
1172
1203
  '</aside>' +
1173
1204
  '<main class="main-content">' +
1174
1205
  '<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>' +
1206
+ '<div class="topbar-left">' +
1207
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1208
+ '<span class="hamburger-icon">' +
1209
+ '<span></span><span></span><span></span>' +
1210
+ '</span>' +
1211
+ '</button>' +
1212
+ '<span class="topbar-brand" aria-hidden="true">W</span>' +
1213
+ '</div>' +
1214
+ '<div class="topbar-center">' +
1215
+ (selectedSession
1216
+ ? (
1217
+ '<span class="topbar-session-title" title="' + escapeHtml(selectedSession.command || "") + '">' + escapeHtml(shortCommand(selectedSession.command)) + '</span>' +
1218
+ '<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>' +
1219
+ '<span class="current-task hidden" id="current-task"></span>' +
1220
+ (selectedSession.cwd ? '<span class="topbar-cwd" id="topbar-cwd" title="' + escapeHtml(selectedSession.cwd) + '" role="button" tabindex="0">' + escapeHtml(selectedSession.cwd) + '</span>' : '')
1221
+ )
1222
+ : '<span class="topbar-tagline">Wand 控制台</span>' +
1223
+ '<span class="current-task hidden" id="current-task"></span>'
1224
+ ) +
1225
+ '</div>' +
1226
+ '<div class="topbar-right">' +
1227
+ (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>' : '') +
1228
+ '<div class="topbar-more-wrap">' +
1229
+ '<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
+ '<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
1231
+ '<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>' +
1232
+ '<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>' +
1233
+ '<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>' +
1234
+ '<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>' +
1235
+ '</div>' +
1236
+ '</div>' +
1237
+ '</div>' +
1181
1238
  '</div>' +
1182
1239
  // File panel backdrop (mobile)
1183
1240
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
@@ -1821,7 +1878,7 @@
1821
1878
  }
1822
1879
 
1823
1880
  function renderSessions() {
1824
- var activeSessions = state.sessions.filter(function(session) { return !session.archived && !session.resumedToSessionId; });
1881
+ var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
1825
1882
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
1826
1883
  var groups = [];
1827
1884
  groups.push(renderSessionManageBar());
@@ -1994,9 +2051,7 @@
1994
2051
  }
1995
2052
 
1996
2053
  function getSelectableSessions() {
1997
- return state.sessions.filter(function(session) {
1998
- return session.archived || !session.resumedToSessionId;
1999
- });
2054
+ return state.sessions.slice();
2000
2055
  }
2001
2056
 
2002
2057
  function countSelectableItems() {
@@ -2743,10 +2798,8 @@
2743
2798
  var statusMap = {
2744
2799
  "stopped": "已停止",
2745
2800
  "running": "运行中",
2746
- "idle": "空闲",
2747
- "thinking": "思考中",
2748
- "waiting-input": "等待输入",
2749
- "initializing": "启动中"
2801
+ "exited": "已退出",
2802
+ "failed": "已失败"
2750
2803
  };
2751
2804
  return statusMap[session.status] || session.status;
2752
2805
  }
@@ -3790,6 +3843,88 @@
3790
3843
  var filePanelBackdrop = document.getElementById("file-panel-backdrop");
3791
3844
  if (filePanelBackdrop) filePanelBackdrop.addEventListener("click", closeFilePanel);
3792
3845
 
3846
+ // Topbar: file button (mirrors toggleFilePanel)
3847
+ var topbarFileBtn = document.getElementById("topbar-file-button");
3848
+ if (topbarFileBtn) topbarFileBtn.addEventListener("click", toggleFilePanel);
3849
+
3850
+ // Topbar: cwd click → open file panel
3851
+ var topbarCwdEl = document.getElementById("topbar-cwd");
3852
+ if (topbarCwdEl) {
3853
+ topbarCwdEl.addEventListener("click", function() {
3854
+ if (!state.filePanelOpen) toggleFilePanel();
3855
+ });
3856
+ topbarCwdEl.addEventListener("keydown", function(e) {
3857
+ if (e.key === "Enter" || e.key === " ") {
3858
+ e.preventDefault();
3859
+ if (!state.filePanelOpen) toggleFilePanel();
3860
+ }
3861
+ });
3862
+ }
3863
+
3864
+ // Topbar: more menu
3865
+ var topbarMoreBtn = document.getElementById("topbar-more-button");
3866
+ var topbarMoreMenu = document.getElementById("topbar-more-menu");
3867
+ if (topbarMoreBtn && topbarMoreMenu) {
3868
+ topbarMoreBtn.addEventListener("click", function(e) {
3869
+ e.stopPropagation();
3870
+ state.topbarMoreOpen = !state.topbarMoreOpen;
3871
+ topbarMoreMenu.classList.toggle("hidden", !state.topbarMoreOpen);
3872
+ topbarMoreBtn.classList.toggle("active", state.topbarMoreOpen);
3873
+ topbarMoreBtn.setAttribute("aria-expanded", state.topbarMoreOpen ? "true" : "false");
3874
+ });
3875
+ topbarMoreMenu.addEventListener("click", function(e) {
3876
+ var btn = e.target && e.target.closest ? e.target.closest(".topbar-more-item") : null;
3877
+ if (!btn) return;
3878
+ var action = btn.getAttribute("data-action");
3879
+ // Close menu first regardless of action
3880
+ state.topbarMoreOpen = false;
3881
+ topbarMoreMenu.classList.add("hidden");
3882
+ topbarMoreBtn.classList.remove("active");
3883
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3884
+ switch (action) {
3885
+ case "settings":
3886
+ openSettingsModal();
3887
+ break;
3888
+ case "refresh":
3889
+ window.location.reload();
3890
+ break;
3891
+ case "install":
3892
+ if (state.deferredPrompt) {
3893
+ state.deferredPrompt.prompt();
3894
+ state.deferredPrompt.userChoice.then(function() {
3895
+ state.deferredPrompt = null;
3896
+ state.showInstallPrompt = false;
3897
+ updateInstallPrompt();
3898
+ });
3899
+ }
3900
+ break;
3901
+ case "logout":
3902
+ logout();
3903
+ break;
3904
+ }
3905
+ });
3906
+ // Close on outside click
3907
+ document.addEventListener("click", function(e) {
3908
+ if (!state.topbarMoreOpen) return;
3909
+ var wrap = topbarMoreMenu.parentElement;
3910
+ if (wrap && !wrap.contains(e.target)) {
3911
+ state.topbarMoreOpen = false;
3912
+ topbarMoreMenu.classList.add("hidden");
3913
+ topbarMoreBtn.classList.remove("active");
3914
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3915
+ }
3916
+ });
3917
+ // Close on ESC
3918
+ document.addEventListener("keydown", function(e) {
3919
+ if (e.key === "Escape" && state.topbarMoreOpen) {
3920
+ state.topbarMoreOpen = false;
3921
+ topbarMoreMenu.classList.add("hidden");
3922
+ topbarMoreBtn.classList.remove("active");
3923
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3924
+ }
3925
+ });
3926
+ }
3927
+
3793
3928
  // Terminal scale controls (topbar)
3794
3929
  var scaleDownBtn = document.getElementById("terminal-scale-down-top");
3795
3930
  var scaleUpBtn = document.getElementById("terminal-scale-up-top");
@@ -4659,9 +4794,148 @@
4659
4794
  requestSyncScrollbar();
4660
4795
  }
4661
4796
 
4797
+ // ──────── East-Asian-Wide padding for wterm WASM ────────
4798
+ //
4799
+ // wterm's WASM grid (as of @wterm/core 0.1.8/0.1.9) treats every
4800
+ // codepoint as occupying exactly 1 cell, while node-pty's backend
4801
+ // and Claude Code's TUI emit cursor-positioning sequences that
4802
+ // assume CJK / fullwidth / emoji codepoints occupy 2 columns
4803
+ // (Unicode TR11 East-Asian-Width = W or F). The mismatch makes
4804
+ // every CSI cursor move after CJK output drift by N/2 columns,
4805
+ // causing in-place rewrites (thinking spinner, todo list,
4806
+ // permission menus) to leave torn residue like "替替换换".
4807
+ //
4808
+ // Fix: insert U+2060 (Word Joiner — zero-width, unbreakable) after
4809
+ // each wide codepoint before handing the byte stream to the WASM
4810
+ // grid. The WJ takes one cell, so wide chars now occupy 2 cells —
4811
+ // matching the backend's column accounting exactly. The browser
4812
+ // renders WJ at zero width, so the visual layout stays correct.
4813
+ //
4814
+ // The scanner is ANSI-aware: it tracks ESC / CSI / OSC / DCS
4815
+ // / PM / APC state across chunk boundaries so wide codepoints
4816
+ // inside escape sequences (e.g. OSC window-title payloads) are
4817
+ // not padded — that would break sequence parsing.
4818
+ function isEastAsianWide(cp) {
4819
+ if (cp < 0x1100) return false;
4820
+ return (
4821
+ (cp >= 0x1100 && cp <= 0x115f) ||
4822
+ (cp >= 0x2329 && cp <= 0x232a) ||
4823
+ (cp >= 0x2e80 && cp <= 0x303e) ||
4824
+ (cp >= 0x3041 && cp <= 0x33ff) ||
4825
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
4826
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
4827
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
4828
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
4829
+ (cp >= 0xf900 && cp <= 0xfaff) ||
4830
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
4831
+ (cp >= 0xff00 && cp <= 0xff60) ||
4832
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
4833
+ (cp >= 0x1f000 && cp <= 0x1f9ff) ||
4834
+ (cp >= 0x20000 && cp <= 0x2fffd) ||
4835
+ (cp >= 0x30000 && cp <= 0x3fffd)
4836
+ );
4837
+ }
4838
+
4839
+ var WAND_WIDE_FILLER = "\u2060";
4840
+
4841
+ function createWideParserState() { return { mode: "normal" }; }
4842
+
4843
+ function widePadAnsi(data, st) {
4844
+ if (!data) return "";
4845
+ var s = String(data);
4846
+ var out = "";
4847
+ for (var i = 0; i < s.length; i++) {
4848
+ var code = s.charCodeAt(i);
4849
+ var cp = code;
4850
+ var consumed = 1;
4851
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
4852
+ var lo = s.charCodeAt(i + 1);
4853
+ if (lo >= 0xdc00 && lo <= 0xdfff) {
4854
+ cp = (code - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
4855
+ consumed = 2;
4856
+ }
4857
+ }
4858
+ var ch = consumed === 2 ? s.substr(i, 2) : s.charAt(i);
4859
+ switch (st.mode) {
4860
+ case "normal":
4861
+ if (cp === 0x1b) { st.mode = "esc"; out += ch; }
4862
+ else if (cp === 0x9b) { st.mode = "csi"; out += ch; }
4863
+ else if (cp === 0x9d || cp === 0x90 || cp === 0x9e || cp === 0x9f) {
4864
+ st.mode = "string"; out += ch;
4865
+ } else {
4866
+ out += ch;
4867
+ if (isEastAsianWide(cp)) out += WAND_WIDE_FILLER;
4868
+ }
4869
+ break;
4870
+ case "esc":
4871
+ out += ch;
4872
+ if (cp === 0x5b) st.mode = "csi";
4873
+ else if (cp === 0x5d || cp === 0x50 || cp === 0x58 ||
4874
+ cp === 0x5e || cp === 0x5f) st.mode = "string";
4875
+ else st.mode = "normal";
4876
+ break;
4877
+ case "csi":
4878
+ out += ch;
4879
+ if (cp >= 0x40 && cp <= 0x7e) st.mode = "normal";
4880
+ break;
4881
+ case "string":
4882
+ out += ch;
4883
+ if (cp === 0x07 || cp === 0x9c) st.mode = "normal";
4884
+ else if (cp === 0x1b) st.mode = "string-esc";
4885
+ break;
4886
+ case "string-esc":
4887
+ out += ch;
4888
+ if (cp === 0x5c) st.mode = "normal";
4889
+ else st.mode = "string";
4890
+ break;
4891
+ }
4892
+ i += consumed - 1;
4893
+ }
4894
+ return out;
4895
+ }
4896
+
4897
+ function wandTerminalWrite(terminal, data) {
4898
+ if (!terminal || data == null) return;
4899
+ if (!state.wideParserState) state.wideParserState = createWideParserState();
4900
+ terminal.write(widePadAnsi(data, state.wideParserState));
4901
+ }
4902
+
4903
+ function resetWideParserState() {
4904
+ state.wideParserState = createWideParserState();
4905
+ }
4906
+
4907
+ // Strip the wide-pad filler from copied text so users pasting
4908
+ // selected terminal output don't get hidden U+2060 sprinkled
4909
+ // through every CJK string.
4910
+ function stripWideFillerForCopy() {
4911
+ if (typeof document === "undefined") return;
4912
+ document.addEventListener("copy", function(e) {
4913
+ var sel = window.getSelection && window.getSelection();
4914
+ if (!sel || sel.isCollapsed) return;
4915
+ var anchor = sel.anchorNode;
4916
+ var node = anchor && anchor.nodeType === 3 ? anchor.parentNode : anchor;
4917
+ var output = document.getElementById("output");
4918
+ if (!output || !node || !output.contains(node)) return;
4919
+ var text = sel.toString();
4920
+ if (text.indexOf(WAND_WIDE_FILLER) === -1) return;
4921
+ if (e.clipboardData) {
4922
+ e.clipboardData.setData("text/plain", text.split(WAND_WIDE_FILLER).join(""));
4923
+ e.preventDefault();
4924
+ }
4925
+ });
4926
+ }
4927
+ stripWideFillerForCopy();
4928
+
4662
4929
  function resetTerminal() {
4663
- if (!state.terminal || typeof state.terminal.reset !== "function") return;
4664
- state.terminal.reset();
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");
4938
+ resetWideParserState();
4665
4939
  }
4666
4940
 
4667
4941
  // Soft resync terminal: reset WASM grid and replay full output buffer.
@@ -4670,7 +4944,7 @@
4670
4944
  function softResyncTerminal() {
4671
4945
  if (!state.terminal || !state.terminalOutput) return false;
4672
4946
  resetTerminal();
4673
- state.terminal.write(state.terminalOutput);
4947
+ wandTerminalWrite(state.terminal, state.terminalOutput);
4674
4948
  state.lastTerminalResyncAt = Date.now();
4675
4949
  maybeScrollTerminalToBottom("output");
4676
4950
  // Remeasure against real container: the refresh button used to only
@@ -4734,7 +5008,7 @@
4734
5008
  if (normalizedOutput !== currentOutput) {
4735
5009
  resetTerminal();
4736
5010
  if (normalizedOutput) {
4737
- state.terminal.write(normalizedOutput);
5011
+ wandTerminalWrite(state.terminal, normalizedOutput);
4738
5012
  }
4739
5013
  wrote = true;
4740
5014
  }
@@ -4745,7 +5019,7 @@
4745
5019
  } else if (normalizedOutput.startsWith(currentOutput)) {
4746
5020
  var delta = normalizedOutput.slice(currentOutput.length);
4747
5021
  if (delta) {
4748
- state.terminal.write(delta);
5022
+ wandTerminalWrite(state.terminal, delta);
4749
5023
  wrote = true;
4750
5024
  }
4751
5025
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -4753,7 +5027,7 @@
4753
5027
  } else {
4754
5028
  resetTerminal();
4755
5029
  if (normalizedOutput) {
4756
- state.terminal.write(normalizedOutput);
5030
+ wandTerminalWrite(state.terminal, normalizedOutput);
4757
5031
  }
4758
5032
  wrote = true;
4759
5033
  }
@@ -4800,6 +5074,19 @@
4800
5074
  },
4801
5075
  onResize: function(cols, rows) {
4802
5076
  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 完的内容,体感上就是"刷新都没用、动一下窗口才好"。
5086
+ if (state.terminal && state.terminalOutput) {
5087
+ state.suppressFitReplay = true;
5088
+ softResyncTerminal();
5089
+ }
4803
5090
  }
4804
5091
  });
4805
5092
 
@@ -4853,7 +5140,7 @@
4853
5140
  syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4854
5141
  }
4855
5142
  } else {
4856
- term.write("点击上方「新对话」开始你的第一次对话。\r\n");
5143
+ wandTerminalWrite(term, "点击上方「新对话」开始你的第一次对话。\r\n");
4857
5144
  }
4858
5145
 
4859
5146
  state.terminalClickHandler = focusInputBox;
@@ -4994,7 +5281,7 @@
4994
5281
  return "会话已结束,无法继续发送";
4995
5282
  }
4996
5283
  return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
4997
- ? "思考中,可继续发送,消息会自动排队"
5284
+ ? "思考中 · 发送新消息将中断当前回复"
4998
5285
  : "输入消息...";
4999
5286
  }
5000
5287
 
@@ -5623,12 +5910,17 @@
5623
5910
  initTerminal();
5624
5911
  }
5625
5912
 
5626
- if (selectedSession && state.terminal) {
5627
- syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "append", scroll: false });
5628
- } else if (!selectedSession) {
5913
+ if (!selectedSession) {
5629
5914
  state.terminalSessionId = null;
5630
5915
  state.terminalOutput = "";
5631
5916
  }
5917
+ // 之前这里会用 selectedSession.output 再 syncTerminalBuffer 一次。
5918
+ // 但 updateShellChrome 在 updateSessionsList、status 推送、init
5919
+ // 等多个高频路径都会被调,每次都拿"可能不带 output 的 slim 快照"
5920
+ // 兜回来 sync 一遍:要么早返回浪费判断,要么 prefix 不匹配触发
5921
+ // reset+全量重写、把 alt-screen 中正在绘制的 Claude TUI 切走。
5922
+ // terminal 写入应当只走 chunk hot-path 与 ws init 这两条权威路径,
5923
+ // 这里不再插手,避免引入二次覆盖。
5632
5924
 
5633
5925
  if (state.terminal && selectedSession && state.currentView === "terminal") {
5634
5926
  maybeScrollTerminalToBottom("view");
@@ -8155,29 +8447,25 @@
8155
8447
  return Promise.resolve();
8156
8448
  }
8157
8449
 
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
- }
8450
+ var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
8451
+ // Immediately render user message with thinking indicator
8452
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
8453
+ var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
8454
+ userMsgs.push(userTurn);
8455
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
8456
+ updateSessionSnapshot({
8457
+ id: session.id,
8458
+ status: "running",
8459
+ messages: userMsgs,
8460
+ structuredState: optimisticStructuredState,
8461
+ });
8462
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
8463
+ status: "running",
8464
+ messages: userMsgs,
8465
+ structuredState: optimisticStructuredState,
8466
+ }), userMsgs);
8467
+ updateInputHint("思考中…");
8468
+ renderChat(true);
8181
8469
 
8182
8470
  if (inputBox) {
8183
8471
  inputBox.value = "";
@@ -8194,17 +8482,21 @@
8194
8482
  method: "POST",
8195
8483
  headers: { "Content-Type": "application/json" },
8196
8484
  credentials: "same-origin",
8197
- body: JSON.stringify({ input: input })
8485
+ body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
8486
+ })
8487
+ .then(function(res) {
8488
+ if (!res.ok) {
8489
+ return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
8490
+ throw new Error((payload && payload.error) || "无法发送结构化消息。");
8491
+ });
8492
+ }
8493
+ return res.json();
8198
8494
  })
8199
- .then(function(res) { return res.json(); })
8200
8495
  .then(function(snapshot) {
8201
8496
  if (snapshot && snapshot.error) {
8202
8497
  throw new Error(snapshot.error);
8203
8498
  }
8204
8499
  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
8500
  if (state.queueEpoch > epochBeforePost && snapshot.queuedMessages) {
8209
8501
  delete snapshot.queuedMessages;
8210
8502
  }
@@ -8212,13 +8504,8 @@
8212
8504
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
8213
8505
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
8214
8506
  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 换行");
8507
+ if (isInterrupting) {
8508
+ showToast("已中断上一条回复,正在处理新消息…", "info");
8222
8509
  }
8223
8510
  }
8224
8511
  })
@@ -8902,6 +9189,12 @@
8902
9189
  function flushPendingMessages() {
8903
9190
  if (state.pendingMessages.length === 0) return;
8904
9191
 
9192
+ var selectedSession = getSelectedSession();
9193
+ if (isStructuredSession(selectedSession)) {
9194
+ state.pendingMessages = [];
9195
+ return;
9196
+ }
9197
+
8905
9198
  // Send queued messages in order, bypassing the session-running check
8906
9199
  // since our local state may be stale right after reconnect
8907
9200
  var queue = state.pendingMessages.slice();
@@ -10423,10 +10716,66 @@
10423
10716
  // new output renders with broken wrapping (content visually piles at
10424
10717
  // the top). Call this after any layout change that might have altered
10425
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) {
10729
+ if (!state.terminal) return;
10730
+ var attempts = 0;
10731
+ var maxAttempts = 8;
10732
+ function tryFit() {
10733
+ if (!state.terminal) return;
10734
+ var el = document.getElementById("output");
10735
+ if (el) {
10736
+ // Force a layout flush so offsetWidth reflects the post-resume
10737
+ // container size, not a stale 0 from the suspended frame.
10738
+ void el.offsetHeight;
10739
+ }
10740
+ 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
+ }
10751
+ return;
10752
+ }
10753
+ if (++attempts >= maxAttempts) return;
10754
+ // Mix rAF and timeout: some Android WebView versions skip rAF
10755
+ // during the first frame after resume, so falling back to a
10756
+ // 16ms timer guarantees forward progress.
10757
+ if (attempts <= 4) {
10758
+ requestAnimationFrame(tryFit);
10759
+ } else {
10760
+ setTimeout(tryFit, 32);
10761
+ }
10762
+ }
10763
+ tryFit();
10764
+ }
10765
+
10426
10766
  function ensureTerminalFit(reason) {
10427
10767
  if (!state.terminal) return false;
10428
10768
  var el = document.getElementById("output");
10429
- if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return false;
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
+ }
10430
10779
  var prevCols = state.terminal.cols;
10431
10780
  var prevRows = state.terminal.rows;
10432
10781
  requestAnimationFrame(function() {
@@ -10459,43 +10808,6 @@
10459
10808
  return true;
10460
10809
  }
10461
10810
 
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() {
10469
- 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.
10496
- }
10497
- }
10498
-
10499
10811
  function scheduleTerminalResize(immediate) {
10500
10812
  if (state.resizeTimer) {
10501
10813
  clearTimeout(state.resizeTimer);
@@ -10535,7 +10847,54 @@
10535
10847
  if (timeEls.length > 0) scheduleSessionListUpdate();
10536
10848
  }, 30000);
10537
10849
 
10538
- function initWebSocket() {
10850
+ function cancelWsReconnect() {
10851
+ if (state.wsReconnectTimer) {
10852
+ clearTimeout(state.wsReconnectTimer);
10853
+ state.wsReconnectTimer = null;
10854
+ }
10855
+ }
10856
+
10857
+ // Drop any in-flight socket and start a new one *now* — used by the
10858
+ // Android resume bridge to recover from zombie connections (socket
10859
+ // still says OPEN, but the TCP path was torn down by Doze). Skips
10860
+ // the backoff timer; the caller has already decided this is urgent.
10861
+ function forceReconnectWebSocket(reason) {
10862
+ cancelWsReconnect();
10863
+ if (state.ws) {
10864
+ var stale = state.ws;
10865
+ // Detach handlers so the imminent close doesn't trigger another
10866
+ // reconnect path while we're already starting a fresh one.
10867
+ try { stale.onclose = null; } catch (e) { /* ignore */ }
10868
+ try { stale.onerror = null; } catch (e) { /* ignore */ }
10869
+ try { stale.close(); } catch (e) { /* ignore */ }
10870
+ state.ws = null;
10871
+ }
10872
+ state.wsConnected = false;
10873
+ state.wsReconnectAttempts = 0;
10874
+ initWebSocket(reason);
10875
+ }
10876
+
10877
+ function scheduleWsReconnect() {
10878
+ if (state.wsReconnectTimer) return;
10879
+ // Don't burn battery reconnecting while hidden — the resume
10880
+ // listener will kick a fresh connect when we're foreground.
10881
+ if (document.hidden) return;
10882
+ var attempt = state.wsReconnectAttempts || 0;
10883
+ // 0.5s, 1s, 2s, 4s, then capped at 8s. Faster than the old
10884
+ // fixed 2s on the first retry (matters for transient blips)
10885
+ // and bounded so a flapping server doesn't get hammered.
10886
+ var delays = [500, 1000, 2000, 4000, 8000];
10887
+ var delay = delays[attempt < delays.length ? attempt : delays.length - 1];
10888
+ state.wsReconnectAttempts = attempt + 1;
10889
+ state.wsReconnectTimer = setTimeout(function() {
10890
+ state.wsReconnectTimer = null;
10891
+ if (state.config && !state.ws && !document.hidden) {
10892
+ initWebSocket("backoff");
10893
+ }
10894
+ }, delay);
10895
+ }
10896
+
10897
+ function initWebSocket(reason) {
10539
10898
  if (!window.WebSocket) return false;
10540
10899
 
10541
10900
  // Prevent duplicate connections
@@ -10553,6 +10912,10 @@
10553
10912
  ws.onopen = function() {
10554
10913
  state.ws = ws;
10555
10914
  state.wsConnected = true;
10915
+ // Reset backoff on a successful connect so the next disconnect
10916
+ // starts the ladder from 500ms again.
10917
+ state.wsReconnectAttempts = 0;
10918
+ cancelWsReconnect();
10556
10919
  // Subscribe to current session if any
10557
10920
  subscribeToSession(state.selectedId);
10558
10921
  // Flush pending messages after reconnection
@@ -10560,7 +10923,10 @@
10560
10923
  // Re-fit terminal on reconnect — the viewport may have changed
10561
10924
  // while disconnected, so remeasure against real container size
10562
10925
  // rather than sending stale cols/rows from before the disconnect.
10563
- ensureTerminalFit("ws-reconnect");
10926
+ // Use the retry variant: when the reconnect is triggered by
10927
+ // Android resume, the WebView container may still be 0×0 for
10928
+ // the first 1–2 frames while layout settles.
10929
+ ensureTerminalFitWithRetry("ws-reconnect");
10564
10930
  };
10565
10931
 
10566
10932
  ws.onmessage = function(event) {
@@ -10575,12 +10941,7 @@
10575
10941
  ws.onclose = function() {
10576
10942
  state.ws = null;
10577
10943
  state.wsConnected = false;
10578
- // Reconnect after 2 seconds
10579
- setTimeout(function() {
10580
- if (state.config && !state.ws) {
10581
- initWebSocket();
10582
- }
10583
- }, 2000);
10944
+ scheduleWsReconnect();
10584
10945
  };
10585
10946
 
10586
10947
  ws.onerror = function() {
@@ -10589,6 +10950,9 @@
10589
10950
 
10590
10951
  return true;
10591
10952
  } catch (e) {
10953
+ // Constructor threw (rare — bad URL, blocked scheme). Try again
10954
+ // through the backoff path so we don't get stuck.
10955
+ scheduleWsReconnect();
10592
10956
  return false;
10593
10957
  }
10594
10958
  }
@@ -10675,11 +11039,13 @@
10675
11039
  // Fast path: write chunk directly to avoid full-output comparison.
10676
11040
  state.lastChunkAt = Date.now();
10677
11041
  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);
11042
+ // 不再在 hot-path maybeRefitTerminal/remeasure。它会偷偷把
11043
+ // wterm this.cols 改成新值,让 wterm 自己的 ResizeObserver
11044
+ // 误判 newCols === this.cols 而跳过 wterm.resize() —— 那条路径
11045
+ // 才会真正调 Renderer.setup() 重建 DOM 行。绕过它就让容器尺寸
11046
+ // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
11047
+ // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
11048
+ wandTerminalWrite(state.terminal, msg.data.chunk);
10683
11049
  state.terminalSessionId = msg.sessionId;
10684
11050
  if (msg.data.output) {
10685
11051
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
@@ -10813,14 +11179,19 @@
10813
11179
  renderChat(true);
10814
11180
  updateTaskDisplay();
10815
11181
  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
- }
11182
+ // ws 重新订阅时拿到的是服务端 ring buffer 的最新窗口(最多
11183
+ // 120KB);客户端缓存的 terminalOutput 可能早于服务端窗口
11184
+ // 的起点。append 模式有 prefix 检查,prefix 不匹配就 reset+
11185
+ // 全量重写、全等就直接 return false——前者会把 alt-screen
11186
+ // 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
11187
+ // 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
11188
+ // 这是订阅时唯一可信的全量基线。
11189
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
11190
+ // 紧接着等容器有真实尺寸再 fit + softResync:wterm 启动
11191
+ // 硬编码 cols=120,replace 写入也可能落在错的列宽上,
11192
+ // ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
11193
+ // 一次,确保最终一定按真实宽度重排。
11194
+ ensureTerminalFitWithRetry("init");
10824
11195
  }
10825
11196
  break;
10826
11197
  case 'usage':