@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.
- package/dist/process-manager.d.ts +2 -1
- package/dist/process-manager.js +14 -34
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.js +6 -12
- package/dist/storage.js +4 -8
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +156 -13
- package/dist/types.d.ts +0 -14
- package/dist/web-ui/content/scripts.js +512 -141
- package/dist/web-ui/content/styles.css +202 -16
- package/package.json +1 -1
|
@@ -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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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 (
|
|
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
|
-
'<
|
|
1176
|
-
'<
|
|
1177
|
-
'<span
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
2747
|
-
"
|
|
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.
|
|
4664
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
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 (
|
|
8216
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
10679
|
-
//
|
|
10680
|
-
//
|
|
10681
|
-
|
|
10682
|
-
|
|
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
|
-
|
|
10817
|
-
//
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
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':
|