@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.
- package/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +373 -0
- package/dist/process-manager.d.ts +6 -9
- package/dist/process-manager.js +26 -195
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +79 -13
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- 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/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +42 -14
- package/dist/web-ui/content/scripts.js +1188 -209
- package/dist/web-ui/content/styles.css +536 -19
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
|
@@ -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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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 (
|
|
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
|
-
'<
|
|
1176
|
-
'<
|
|
1177
|
-
'<span
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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="关闭">×</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
|
|
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.
|
|
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
|
-
"
|
|
2747
|
-
"
|
|
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
|
-
|
|
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
|
|
4664
|
-
|
|
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
|
-
|
|
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
|
|
5322
|
+
wandTerminalWrite(state.terminal, state.terminalOutput);
|
|
4674
5323
|
state.lastTerminalResyncAt = Date.now();
|
|
4675
5324
|
maybeScrollTerminalToBottom("output");
|
|
4676
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5685
|
-
//
|
|
5686
|
-
//
|
|
5687
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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 (
|
|
8216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10420
|
-
//
|
|
10421
|
-
//
|
|
10422
|
-
//
|
|
10423
|
-
//
|
|
10424
|
-
//
|
|
10425
|
-
//
|
|
10426
|
-
|
|
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)
|
|
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
|
-
|
|
10440
|
-
|
|
10441
|
-
//
|
|
10442
|
-
|
|
10443
|
-
|
|
10444
|
-
|
|
10445
|
-
|
|
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
|
-
//
|
|
10463
|
-
//
|
|
10464
|
-
//
|
|
10465
|
-
//
|
|
10466
|
-
// when the container
|
|
10467
|
-
|
|
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
|
|
10471
|
-
|
|
10472
|
-
var
|
|
10473
|
-
var
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
10679
|
-
//
|
|
10680
|
-
//
|
|
10681
|
-
|
|
10682
|
-
|
|
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
|
-
|
|
10817
|
-
//
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
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':
|