@co0ontty/wand 1.15.1 → 1.17.2

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.
@@ -70,16 +70,13 @@
70
70
  sessions: [],
71
71
  suggestionTimer: null,
72
72
  terminal: null,
73
- fitAddon: null,
74
73
  terminalFitInProgress: false,
75
- serializeAddon: null,
76
- terminalDomView: null,
77
- terminalDomUpdateTimer: null,
78
- _lastDomHtml: "",
79
74
  terminalSessionId: null,
80
75
  terminalOutput: "",
81
76
  terminalLiveStreamSessions: {},
82
- terminalViewportSize: { width: 0, height: 0 },
77
+ lastChunkAt: 0,
78
+ terminalHealthTimer: null,
79
+ lastTerminalResyncAt: 0,
83
80
  terminalAutoFollow: true,
84
81
  terminalScrollIdleTimer: null,
85
82
  terminalScrollIdleMs: 1800,
@@ -118,6 +115,11 @@
118
115
  cwdValue: "",
119
116
  modeValue: "managed",
120
117
  chatMode: "managed",
118
+ chatModel: (function() {
119
+ try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
120
+ })(),
121
+ availableModels: [],
122
+ modelsRefreshing: false,
121
123
  sessionCreateKind: "structured",
122
124
  sessionCreateWorktree: false,
123
125
  sessionTool: "claude",
@@ -204,6 +206,7 @@
204
206
  selectedClaudeHistoryIds: {},
205
207
  askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
206
208
  queueEpoch: 0, // Monotonic counter for queue state freshness
209
+ pendingAttachments: [], // [{ file, previewUrl, name, size }]
207
210
  // Load last used working directory from localStorage
208
211
  workingDir: (function() {
209
212
  try {
@@ -889,16 +892,21 @@
889
892
  try {
890
893
  render({ skipShellChrome: true });
891
894
  } catch (_e) {
892
- // render() may fail if external scripts (xterm.js) failed to load;
895
+ // render() may fail if external scripts (wterm) failed to load;
893
896
  // continue with polling and session loading so the app remains functional
894
897
  }
895
898
  bindForegroundSyncListeners();
896
899
  startPolling();
897
900
  refreshAll();
901
+ fetchAvailableModels();
898
902
  requestNotificationPermission();
899
903
  if (config.updateAvailable && config.latestVersion) {
900
904
  notifyUpdateAvailable(config.currentVersion || "-", config.latestVersion);
901
905
  }
906
+ // APK auto-update check on startup
907
+ if (_apkVersion) {
908
+ checkApkAutoUpdate();
909
+ }
902
910
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
903
911
  loadClaudeHistory();
904
912
  }
@@ -952,6 +960,7 @@
952
960
  attachEventListeners();
953
961
  updateDrawerState();
954
962
  syncComposerModeSelect();
963
+ syncComposerModelSelect(getSelectedSession());
955
964
  applyCurrentView();
956
965
  if (!skipShellChrome) {
957
966
  updateShellChrome();
@@ -1106,12 +1115,21 @@
1106
1115
  '<span class="session-count" id="session-count">' + String(state.sessions.length) + '</span>' +
1107
1116
  '</div>' +
1108
1117
  '<div class="sidebar-header-actions">' +
1109
- '<button id="sidebar-home-btn" class="btn btn-ghost btn-sm" type="button" title="回到首页">' +
1110
- '<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="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' +
1111
- '</button>' +
1112
- '<button id="sidebar-refresh-btn" class="btn btn-ghost btn-sm" type="button" title="刷新页面">' +
1113
- '<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"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
1114
- '</button>' +
1118
+ '<div class="sidebar-header-more">' +
1119
+ '<button id="sidebar-more-btn" class="btn btn-ghost btn-sm" type="button" title="更多操作">' +
1120
+ '<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="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>' +
1121
+ '</button>' +
1122
+ '<div class="sidebar-header-overflow" id="sidebar-overflow-menu">' +
1123
+ '<button class="overflow-item" id="sidebar-home-btn" type="button">' +
1124
+ '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' +
1125
+ '<span>回到首页</span>' +
1126
+ '</button>' +
1127
+ '<button class="overflow-item" id="sidebar-refresh-btn" type="button">' +
1128
+ '<svg width="15" height="15" 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"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
1129
+ '<span>刷新页面</span>' +
1130
+ '</button>' +
1131
+ '</div>' +
1132
+ '</div>' +
1115
1133
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1116
1134
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' +
1117
1135
  '</button>' +
@@ -1126,15 +1144,21 @@
1126
1144
  '<div class="sidebar-footer">' +
1127
1145
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
1128
1146
  '<div class="sidebar-footer-actions">' +
1129
- '<button id="file-panel-toggle-btn" class="btn btn-ghost btn-sm' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">📁 文件</button>' +
1147
+ '<button id="file-panel-toggle-btn" class="btn btn-ghost btn-sm' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">' +
1148
+ '<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>' +
1149
+ '<span>文件</span>' +
1150
+ '</button>' +
1130
1151
  '<button id="settings-button" class="btn btn-ghost btn-sm" type="button" title="设置">' +
1131
- '<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-2.83 2.83l-.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-4 0v-.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-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.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 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> 设置' +
1152
+ '<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.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-4 0v-.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-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.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 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
1153
+ '<span>设置</span>' +
1132
1154
  '</button>' +
1133
1155
  '<button id="pwa-install-button" class="btn btn-ghost btn-sm hidden" title="安装应用">' +
1134
- '<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> 安装' +
1156
+ '<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="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>' +
1157
+ '<span>安装</span>' +
1135
1158
  '</button>' +
1136
1159
  '<button id="logout-button" class="btn btn-ghost btn-sm sidebar-logout" type="button" title="退出登录">' +
1137
- '<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> 退出' +
1160
+ '<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="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>' +
1161
+ '<span>退出</span>' +
1138
1162
  '</button>' +
1139
1163
  '</div>' +
1140
1164
  '</div>' +
@@ -1226,11 +1250,19 @@
1226
1250
  '</div>' +
1227
1251
  '<div class="input-composer">' +
1228
1252
  '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1253
+ '<div id="attachment-preview" class="attachment-preview hidden"></div>' +
1229
1254
  '<div class="input-composer-bar">' +
1230
1255
  '<div class="input-composer-left">' +
1256
+ '<button id="attach-btn" class="btn-circle btn-circle-attach" type="button" title="附加文件">' +
1257
+ '<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.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>' +
1258
+ '</button>' +
1259
+ '<input type="file" id="file-upload-input" multiple style="display:none">' +
1231
1260
  '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
1232
1261
  renderModeOptions(preferredTool, composerMode) +
1233
1262
  '</select>' +
1263
+ '<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1264
+ renderChatModelOptions(getEffectiveModel(selectedSession)) +
1265
+ '</select>' +
1234
1266
  '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1235
1267
  '<span class="permission-actions hidden" id="permission-actions">' +
1236
1268
  '<span class="permission-actions-divider"></span>' +
@@ -1387,6 +1419,14 @@
1387
1419
  '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
1388
1420
  '</div>' +
1389
1421
  '<p id="update-message" class="hint hidden"></p>' +
1422
+ '<div class="settings-auto-update-row" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1423
+ '<span class="settings-label">自动更新</span>' +
1424
+ '<label style="position:relative;cursor:pointer">' +
1425
+ '<input type="checkbox" id="auto-update-web-toggle" class="switch-toggle">' +
1426
+ '<span class="switch-slider"></span>' +
1427
+ '</label>' +
1428
+ '</div>' +
1429
+ '<p class="hint" style="margin-top:2px">开启后,检测到新版本将自动下载安装并重启服务。</p>' +
1390
1430
  '</div>' +
1391
1431
  '<div class="settings-update-section" id="android-apk-section">' +
1392
1432
  '<div id="android-apk-current-row" class="settings-about-row hidden">' +
@@ -1403,6 +1443,14 @@
1403
1443
  '<span class="settings-value" id="settings-android-apk-local" style="flex:1">-</span>' +
1404
1444
  '<button id="download-local-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1405
1445
  '</div>' +
1446
+ '<div id="android-auto-update-row" class="hidden" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1447
+ '<span class="settings-label">自动更新</span>' +
1448
+ '<label style="position:relative;cursor:pointer">' +
1449
+ '<input type="checkbox" id="auto-update-apk-toggle" class="switch-toggle">' +
1450
+ '<span class="switch-slider"></span>' +
1451
+ '</label>' +
1452
+ '</div>' +
1453
+ '<p id="android-auto-update-hint" class="hint hidden" style="margin-top:2px">开启后,检测到新版 APK 将自动下载安装。</p>' +
1406
1454
  '<p id="android-apk-message" class="hint hidden"></p>' +
1407
1455
  '</div>' +
1408
1456
  '<div class="settings-update-section" id="android-connect-section">' +
@@ -1516,6 +1564,16 @@
1516
1564
  '</div>' +
1517
1565
  '</div>' +
1518
1566
  '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
1567
+ '<div class="field">' +
1568
+ '<label class="field-label" for="cfg-default-model">默认模型</label>' +
1569
+ '<div style="display:flex;gap:8px;align-items:center;">' +
1570
+ '<select id="cfg-default-model" class="field-input" style="flex:1;">' +
1571
+ '<option value="">跟随 Claude Code 默认</option>' +
1572
+ '</select>' +
1573
+ '<button type="button" id="cfg-default-model-refresh" class="btn btn-ghost btn-sm" title="刷新模型列表">刷新</button>' +
1574
+ '</div>' +
1575
+ '<p class="field-hint" id="cfg-default-model-version" style="margin-top:4px;">新建会话时默认使用该模型;运行中的会话可在输入框切换。</p>' +
1576
+ '</div>' +
1519
1577
  '<div class="field">' +
1520
1578
  '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
1521
1579
  '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
@@ -2078,19 +2136,18 @@
2078
2136
  } catch (e) {}
2079
2137
  applyTerminalScale();
2080
2138
  updateScaleLabel();
2081
- // Force refit: font size changed but container dimensions didn't,
2082
- // so ensureTerminalFit (which resets viewport tracking) is needed
2083
- // instead of scheduleTerminalResize (which skips when size unchanged).
2084
- ensureTerminalFit();
2085
2139
  }
2086
2140
 
2087
2141
  function applyTerminalScale() {
2088
- if (!state.terminal) return;
2089
- state.terminal.options.fontSize = state.terminalBaseFontSize * state.terminalScale;
2090
- state.terminal.refresh(0, state.terminal.rows - 1);
2091
- // Apply to DOM terminal view as well
2092
- if (state.terminalDomView) {
2093
- state.terminalDomView.style.fontSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
2142
+ if (!state.terminal || !state.terminal.element) return;
2143
+ var newSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
2144
+ var newRowHeight = (state.terminalBaseFontSize * state.terminalScale * 1.5) + "px";
2145
+ state.terminal.element.style.setProperty("--term-font-size", newSize);
2146
+ state.terminal.element.style.setProperty("--term-row-height", newRowHeight);
2147
+ if (typeof state.terminal.remeasure === "function") {
2148
+ requestAnimationFrame(function() {
2149
+ if (state.terminal) state.terminal.remeasure();
2150
+ });
2094
2151
  }
2095
2152
  }
2096
2153
 
@@ -3217,6 +3274,17 @@
3217
3274
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
3218
3275
  var pinBtn = document.getElementById("sidebar-pin-btn");
3219
3276
  if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
3277
+ var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
3278
+ var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
3279
+ if (sidebarMoreBtn && sidebarOverflow) {
3280
+ sidebarMoreBtn.addEventListener("click", function(e) {
3281
+ e.stopPropagation();
3282
+ sidebarOverflow.classList.toggle("open");
3283
+ });
3284
+ document.addEventListener("click", function() {
3285
+ sidebarOverflow.classList.remove("open");
3286
+ });
3287
+ }
3220
3288
  var homeBtn = document.getElementById("sidebar-home-btn");
3221
3289
  if (homeBtn) homeBtn.addEventListener("click", function() {
3222
3290
  state.selectedId = null;
@@ -3245,11 +3313,15 @@
3245
3313
  var settingsTabs = document.querySelectorAll(".settings-tab");
3246
3314
  for (var ti = 0; ti < settingsTabs.length; ti++) {
3247
3315
  settingsTabs[ti].addEventListener("click", function(e) {
3248
- switchSettingsTab(e.target.getAttribute("data-tab"));
3316
+ var btn = e.currentTarget || this;
3317
+ var tabName = btn && btn.getAttribute ? btn.getAttribute("data-tab") : null;
3318
+ if (tabName) switchSettingsTab(tabName);
3249
3319
  });
3250
3320
  }
3251
3321
  var saveConfigBtn = document.getElementById("save-config-button");
3252
3322
  if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
3323
+ var defaultModelRefreshBtn = document.getElementById("cfg-default-model-refresh");
3324
+ if (defaultModelRefreshBtn) defaultModelRefreshBtn.addEventListener("click", refreshAvailableModels);
3253
3325
  var saveDisplayBtn = document.getElementById("save-display-button");
3254
3326
  if (saveDisplayBtn) saveDisplayBtn.addEventListener("click", saveDisplaySettings);
3255
3327
  // App icon picker (APK only)
@@ -3282,6 +3354,14 @@
3282
3354
  if (doUpdateBtn) doUpdateBtn.addEventListener("click", performUpdate);
3283
3355
  var doRestartBtn = document.getElementById("do-restart-button");
3284
3356
  if (doRestartBtn) doRestartBtn.addEventListener("click", performSettingsRestart);
3357
+ var autoUpdateWebToggle = document.getElementById("auto-update-web-toggle");
3358
+ if (autoUpdateWebToggle) autoUpdateWebToggle.addEventListener("change", function() {
3359
+ toggleAutoUpdate("web", autoUpdateWebToggle.checked);
3360
+ });
3361
+ var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
3362
+ if (autoUpdateApkToggle) autoUpdateApkToggle.addEventListener("change", function() {
3363
+ toggleAutoUpdate("apk", autoUpdateApkToggle.checked);
3364
+ });
3285
3365
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
3286
3366
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
3287
3367
  var text = document.getElementById("android-connect-code");
@@ -3414,6 +3494,10 @@
3414
3494
  state.chatMode = this.value;
3415
3495
  showToast("新会话模式已切换为:" + getModeLabel(this.value), "info");
3416
3496
  });
3497
+ var modelSelect = document.getElementById("chat-model-select");
3498
+ if (modelSelect) modelSelect.addEventListener("change", function() {
3499
+ onChatModelChange(this.value);
3500
+ });
3417
3501
 
3418
3502
  var sessionModal = document.getElementById("session-modal");
3419
3503
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
@@ -3441,6 +3525,42 @@
3441
3525
  inputBox.addEventListener("blur", handleInputBoxBlur);
3442
3526
  }
3443
3527
 
3528
+ // Attach button & drag-drop
3529
+ var attachBtn = document.getElementById("attach-btn");
3530
+ var fileInput = document.getElementById("file-upload-input");
3531
+ if (attachBtn && fileInput) {
3532
+ attachBtn.addEventListener("click", function() { fileInput.click(); });
3533
+ fileInput.addEventListener("change", function() {
3534
+ var files = fileInput.files;
3535
+ if (files) {
3536
+ for (var i = 0; i < files.length; i++) addPendingAttachment(files[i]);
3537
+ }
3538
+ fileInput.value = "";
3539
+ });
3540
+ }
3541
+ var composer = document.querySelector(".input-composer");
3542
+ if (composer) {
3543
+ composer.addEventListener("dragover", function(e) {
3544
+ e.preventDefault();
3545
+ e.stopPropagation();
3546
+ composer.classList.add("drag-over");
3547
+ });
3548
+ composer.addEventListener("dragleave", function(e) {
3549
+ e.preventDefault();
3550
+ e.stopPropagation();
3551
+ composer.classList.remove("drag-over");
3552
+ });
3553
+ composer.addEventListener("drop", function(e) {
3554
+ e.preventDefault();
3555
+ e.stopPropagation();
3556
+ composer.classList.remove("drag-over");
3557
+ var files = e.dataTransfer && e.dataTransfer.files;
3558
+ if (files) {
3559
+ for (var i = 0; i < files.length; i++) addPendingAttachment(files[i]);
3560
+ }
3561
+ });
3562
+ }
3563
+
3444
3564
  // Terminal interactive toggle (both topbar and terminal-header)
3445
3565
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
3446
3566
  terminalInteractiveToggles.forEach(function(id) {
@@ -4107,10 +4227,7 @@
4107
4227
 
4108
4228
  function getTerminalViewport() {
4109
4229
  if (!state.terminal || !state.terminal.element) return null;
4110
- if (state.terminalViewportEl && state.terminal.element.contains(state.terminalViewportEl)) {
4111
- return state.terminalViewportEl;
4112
- }
4113
- state.terminalViewportEl = state.terminal.element.querySelector(".xterm-viewport");
4230
+ state.terminalViewportEl = state.terminal.element;
4114
4231
  return state.terminalViewportEl;
4115
4232
  }
4116
4233
 
@@ -4136,11 +4253,6 @@
4136
4253
  }
4137
4254
 
4138
4255
  function isTerminalNearBottom() {
4139
- // On mobile, check DOM view scroll position
4140
- if (state.terminalDomView) {
4141
- var d = state.terminalDomView.scrollHeight - state.terminalDomView.clientHeight - state.terminalDomView.scrollTop;
4142
- return d <= state.terminalScrollThreshold;
4143
- }
4144
4256
  var viewport = getTerminalViewport();
4145
4257
  if (!viewport) return true;
4146
4258
  var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
@@ -4149,25 +4261,13 @@
4149
4261
 
4150
4262
  function scrollTerminalToBottom(smooth) {
4151
4263
  if (!state.terminal) return;
4152
- // Also scroll mobile DOM view
4153
- if (state.terminalDomView) {
4154
- if (smooth) {
4155
- state.terminalDomView.scrollTo({ top: state.terminalDomView.scrollHeight, behavior: "smooth" });
4156
- } else {
4157
- state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
4158
- }
4159
- }
4264
+ var viewport = getTerminalViewport();
4265
+ if (!viewport) return;
4160
4266
  if (smooth) {
4161
- var viewport = getTerminalViewport();
4162
- if (viewport) {
4163
- viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
4164
- setTimeout(function() {
4165
- if (state.terminal) state.terminal.scrollToBottom();
4166
- }, 160);
4167
- return;
4168
- }
4267
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
4268
+ } else {
4269
+ viewport.scrollTop = viewport.scrollHeight;
4169
4270
  }
4170
- state.terminal.scrollToBottom();
4171
4271
  }
4172
4272
 
4173
4273
  function scheduleTerminalResumeFollow() {
@@ -4388,6 +4488,11 @@
4388
4488
  requestSyncScrollbar();
4389
4489
  }
4390
4490
 
4491
+ function resetTerminal() {
4492
+ if (!state.terminal || typeof state.terminal.reset !== "function") return;
4493
+ state.terminal.reset();
4494
+ }
4495
+
4391
4496
  function syncTerminalBuffer(sessionId, output, options) {
4392
4497
  if (!state.terminal) return false;
4393
4498
  var normalizedOutput = normalizeTerminalOutput(output || "");
@@ -4407,7 +4512,7 @@
4407
4512
  }
4408
4513
 
4409
4514
  if (sessionChanged) {
4410
- state.terminal.reset();
4515
+ resetTerminal();
4411
4516
  currentOutput = "";
4412
4517
  state.terminalOutput = "";
4413
4518
  state.terminalAutoFollow = true;
@@ -4417,18 +4522,15 @@
4417
4522
 
4418
4523
  if (mode === "replace") {
4419
4524
  if (normalizedOutput !== currentOutput) {
4420
- state.terminal.reset();
4525
+ resetTerminal();
4421
4526
  if (normalizedOutput) {
4422
4527
  state.terminal.write(normalizedOutput);
4423
4528
  }
4424
4529
  wrote = true;
4425
4530
  }
4426
4531
  } else if (normalizedOutput.length < currentOutput.length && !sessionChanged) {
4427
- // Ignore regressive snapshots for the active session; wait for an explicit replace.
4428
4532
  return false;
4429
4533
  } else if (liveChunkStream && !sessionChanged && mode !== "replace" && currentOutput && !normalizedOutput.startsWith(currentOutput)) {
4430
- // When a session is already streaming live chunks, do not let polled snapshots
4431
- // rewrite the terminal unless they are strict appends of what we've rendered.
4432
4534
  return false;
4433
4535
  } else if (normalizedOutput.startsWith(currentOutput)) {
4434
4536
  var delta = normalizedOutput.slice(currentOutput.length);
@@ -4437,10 +4539,9 @@
4437
4539
  wrote = true;
4438
4540
  }
4439
4541
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
4440
- // Ignore shorter/stale snapshots from polling or reconnect races.
4441
4542
  return false;
4442
4543
  } else {
4443
- state.terminal.reset();
4544
+ resetTerminal();
4444
4545
  if (normalizedOutput) {
4445
4546
  state.terminal.write(normalizedOutput);
4446
4547
  }
@@ -4449,40 +4550,21 @@
4449
4550
 
4450
4551
  state.terminalSessionId = nextSessionId;
4451
4552
  state.terminalOutput = normalizedOutput;
4452
- scheduleMobileDomUpdate();
4453
4553
  if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
4454
4554
  maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
4455
4555
  } else {
4456
4556
  updateTerminalJumpToBottomButton();
4457
4557
  }
4458
- // When switching sessions, re-fit the terminal so the PTY receives
4459
- // the correct dimensions for this client's viewport.
4460
- if (sessionChanged && state.fitAddon) {
4461
- state.terminalViewportSize = { width: 0, height: 0 };
4462
- scheduleTerminalResize(true);
4558
+ if (sessionChanged) {
4559
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
4463
4560
  }
4464
4561
  return wrote || sessionChanged;
4465
4562
  }
4466
4563
 
4467
- function shouldResizeTerminalViewport() {
4468
- var output = document.getElementById("output");
4469
- if (!output) return false;
4470
- var rect = output.getBoundingClientRect();
4471
- var width = Math.round(rect.width);
4472
- var height = Math.round(rect.height);
4473
- if (!width || !height) return false;
4474
- if (state.terminalViewportSize.width === width && state.terminalViewportSize.height === height) {
4475
- return false;
4476
- }
4477
- state.terminalViewportSize = { width: width, height: height };
4478
- return true;
4479
- }
4480
-
4481
4564
  function initTerminal() {
4482
4565
  var container = document.getElementById("output");
4483
- if (!container || state.terminal) return;
4484
- if (typeof Terminal === "undefined") {
4485
- // xterm.js not yet loaded — retry after a short delay
4566
+ if (!container || state.terminal || state.terminalInitializing) return;
4567
+ if (typeof WTermLib === "undefined" || !WTermLib.WTerm) {
4486
4568
  if (!state.terminalInitRetries) state.terminalInitRetries = 0;
4487
4569
  if (state.terminalInitRetries < 10) {
4488
4570
  state.terminalInitRetries++;
@@ -4491,160 +4573,80 @@
4491
4573
  return;
4492
4574
  }
4493
4575
  state.terminalInitRetries = 0;
4576
+ state.terminalInitializing = true;
4577
+
4578
+ var termWrap = document.createElement("div");
4579
+ termWrap.className = "terminal-scroll-wrap";
4580
+ container.appendChild(termWrap);
4494
4581
 
4495
- state.terminal = new Terminal({
4582
+ var term = new WTermLib.WTerm(termWrap, {
4496
4583
  cols: 120,
4497
4584
  rows: 36,
4498
- convertEol: true,
4499
- disableStdin: false,
4585
+ autoResize: true,
4500
4586
  cursorBlink: false,
4501
- fontFamily: '"Geist Mono", "SF Mono", monospace',
4502
- fontSize: 13,
4503
- lineHeight: 1.5,
4504
- allowProposedApi: true,
4505
- scrollback: 10000,
4506
- wheelScrollMargin: 0,
4507
- theme: {
4508
- background: "#1f1b17",
4509
- foreground: "#f5eadc",
4510
- cursor: "#d67b52",
4511
- selectionBackground: "rgba(214, 123, 82, 0.28)",
4512
- black: "#1f1b17",
4513
- red: "#d27766",
4514
- green: "#7fa36f",
4515
- yellow: "#d5a35b",
4516
- blue: "#87a9d9",
4517
- magenta: "#c595c7",
4518
- cyan: "#7fb3b1",
4519
- white: "#f5eadc",
4520
- brightBlack: "#625347",
4521
- brightRed: "#e39a89",
4522
- brightGreen: "#9cc08a",
4523
- brightYellow: "#ebbb6e",
4524
- brightBlue: "#a8c1ea",
4525
- brightMagenta: "#dbb1dc",
4526
- brightCyan: "#9acbca",
4527
- brightWhite: "#fff7ef"
4587
+ onData: function(data) {
4588
+ if (state.terminalInteractive) return;
4589
+ queueDirectInput(data);
4590
+ },
4591
+ onResize: function(cols, rows) {
4592
+ sendTerminalResize(cols, rows);
4528
4593
  }
4529
4594
  });
4530
4595
 
4531
- var fitAddonConstructor =
4532
- typeof FitAddon !== "undefined" && FitAddon && typeof FitAddon.FitAddon === "function"
4533
- ? FitAddon.FitAddon
4534
- : null;
4535
- state.fitAddon = fitAddonConstructor ? new fitAddonConstructor() : null;
4536
- if (state.fitAddon) {
4537
- state.terminal.loadAddon(state.fitAddon);
4538
- // Patch: FitAddon subtracts 14px for a scrollbar that CSS hides;
4539
- // recalculate cols without the scrollbar deduction.
4540
- var _origPropose = state.fitAddon.proposeDimensions;
4541
- state.fitAddon.proposeDimensions = function() {
4542
- var result = _origPropose.call(state.fitAddon);
4543
- if (result && state.terminal) {
4544
- try {
4545
- var core = state.terminal._core;
4546
- var cellW = core._renderService.dimensions.css.cell.width;
4547
- var el = state.terminal.element;
4548
- if (cellW > 0 && el && el.parentElement) {
4549
- var pw = Math.max(0, parseInt(window.getComputedStyle(el.parentElement).getPropertyValue("width")));
4550
- var es = window.getComputedStyle(el);
4551
- var ePad = parseInt(es.getPropertyValue("padding-left")) + parseInt(es.getPropertyValue("padding-right"));
4552
- result.cols = Math.max(2, Math.floor((pw - ePad) / cellW));
4553
- }
4554
- } catch(e) {}
4555
- }
4556
- return result;
4557
- };
4558
- } else {
4559
- console.error("[wand] xterm fit addon failed to load; continuing without fit support.");
4560
- }
4561
-
4562
- // Load serialize addon for mobile DOM rendering
4563
- if (typeof SerializeAddon !== "undefined" && SerializeAddon && typeof SerializeAddon.SerializeAddon === "function") {
4564
- state.serializeAddon = new SerializeAddon.SerializeAddon();
4565
- state.terminal.loadAddon(state.serializeAddon);
4566
- }
4596
+ term.init().then(function() {
4597
+ state.terminal = term;
4598
+ state.terminalInitializing = false;
4599
+ applyTerminalScale();
4600
+ state.terminalAutoFollow = true;
4601
+ clearTerminalScrollIdleTimer();
4567
4602
 
4568
- state.terminal.open(container);
4569
- applyTerminalScale();
4570
- state.terminalViewportSize = { width: 0, height: 0 };
4571
- state.terminalAutoFollow = true;
4572
- clearTerminalScrollIdleTimer();
4573
- // Retry-based fit: wait for browser to complete layout before measuring and fitting
4574
- if (state.fitAddon) {
4575
- ensureTerminalFit();
4576
- // Secondary fit after fonts are loaded — FitAddon measures character
4577
- // dimensions from the rendered font; if a custom web font (e.g. Geist
4578
- // Mono) hasn't loaded yet the initial fit() uses fallback metrics and
4579
- // computes too few columns.
4580
- if (document.fonts && document.fonts.ready) {
4581
- document.fonts.ready.then(function() {
4582
- state.terminalViewportSize = { width: 0, height: 0 };
4583
- ensureTerminalFit();
4584
- });
4603
+ var viewport = getTerminalViewport();
4604
+ if (viewport) {
4605
+ state.terminalViewportScrollHandler = function() {
4606
+ if (isTerminalNearBottom()) {
4607
+ state.terminalAutoFollow = true;
4608
+ clearTerminalScrollIdleTimer();
4609
+ updateTerminalJumpToBottomButton();
4610
+ return;
4611
+ }
4612
+ setTerminalManualScrollActive();
4613
+ };
4614
+ state.terminalViewportTouchHandler = function() {
4615
+ setTerminalManualScrollActive();
4616
+ };
4617
+ viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
4618
+ viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
4585
4619
  }
4586
- // Safety-net fit after layout has fully stabilised (CSS transitions,
4587
- // deferred reflows, late font loads, etc.)
4588
- setTimeout(function() {
4589
- if (state.terminal && state.fitAddon) {
4590
- state.terminalViewportSize = { width: 0, height: 0 };
4591
- ensureTerminalFit();
4592
- }
4593
- }, 500);
4594
- }
4595
4620
 
4596
- var viewport = getTerminalViewport();
4597
- if (viewport) {
4598
- state.terminalViewportScrollHandler = function() {
4599
- if (isTerminalNearBottom()) {
4600
- state.terminalAutoFollow = true;
4601
- clearTerminalScrollIdleTimer();
4602
- updateTerminalJumpToBottomButton();
4603
- return;
4621
+ state.terminalWheelHandler = function(e) {
4622
+ if (!isTerminalNearBottom() || e.deltaY < 0) {
4623
+ setTerminalManualScrollActive();
4604
4624
  }
4605
- setTerminalManualScrollActive();
4606
- };
4607
- state.terminalViewportTouchHandler = function() {
4608
- setTerminalManualScrollActive();
4625
+ e.stopPropagation();
4609
4626
  };
4610
- viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
4611
- viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
4612
- }
4613
-
4614
- container.addEventListener('wheel', function(e) {
4615
- if (!isTerminalNearBottom() || e.deltaY < 0) {
4616
- setTerminalManualScrollActive();
4617
- }
4618
- e.stopPropagation();
4619
- }, { passive: true });
4620
-
4621
- // Create custom scrollbar overlay
4622
- initTerminalScrollbar(container);
4627
+ container.addEventListener("wheel", state.terminalWheelHandler, { passive: true });
4623
4628
 
4624
- // Terminal copy button for mobile
4625
- initMobileDomTerminal(container);
4629
+ initTerminalScrollbar(container);
4626
4630
 
4627
- if (state.selectedId) {
4628
- var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
4629
- if (session) {
4630
- syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4631
+ if (state.selectedId) {
4632
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
4633
+ if (session) {
4634
+ syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4635
+ }
4636
+ } else {
4637
+ term.write("点击上方「新对话」开始你的第一次对话。\r\n");
4631
4638
  }
4632
- } else {
4633
- state.terminal.writeln("点击上方「新对话」开始你的第一次对话。");
4634
- }
4635
4639
 
4636
- state.terminal.onData(function(data) {
4637
- if (state.terminalInteractive) return;
4638
- queueDirectInput(data);
4640
+ state.terminalClickHandler = focusInputBox;
4641
+ container.addEventListener("click", state.terminalClickHandler);
4642
+ updateTerminalJumpToBottomButton();
4643
+ initTerminalResizeHandle();
4644
+ observeTerminalResize();
4645
+ startTerminalHealthCheck();
4646
+ }).catch(function(err) {
4647
+ state.terminalInitializing = false;
4648
+ console.error("[wand] wterm init failed:", err);
4639
4649
  });
4640
-
4641
- container.addEventListener("click", focusInputBox);
4642
- updateTerminalJumpToBottomButton();
4643
-
4644
- // 初始化拖动调整大小
4645
- initTerminalResizeHandle();
4646
-
4647
- observeTerminalResize();
4648
4650
  }
4649
4651
 
4650
4652
  function login() {
@@ -4869,13 +4871,138 @@
4869
4871
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
4870
4872
  }
4871
4873
 
4874
+ function getEffectiveModel(session) {
4875
+ if (session && session.selectedModel) return session.selectedModel;
4876
+ if (state.chatModel) return state.chatModel;
4877
+ if (state.config && state.config.defaultModel) return state.config.defaultModel;
4878
+ return "";
4879
+ }
4880
+
4881
+ function renderChatModelOptions(selected) {
4882
+ var models = state.availableModels || [];
4883
+ var html = '<option value="">默认(跟随设置)</option>';
4884
+ for (var i = 0; i < models.length; i++) {
4885
+ var m = models[i];
4886
+ var label = m.label || m.id;
4887
+ html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
4888
+ }
4889
+ // If selected is unknown (custom value), prepend it as a sticky option
4890
+ if (selected && !models.some(function(m) { return m.id === selected; })) {
4891
+ html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
4892
+ }
4893
+ return html;
4894
+ }
4895
+
4896
+ function syncComposerModelSelect(session) {
4897
+ var select = document.getElementById("chat-model-select");
4898
+ if (!select) return;
4899
+ var effective = getEffectiveModel(session);
4900
+ select.innerHTML = renderChatModelOptions(effective);
4901
+ select.value = effective;
4902
+ }
4903
+
4904
+ function fetchAvailableModels() {
4905
+ return fetch("/api/models", { credentials: "same-origin" })
4906
+ .then(function(res) { return res.json(); })
4907
+ .then(function(data) {
4908
+ if (data && Array.isArray(data.models)) {
4909
+ state.availableModels = data.models;
4910
+ syncComposerModelSelect(getSelectedSession());
4911
+ updateSettingsDefaultModelSelect(data);
4912
+ }
4913
+ return data;
4914
+ })
4915
+ .catch(function() { return null; });
4916
+ }
4917
+
4918
+ function refreshAvailableModels() {
4919
+ if (state.modelsRefreshing) return Promise.resolve(null);
4920
+ state.modelsRefreshing = true;
4921
+ var btn = document.getElementById("cfg-default-model-refresh");
4922
+ if (btn) { btn.disabled = true; btn.textContent = "刷新中..."; }
4923
+ return fetch("/api/models/refresh", { method: "POST", credentials: "same-origin" })
4924
+ .then(function(res) { return res.json(); })
4925
+ .then(function(data) {
4926
+ if (data && Array.isArray(data.models)) {
4927
+ state.availableModels = data.models;
4928
+ syncComposerModelSelect(getSelectedSession());
4929
+ updateSettingsDefaultModelSelect(data);
4930
+ if (typeof showToast === "function") {
4931
+ showToast("模型列表已刷新" + (data.claudeVersion ? "(claude " + data.claudeVersion + ")" : ""), "success");
4932
+ }
4933
+ }
4934
+ return data;
4935
+ })
4936
+ .catch(function() {
4937
+ if (typeof showToast === "function") showToast("刷新模型列表失败", "error");
4938
+ return null;
4939
+ })
4940
+ .finally(function() {
4941
+ state.modelsRefreshing = false;
4942
+ if (btn) { btn.disabled = false; btn.textContent = "刷新"; }
4943
+ });
4944
+ }
4945
+
4946
+ function updateSettingsDefaultModelSelect(data) {
4947
+ var select = document.getElementById("cfg-default-model");
4948
+ if (!select) return;
4949
+ var previous = select.value;
4950
+ var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
4951
+ select.innerHTML = renderChatModelOptions(current);
4952
+ select.value = current;
4953
+ var versionEl = document.getElementById("cfg-default-model-version");
4954
+ if (versionEl && data) {
4955
+ versionEl.textContent = data.claudeVersion ? "已检测到 claude " + data.claudeVersion : "新建会话时默认使用该模型。";
4956
+ }
4957
+ }
4958
+
4959
+ function getSelectedSession() {
4960
+ if (!state.selectedId) return null;
4961
+ for (var i = 0; i < state.sessions.length; i++) {
4962
+ if (state.sessions[i].id === state.selectedId) return state.sessions[i];
4963
+ }
4964
+ return null;
4965
+ }
4966
+
4967
+ function onChatModelChange(value) {
4968
+ var normalized = (value || "").trim();
4969
+ state.chatModel = normalized;
4970
+ try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
4971
+ var session = getSelectedSession();
4972
+ if (!session) return;
4973
+ if (session.provider && session.provider !== "claude") return;
4974
+ fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
4975
+ method: "POST",
4976
+ headers: { "Content-Type": "application/json" },
4977
+ credentials: "same-origin",
4978
+ body: JSON.stringify({ model: normalized || null })
4979
+ })
4980
+ .then(function(res) { return res.json(); })
4981
+ .then(function(data) {
4982
+ if (data && data.error) {
4983
+ showToast(data.error, "error");
4984
+ return;
4985
+ }
4986
+ if (data && data.id) {
4987
+ updateSessionSnapshot(data);
4988
+ if (typeof showToast === "function") {
4989
+ var display = normalized || "默认";
4990
+ showToast("已切换模型 → " + display, "success");
4991
+ }
4992
+ }
4993
+ })
4994
+ .catch(function() { showToast("切换模型失败", "error"); });
4995
+ }
4996
+
4872
4997
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
4998
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
4873
4999
  var payload = {
4874
5000
  cwd: cwdOverride || getEffectiveCwd(),
4875
5001
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
4876
5002
  runner: state.structuredRunner || "claude-cli-print",
4877
5003
  prompt: prompt || undefined,
4878
- worktreeEnabled: worktreeEnabled === true
5004
+ worktreeEnabled: worktreeEnabled === true,
5005
+ model: modelPref || undefined
4879
5006
  };
4880
5007
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
4881
5008
  return fetch("/api/structured-sessions", {
@@ -5254,10 +5381,8 @@
5254
5381
  initTerminal();
5255
5382
  }
5256
5383
  if (state.terminal && terminalContainer && !terminalContainer.contains(state.terminal.element)) {
5257
- state.terminal.open(terminalContainer);
5258
- applyTerminalScale();
5259
- state.terminalViewportSize = { width: 0, height: 0 };
5260
- scheduleTerminalResize();
5384
+ teardownTerminal();
5385
+ initTerminal();
5261
5386
  }
5262
5387
 
5263
5388
  if (selectedSession && state.terminal) {
@@ -5286,6 +5411,7 @@
5286
5411
  if (inputPanel) inputPanel.classList.add("hidden");
5287
5412
  }
5288
5413
  syncComposerModeSelect();
5414
+ syncComposerModelSelect(getSelectedSession());
5289
5415
  applyCurrentView();
5290
5416
  reconcileInteractiveState();
5291
5417
  }
@@ -5316,6 +5442,15 @@
5316
5442
  updateSessionSnapshot(data);
5317
5443
  updateShellChrome();
5318
5444
 
5445
+ if (state.terminal && id === state.selectedId && data.output !== undefined) {
5446
+ syncTerminalBuffer(id, data.output, { mode: "append" });
5447
+ if (state.terminal.remeasure) {
5448
+ requestAnimationFrame(function() {
5449
+ if (state.terminal) state.terminal.remeasure();
5450
+ });
5451
+ }
5452
+ }
5453
+
5319
5454
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
5320
5455
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, data.output, false));
5321
5456
 
@@ -5944,6 +6079,13 @@
5944
6079
  }
5945
6080
  }
5946
6081
 
6082
+ // Auto-update toggles
6083
+ var autoUpdate = data.autoUpdate || {};
6084
+ var autoUpdateWebToggle = document.getElementById("auto-update-web-toggle");
6085
+ if (autoUpdateWebToggle) autoUpdateWebToggle.checked = !!autoUpdate.web;
6086
+ var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
6087
+ if (autoUpdateApkToggle) autoUpdateApkToggle.checked = !!autoUpdate.apk;
6088
+
5947
6089
  // ── Android APK version display ──
5948
6090
  var apkSection = document.getElementById("android-apk-section");
5949
6091
  var apkCurrentRow = document.getElementById("android-apk-current-row");
@@ -6005,6 +6147,11 @@
6005
6147
  apkMessageEl.textContent = "暂无可用更新";
6006
6148
  apkMessageEl.classList.remove("hidden");
6007
6149
  }
6150
+ // Show APK auto-update toggle in APK mode
6151
+ var apkAutoRow = document.getElementById("android-auto-update-row");
6152
+ var apkAutoHint = document.getElementById("android-auto-update-hint");
6153
+ if (apkAutoRow) apkAutoRow.classList.remove("hidden");
6154
+ if (apkAutoHint) apkAutoHint.classList.remove("hidden");
6008
6155
  } else {
6009
6156
  // ── 浏览器模式:显示线上版本 + 本地版本 + 下载按钮 ──
6010
6157
  if (androidApk.github && apkGithubRow && apkGithubEl) {
@@ -6067,6 +6214,13 @@
6067
6214
  var langEl = document.getElementById("cfg-language");
6068
6215
  if (langEl) langEl.value = cfg.language || "";
6069
6216
 
6217
+ // Default model
6218
+ state.configDefaultModel = cfg.defaultModel || "";
6219
+ updateSettingsDefaultModelSelect();
6220
+ fetchAvailableModels().then(function() {
6221
+ updateSettingsDefaultModelSelect();
6222
+ }).catch(function() {});
6223
+
6070
6224
  // Cert status
6071
6225
  var certStatus = document.getElementById("cert-status");
6072
6226
  if (certStatus) {
@@ -6117,6 +6271,7 @@
6117
6271
  defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
6118
6272
  shell: (document.getElementById("cfg-shell") || {}).value,
6119
6273
  language: (document.getElementById("cfg-language") || {}).value || "",
6274
+ defaultModel: (document.getElementById("cfg-default-model") || {}).value || "",
6120
6275
  };
6121
6276
 
6122
6277
  fetch("/api/settings/config", {
@@ -6330,6 +6485,48 @@
6330
6485
  performRestart(restartBtn, msgEl);
6331
6486
  }
6332
6487
 
6488
+ function checkApkAutoUpdate() {
6489
+ fetch("/api/auto-update", { credentials: "same-origin" })
6490
+ .then(function(res) { return res.json(); })
6491
+ .then(function(autoData) {
6492
+ if (!autoData.apk) return;
6493
+ // Auto-update is enabled, check for APK update
6494
+ return fetch("/api/android-apk-update?currentVersion=" + encodeURIComponent(_apkVersion), { credentials: "same-origin" })
6495
+ .then(function(res) { return res.json(); })
6496
+ .then(function(data) {
6497
+ if (!data.updateAvailable || !data.downloadUrl) return;
6498
+ try {
6499
+ WandNative.downloadUpdate(data.downloadUrl, data.fileName || "wand-update.apk", data.source || "local");
6500
+ } catch (_e) {}
6501
+ });
6502
+ })
6503
+ .catch(function() {});
6504
+ }
6505
+
6506
+ function toggleAutoUpdate(type, enabled) {
6507
+ var body = {};
6508
+ body[type] = enabled;
6509
+ fetch("/api/auto-update", {
6510
+ method: "POST",
6511
+ headers: { "Content-Type": "application/json" },
6512
+ credentials: "same-origin",
6513
+ body: JSON.stringify(body),
6514
+ })
6515
+ .then(function(res) { return res.json(); })
6516
+ .then(function(data) {
6517
+ // Sync toggle state with server response
6518
+ var webToggle = document.getElementById("auto-update-web-toggle");
6519
+ var apkToggle = document.getElementById("auto-update-apk-toggle");
6520
+ if (webToggle) webToggle.checked = !!data.web;
6521
+ if (apkToggle) apkToggle.checked = !!data.apk;
6522
+ })
6523
+ .catch(function() {
6524
+ // Revert toggle on failure
6525
+ var toggle = document.getElementById("auto-update-" + type + "-toggle");
6526
+ if (toggle) toggle.checked = !enabled;
6527
+ });
6528
+ }
6529
+
6333
6530
  // ── Notification Settings Helpers ──
6334
6531
 
6335
6532
  function _updateAppIconSelection(activeIcon) {
@@ -6639,6 +6836,7 @@
6639
6836
  state.sessionTool = "claude";
6640
6837
  state.preferredCommand = "claude";
6641
6838
  syncComposerModeSelect();
6839
+ syncComposerModelSelect(getSelectedSession());
6642
6840
  return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
6643
6841
  .then(function(data) {
6644
6842
  saveWorkingDir(cwd);
@@ -6989,6 +7187,107 @@
6989
7187
  }
6990
7188
  }
6991
7189
 
7190
+ // ── Attachment helpers ──
7191
+
7192
+ var ATTACH_MAX_SIZE = 10 * 1024 * 1024;
7193
+
7194
+ function formatFileSize(bytes) {
7195
+ if (bytes < 1024) return bytes + " B";
7196
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
7197
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
7198
+ }
7199
+
7200
+ function isImageType(type) {
7201
+ return /^image\/(png|jpe?g|gif|webp|bmp|svg\+xml)/.test(type);
7202
+ }
7203
+
7204
+ function addPendingAttachment(file) {
7205
+ if (!file) return;
7206
+ if (file.size > ATTACH_MAX_SIZE) {
7207
+ showToast("文件过大(上限 10 MB): " + file.name, "error");
7208
+ return;
7209
+ }
7210
+ var entry = { file: file, name: file.name, size: file.size, previewUrl: null };
7211
+ if (isImageType(file.type)) {
7212
+ entry.previewUrl = URL.createObjectURL(file);
7213
+ }
7214
+ state.pendingAttachments.push(entry);
7215
+ renderAttachmentPreview();
7216
+ }
7217
+
7218
+ function removePendingAttachment(index) {
7219
+ var removed = state.pendingAttachments.splice(index, 1);
7220
+ if (removed.length && removed[0].previewUrl) {
7221
+ URL.revokeObjectURL(removed[0].previewUrl);
7222
+ }
7223
+ renderAttachmentPreview();
7224
+ }
7225
+
7226
+ function clearAttachments() {
7227
+ state.pendingAttachments.forEach(function(a) {
7228
+ if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
7229
+ });
7230
+ state.pendingAttachments = [];
7231
+ renderAttachmentPreview();
7232
+ }
7233
+
7234
+ function renderAttachmentPreview() {
7235
+ var bar = document.getElementById("attachment-preview");
7236
+ if (!bar) return;
7237
+ var items = state.pendingAttachments;
7238
+ if (items.length === 0) {
7239
+ bar.classList.add("hidden");
7240
+ bar.innerHTML = "";
7241
+ return;
7242
+ }
7243
+ bar.classList.remove("hidden");
7244
+ var html = "";
7245
+ for (var i = 0; i < items.length; i++) {
7246
+ var a = items[i];
7247
+ var thumb = a.previewUrl
7248
+ ? '<img src="' + escapeHtml(a.previewUrl) + '" alt="">'
7249
+ : '<span class="att-icon">📄</span>';
7250
+ html += '<span class="attachment-pill" data-index="' + i + '">' +
7251
+ thumb +
7252
+ '<span class="att-name" title="' + escapeHtml(a.name) + '">' + escapeHtml(a.name) + '</span>' +
7253
+ '<span class="att-size">' + formatFileSize(a.size) + '</span>' +
7254
+ '<button class="att-remove" data-index="' + i + '" title="移除">×</button>' +
7255
+ '</span>';
7256
+ }
7257
+ bar.innerHTML = html;
7258
+ bar.querySelectorAll(".att-remove").forEach(function(btn) {
7259
+ btn.addEventListener("click", function(e) {
7260
+ e.preventDefault();
7261
+ e.stopPropagation();
7262
+ removePendingAttachment(parseInt(btn.getAttribute("data-index"), 10));
7263
+ });
7264
+ });
7265
+ }
7266
+
7267
+ function uploadAttachments(sessionId) {
7268
+ if (!state.pendingAttachments.length) return Promise.resolve([]);
7269
+ var formData = new FormData();
7270
+ state.pendingAttachments.forEach(function(a) {
7271
+ formData.append("files", a.file, a.name);
7272
+ });
7273
+ return fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/upload", {
7274
+ method: "POST",
7275
+ body: formData,
7276
+ credentials: "same-origin"
7277
+ }).then(function(resp) {
7278
+ if (!resp.ok) return resp.json().then(function(e) { throw new Error(e.error || "上传失败"); });
7279
+ return resp.json();
7280
+ }).then(function(data) {
7281
+ return data.files || [];
7282
+ });
7283
+ }
7284
+
7285
+ function buildAttachmentPrefix(uploadedFiles) {
7286
+ if (!uploadedFiles || !uploadedFiles.length) return "";
7287
+ var paths = uploadedFiles.map(function(f) { return f.savedPath; });
7288
+ return "[附件已上传,请查看以下文件:\n" + paths.join("\n") + "]\n\n";
7289
+ }
7290
+
6992
7291
  function handleInteractiveTextInput(inputBox) {
6993
7292
  if (!state.terminalInteractive || !inputBox) return false;
6994
7293
  var value = inputBox.value || "";
@@ -7001,6 +7300,17 @@
7001
7300
  }
7002
7301
 
7003
7302
  function handleInputPaste(event) {
7303
+ var items = event.clipboardData && event.clipboardData.items;
7304
+ if (items && !state.terminalInteractive) {
7305
+ for (var i = 0; i < items.length; i++) {
7306
+ if (items[i].type.indexOf("image/") === 0) {
7307
+ event.preventDefault();
7308
+ var file = items[i].getAsFile();
7309
+ if (file) addPendingAttachment(file);
7310
+ return;
7311
+ }
7312
+ }
7313
+ }
7004
7314
  var pasted = event.clipboardData && event.clipboardData.getData("text");
7005
7315
  if (!pasted) return;
7006
7316
  event.preventDefault();
@@ -7483,12 +7793,9 @@
7483
7793
 
7484
7794
  if (!structured) {
7485
7795
  if (!state.terminal) initTerminal();
7486
- if (state.terminal && state.fitAddon) {
7487
- ensureTerminalFit();
7488
- }
7489
7796
  }
7490
7797
  applyCurrentView();
7491
- focusInputBox();
7798
+ focusInputBox(true);
7492
7799
  }
7493
7800
 
7494
7801
 
@@ -7501,7 +7808,9 @@
7501
7808
  var inputBox = document.getElementById("input-box");
7502
7809
  var value = inputBox ? inputBox.value : "";
7503
7810
  var selectedSession = getSelectedSession();
7504
- if (value) {
7811
+ var hasAttachments = state.pendingAttachments.length > 0;
7812
+
7813
+ if (value || hasAttachments) {
7505
7814
  console.log("[WAND] sendInputFromBox", {
7506
7815
  sessionId: state.selectedId,
7507
7816
  sessionStatus: selectedSession ? selectedSession.status : null,
@@ -7511,50 +7820,62 @@
7511
7820
  view: state.currentView,
7512
7821
  wsConnected: state.wsConnected,
7513
7822
  terminalInteractive: state.terminalInteractive,
7514
- inputLength: value.length
7823
+ inputLength: value.length,
7824
+ attachments: state.pendingAttachments.length
7515
7825
  });
7516
- // Clear todo progress bar at the start of a new user turn
7517
- var todoEl = document.getElementById("todo-progress");
7518
- if (todoEl) todoEl.classList.add("hidden");
7519
7826
 
7520
- if (isStructuredSession(selectedSession)) {
7521
- return postStructuredInput(value, inputBox, selectedSession);
7522
- }
7827
+ var attachUpload = hasAttachments && state.selectedId
7828
+ ? uploadAttachments(state.selectedId)
7829
+ : Promise.resolve([]);
7523
7830
 
7524
- var submitChunks = getTerminalSubmitChunks(selectedSession, value);
7525
- var isOffline = !state.wsConnected;
7831
+ return attachUpload.then(function(uploadedFiles) {
7832
+ var prefix = buildAttachmentPrefix(uploadedFiles);
7833
+ var finalValue = prefix + (value || (uploadedFiles.length ? "请查看附件。" : ""));
7834
+ if (uploadedFiles.length) clearAttachments();
7526
7835
 
7527
- if (isOffline) {
7528
- // Offline: queue for flush on reconnect, clear input immediately
7529
- queueOfflineTerminalChunks(submitChunks);
7530
- if (inputBox) {
7531
- inputBox.value = "";
7532
- autoResizeInput(inputBox);
7533
- }
7534
- setDraftValue("");
7535
- return Promise.resolve();
7536
- }
7836
+ // Clear todo progress bar at the start of a new user turn
7837
+ var todoEl = document.getElementById("todo-progress");
7838
+ if (todoEl) todoEl.classList.add("hidden");
7537
7839
 
7538
- // Online: send via queue, only clear on success
7539
- return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
7540
- if (!readySession) {
7541
- showToast("会话未就绪,将稍后重试。", "info");
7542
- return null;
7840
+ if (isStructuredSession(selectedSession)) {
7841
+ return postStructuredInput(finalValue, inputBox, selectedSession);
7543
7842
  }
7544
- var submitView = state.currentView;
7545
- if (readySession && readySession.provider === "codex" && state.selectedId !== readySession.id) {
7546
- throw new Error("Codex session changed before input send.");
7547
- }
7548
- return sendTerminalChunks(submitChunks, "enter_text", 0, submitView).then(function() {
7549
- // Clear input only after the send succeeds
7550
- if (inputBox && inputBox.value === value) {
7843
+
7844
+ var submitChunks = getTerminalSubmitChunks(selectedSession, finalValue);
7845
+ var isOffline = !state.wsConnected;
7846
+
7847
+ if (isOffline) {
7848
+ queueOfflineTerminalChunks(submitChunks);
7849
+ if (inputBox) {
7551
7850
  inputBox.value = "";
7552
7851
  autoResizeInput(inputBox);
7553
7852
  }
7554
7853
  setDraftValue("");
7854
+ return Promise.resolve();
7855
+ }
7856
+
7857
+ return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
7858
+ if (!readySession) {
7859
+ showToast("会话未就绪,将稍后重试。", "info");
7860
+ return null;
7861
+ }
7862
+ var submitView = state.currentView;
7863
+ if (readySession && readySession.provider === "codex" && state.selectedId !== readySession.id) {
7864
+ throw new Error("Codex session changed before input send.");
7865
+ }
7866
+ return sendTerminalChunks(submitChunks, "enter_text", 30, submitView).then(function() {
7867
+ if (inputBox && inputBox.value === value) {
7868
+ inputBox.value = "";
7869
+ autoResizeInput(inputBox);
7870
+ }
7871
+ setDraftValue("");
7872
+ });
7873
+ }).catch(function(err) {
7874
+ showToast(getInputErrorMessage(err), "error");
7875
+ throw err;
7555
7876
  });
7556
7877
  }).catch(function(err) {
7557
- showToast(getInputErrorMessage(err), "error");
7878
+ showToast("附件上传失败: " + (err.message || err), "error");
7558
7879
  throw err;
7559
7880
  });
7560
7881
  }
@@ -7805,10 +8126,9 @@
7805
8126
  }
7806
8127
 
7807
8128
  function getTerminalSubmitChunks(session, text) {
7808
- if (session && session.provider === "codex") {
7809
- return [text, String.fromCharCode(13)];
7810
- }
7811
- return [text + String.fromCharCode(13)];
8129
+ // 文本与回车分两个 chunk 发,避免 CLI bracketed paste 检测把末尾
8130
+ // \r 并入粘贴内容导致只换行不提交。
8131
+ return [text, String.fromCharCode(13)];
7812
8132
  }
7813
8133
 
7814
8134
  function sendTerminalChunks(chunks, shortcutKey, delayMs, viewOverride) {
@@ -7848,7 +8168,6 @@
7848
8168
  return postInput(input, shortcutKey, viewOverride).finally(function() {
7849
8169
  var idx = state.messageQueue.indexOf(input);
7850
8170
  if (idx > -1) state.messageQueue.splice(idx, 1);
7851
- scheduleMobileDomUpdate();
7852
8171
  });
7853
8172
  });
7854
8173
  return state.inputQueue;
@@ -8491,6 +8810,7 @@
8491
8810
  state.preferredCommand = command;
8492
8811
  state.chatMode = getSafeModeForTool(command, state.chatMode);
8493
8812
  }
8813
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8494
8814
  return fetch("/api/commands", {
8495
8815
  method: "POST",
8496
8816
  headers: { "Content-Type": "application/json" },
@@ -8498,7 +8818,8 @@
8498
8818
  body: JSON.stringify({
8499
8819
  command: command,
8500
8820
  cwd: cwd || "",
8501
- mode: state.chatMode || state.config.defaultMode || "default"
8821
+ mode: state.chatMode || state.config.defaultMode || "default",
8822
+ model: modelPref || undefined
8502
8823
  })
8503
8824
  })
8504
8825
  .then(function(res) { return res.json(); })
@@ -8618,6 +8939,7 @@
8618
8939
  var mode = state.chatMode || "managed";
8619
8940
  var defaultCwd = getEffectiveCwd();
8620
8941
  var preferredTool = getPreferredTool();
8942
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8621
8943
  fetch("/api/commands", {
8622
8944
  method: "POST",
8623
8945
  headers: { "Content-Type": "application/json" },
@@ -8626,7 +8948,8 @@
8626
8948
  command: preferredTool,
8627
8949
  cwd: defaultCwd,
8628
8950
  mode: mode,
8629
- initialInput: value
8951
+ initialInput: value,
8952
+ model: modelPref || undefined
8630
8953
  })
8631
8954
  })
8632
8955
  .then(function(res) { return res.json(); })
@@ -8654,6 +8977,7 @@
8654
8977
  var mode = state.chatMode || "managed";
8655
8978
  var defaultCwd = getEffectiveCwd();
8656
8979
  var preferredTool = getPreferredTool();
8980
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8657
8981
  fetch("/api/commands", {
8658
8982
  method: "POST",
8659
8983
  headers: { "Content-Type": "application/json" },
@@ -8662,7 +8986,8 @@
8662
8986
  command: preferredTool,
8663
8987
  cwd: defaultCwd,
8664
8988
  mode: mode,
8665
- initialInput: value || undefined
8989
+ initialInput: value || undefined,
8990
+ model: modelPref || undefined
8666
8991
  })
8667
8992
  })
8668
8993
  .then(function(res) { return res.json(); })
@@ -8818,7 +9143,7 @@
8818
9143
  setTimeout(function() {
8819
9144
  window.scrollTo(0, 0);
8820
9145
  // On mobile, force terminal refit + scroll after keyboard dismissal.
8821
- // The container height restores but xterm needs an explicit refit to
9146
+ // The container height restores but terminal needs time to
8822
9147
  // fill the expanded space, and the scroll position needs resetting.
8823
9148
  if (isTouchDevice()) {
8824
9149
  ensureTerminalFit();
@@ -9487,9 +9812,8 @@
9487
9812
  if (!output) return;
9488
9813
  output.setAttribute("tabindex", "0");
9489
9814
  output.focus();
9490
- var terminalTextarea = output.querySelector(".xterm-helper-textarea");
9491
- if (terminalTextarea && typeof terminalTextarea.focus === "function") {
9492
- terminalTextarea.focus();
9815
+ if (state.terminal && state.terminal.focus) {
9816
+ state.terminal.focus();
9493
9817
  }
9494
9818
  }
9495
9819
 
@@ -9655,11 +9979,6 @@
9655
9979
  function observeTerminalResize() {
9656
9980
  var output = document.getElementById("output");
9657
9981
  if (!output) return;
9658
-
9659
- if (typeof ResizeObserver === "function") {
9660
- state.resizeObserver = new ResizeObserver(function() { scheduleTerminalResize(true); });
9661
- state.resizeObserver.observe(output);
9662
- }
9663
9982
  var lastKnownDesktop = !isMobileLayout();
9664
9983
  state.resizeHandler = function() {
9665
9984
  scheduleTerminalResize(true);
@@ -9685,7 +10004,36 @@
9685
10004
  requestAnimationFrame(function() { scheduleTerminalResize(true); });
9686
10005
  }
9687
10006
 
10007
+ function startTerminalHealthCheck() {
10008
+ if (state.terminalHealthTimer) return;
10009
+ state.terminalHealthTimer = setInterval(function() {
10010
+ if (!state.terminal || state.currentView !== "terminal" || document.hidden) return;
10011
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
10012
+ if (!selectedSession || selectedSession.sessionKind === "structured") return;
10013
+ // Lightweight remeasure every 5s
10014
+ if (state.terminal.remeasure) state.terminal.remeasure();
10015
+ // Full re-sync every 30s during output pauses
10016
+ var now = Date.now();
10017
+ var chunkPause = state.lastChunkAt > 0 && (now - state.lastChunkAt > 300);
10018
+ var resyncDue = (now - state.lastTerminalResyncAt) > 30000;
10019
+ if (resyncDue && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
10020
+ state.lastTerminalResyncAt = now;
10021
+ state.terminal.reset();
10022
+ state.terminal.write(state.terminalOutput);
10023
+ maybeScrollTerminalToBottom("output");
10024
+ }
10025
+ }, 5000);
10026
+ }
10027
+
10028
+ function stopTerminalHealthCheck() {
10029
+ if (state.terminalHealthTimer) {
10030
+ clearInterval(state.terminalHealthTimer);
10031
+ state.terminalHealthTimer = null;
10032
+ }
10033
+ }
10034
+
9688
10035
  function teardownTerminal() {
10036
+ stopTerminalHealthCheck();
9689
10037
  if (state.resizeTimer) {
9690
10038
  clearTimeout(state.resizeTimer);
9691
10039
  state.resizeTimer = null;
@@ -9711,6 +10059,7 @@
9711
10059
  }
9712
10060
  });
9713
10061
  clearTerminalScrollIdleTimer();
10062
+ var output = document.getElementById("output");
9714
10063
  if (state.terminalViewportEl) {
9715
10064
  if (state.terminalViewportScrollHandler) {
9716
10065
  state.terminalViewportEl.removeEventListener("scroll", state.terminalViewportScrollHandler);
@@ -9719,27 +10068,30 @@
9719
10068
  state.terminalViewportEl.removeEventListener("touchmove", state.terminalViewportTouchHandler);
9720
10069
  }
9721
10070
  }
10071
+ if (output) {
10072
+ if (state.terminalWheelHandler) {
10073
+ output.removeEventListener("wheel", state.terminalWheelHandler);
10074
+ }
10075
+ if (state.terminalClickHandler) {
10076
+ output.removeEventListener("click", state.terminalClickHandler);
10077
+ }
10078
+ }
9722
10079
  state.terminalViewportEl = null;
9723
10080
  state.terminalViewportScrollHandler = null;
9724
10081
  state.terminalViewportTouchHandler = null;
10082
+ state.terminalWheelHandler = null;
10083
+ state.terminalClickHandler = null;
10084
+ if (state.terminalScrollbarEl && state.terminalScrollbarEl.parentNode) {
10085
+ state.terminalScrollbarEl.parentNode.removeChild(state.terminalScrollbarEl);
10086
+ }
10087
+ state.terminalScrollbarEl = null;
10088
+ state.terminalScrollbarThumbEl = null;
9725
10089
  if (state.terminal) {
9726
- state.terminal.dispose();
10090
+ state.terminal.destroy();
9727
10091
  state.terminal = null;
9728
10092
  }
9729
- state.fitAddon = null;
9730
- state.serializeAddon = null;
9731
- if (state.terminalDomView && state.terminalDomView.parentNode) {
9732
- state.terminalDomView.parentNode.removeChild(state.terminalDomView);
9733
- }
9734
- state.terminalDomView = null;
9735
- state._lastDomHtml = "";
9736
- if (state.terminalDomUpdateTimer) {
9737
- clearTimeout(state.terminalDomUpdateTimer);
9738
- state.terminalDomUpdateTimer = null;
9739
- }
9740
10093
  state.terminalSessionId = null;
9741
10094
  state.terminalOutput = "";
9742
- state.terminalViewportSize = { width: 0, height: 0 };
9743
10095
  state.terminalAutoFollow = true;
9744
10096
  state.showTerminalJumpToBottom = false;
9745
10097
  updateTerminalJumpToBottomButton();
@@ -9762,39 +10114,9 @@
9762
10114
  }
9763
10115
 
9764
10116
  function ensureTerminalFit() {
9765
- if (state.terminalFitInProgress) return;
9766
- state.terminalFitInProgress = true;
9767
- var maxAttempts = 20;
9768
- var attempt = 0;
9769
- function finishFit() {
9770
- state.terminalFitInProgress = false;
9771
- }
9772
- function tryFit() {
9773
- attempt++;
9774
- state.terminalViewportSize = { width: 0, height: 0 };
9775
- if (shouldResizeTerminalViewport() && state.fitAddon) {
9776
- state.fitAddon.fit();
9777
- maybeScrollTerminalToBottom("resize");
9778
- if (state.terminal) {
9779
- sendTerminalResize(state.terminal.cols, state.terminal.rows);
9780
- }
9781
- var output = document.getElementById("output");
9782
- if (output && state.terminal) {
9783
- var containerW = output.getBoundingClientRect().width;
9784
- var expectedMinCols = Math.floor(containerW / 20);
9785
- if (state.terminal.cols < expectedMinCols && attempt < maxAttempts) {
9786
- requestAnimationFrame(tryFit);
9787
- return;
9788
- }
9789
- }
9790
- finishFit();
9791
- } else if (attempt < maxAttempts) {
9792
- requestAnimationFrame(tryFit);
9793
- } else {
9794
- finishFit();
9795
- }
9796
- }
9797
- requestAnimationFrame(tryFit);
10117
+ if (!state.terminal) return;
10118
+ maybeScrollTerminalToBottom("resize");
10119
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9798
10120
  }
9799
10121
 
9800
10122
  function scheduleTerminalResize(immediate) {
@@ -9810,16 +10132,11 @@
9810
10132
  }
9811
10133
 
9812
10134
  function syncTerminalSize() {
9813
- var output = document.getElementById("output");
9814
- if (!state.terminal || !state.fitAddon || !output) return;
9815
- if (!shouldResizeTerminalViewport()) return;
9816
-
10135
+ if (!state.terminal) return;
9817
10136
  var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
9818
- state.fitAddon.fit();
9819
10137
  if (shouldFollow) {
9820
10138
  maybeScrollTerminalToBottom("resize");
9821
10139
  }
9822
-
9823
10140
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
9824
10141
  }
9825
10142
 
@@ -9865,9 +10182,8 @@
9865
10182
  flushPendingMessages();
9866
10183
  // Re-fit terminal on reconnect — the viewport may have changed
9867
10184
  // while disconnected, and the PTY needs up-to-date dimensions.
9868
- if (state.terminal && state.fitAddon) {
9869
- state.terminalViewportSize = { width: 0, height: 0 };
9870
- ensureTerminalFit();
10185
+ if (state.terminal) {
10186
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9871
10187
  }
9872
10188
  };
9873
10189
 
@@ -9947,8 +10263,22 @@
9947
10263
  snapshot.messages = msg.data.messages;
9948
10264
  }
9949
10265
 
9950
- // Only update if we have meaningful data
9951
- if (snapshot.output !== undefined || snapshot.messages || isIncremental || msg.data.permissionBlocked !== undefined) {
10266
+ // Fast path: chunk-only incremental events skip expensive chat update
10267
+ var isChunkOnly = isIncremental && msg.data.chunk
10268
+ && !msg.data.lastMessage && !snapshot.messages
10269
+ && snapshot.output === undefined
10270
+ && !msg.data.structuredState && !msg.data.sessionKind;
10271
+
10272
+ if (isChunkOnly) {
10273
+ // Only update permissionBlocked if it actually changed
10274
+ if (msg.data.permissionBlocked !== undefined) {
10275
+ var existingPB = state.sessions.find(function(s) { return s.id === msg.sessionId; });
10276
+ if (existingPB && !!existingPB.permissionBlocked !== !!msg.data.permissionBlocked) {
10277
+ updateSessionSnapshot(snapshot);
10278
+ if (msg.sessionId === state.selectedId) updateTaskDisplay();
10279
+ }
10280
+ }
10281
+ } else if (snapshot.output !== undefined || snapshot.messages || isIncremental || msg.data.permissionBlocked !== undefined) {
9952
10282
  updateSessionSnapshot(snapshot);
9953
10283
  if (msg.sessionId === state.selectedId) {
9954
10284
  var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
@@ -9966,21 +10296,20 @@
9966
10296
  // Real-time terminal output
9967
10297
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
9968
10298
  if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
9969
- // Fast path: write chunk directly to avoid full-output comparison
9970
- // which can trigger terminal.reset() and cause screen flicker.
10299
+ // Fast path: write chunk directly to avoid full-output comparison.
10300
+ state.lastChunkAt = Date.now();
9971
10301
  state.terminalLiveStreamSessions[msg.sessionId] = true;
9972
10302
  state.terminal.write(msg.data.chunk);
9973
10303
  state.terminalSessionId = msg.sessionId;
9974
10304
  if (msg.data.output) {
9975
10305
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
9976
10306
  } else {
9977
- state.terminalOutput = normalizeTerminalOutput((state.terminalOutput || "") + msg.data.chunk);
10307
+ state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
9978
10308
  }
9979
10309
  maybeScrollTerminalToBottom("output");
9980
10310
  updateTerminalJumpToBottomButton();
9981
- scheduleMobileDomUpdate();
9982
10311
  } else if (!msg.data.incremental && Object.prototype.hasOwnProperty.call(msg.data, "output")) {
9983
- // Fallback: no chunk available, use full-output comparison (only in full mode)
10312
+ // Fallback: no chunk available, use full-output comparison.
9984
10313
  syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
9985
10314
  }
9986
10315
  }
@@ -10030,6 +10359,7 @@
10030
10359
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
10031
10360
  }
10032
10361
  notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
10362
+ clearSessionProgressNative(msg.sessionId);
10033
10363
  if (msg.sessionId !== state.selectedId || document.hidden) {
10034
10364
  showNotificationBubble({
10035
10365
  title: endedNotifTitle,
@@ -10104,6 +10434,11 @@
10104
10434
  updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
10105
10435
  // Ensure terminal is properly fitted after receiving initial data
10106
10436
  scheduleTerminalResize(true);
10437
+ if (state.terminal && state.terminal.remeasure) {
10438
+ requestAnimationFrame(function() {
10439
+ if (state.terminal) state.terminal.remeasure();
10440
+ });
10441
+ }
10107
10442
  }
10108
10443
  break;
10109
10444
  case 'usage':
@@ -10116,6 +10451,7 @@
10116
10451
  updateTaskDisplay();
10117
10452
  }
10118
10453
  notifyTaskProgress(msg.sessionId, msg.data || null);
10454
+ syncSessionProgressToNative(msg.sessionId);
10119
10455
  // Update session list to reflect current activity (debounced)
10120
10456
  scheduleSessionListUpdate();
10121
10457
  break;
@@ -10185,6 +10521,7 @@
10185
10521
  statusUpdate.approvalStats = msg.data.approvalStats;
10186
10522
  }
10187
10523
  updateSessionSnapshot(statusUpdate);
10524
+ syncSessionProgressToNative(msg.sessionId);
10188
10525
  if (msg.sessionId === state.selectedId) {
10189
10526
  updateTaskDisplay();
10190
10527
  if (msg.data.approvalStats) {
@@ -10207,6 +10544,10 @@
10207
10544
  if (msg.data) {
10208
10545
  if (msg.data.kind === "update") {
10209
10546
  notifyUpdateAvailable(msg.data.current || "-", msg.data.latest || "-");
10547
+ } else if (msg.data.kind === "auto-update-start") {
10548
+ showAutoUpdateOverlay(msg.data.current || "-", msg.data.latest || "-");
10549
+ } else if (msg.data.kind === "auto-update-restart") {
10550
+ showRestartOverlay();
10210
10551
  } else if (msg.data.kind === "restart") {
10211
10552
  showRestartOverlay();
10212
10553
  }
@@ -10440,8 +10781,12 @@
10440
10781
  }
10441
10782
  updateTerminalJumpToBottomButton();
10442
10783
  if (state.currentView === "terminal") {
10443
- state.terminalViewportSize = { width: 0, height: 0 };
10444
10784
  scheduleTerminalResize(true);
10785
+ if (state.terminal && state.terminal.remeasure) {
10786
+ requestAnimationFrame(function() {
10787
+ if (state.terminal) state.terminal.remeasure();
10788
+ });
10789
+ }
10445
10790
  }
10446
10791
  }
10447
10792
 
@@ -10467,13 +10812,15 @@
10467
10812
  if (chatRenderTimer) clearTimeout(chatRenderTimer);
10468
10813
  if (immediate) {
10469
10814
  chatRenderTimer = null;
10470
- // Messages already updated in handleWebSocketMessage, just render
10471
10815
  renderChat();
10472
10816
  return;
10473
10817
  }
10818
+ var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
10819
+ var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
10820
+ && selectedForDelay.sessionKind !== "structured";
10821
+ var delay = isActiveStream ? 150 : 30;
10474
10822
  chatRenderTimer = setTimeout(function() {
10475
10823
  chatRenderTimer = null;
10476
- // Re-parse messages from the latest session output (fallback for edge cases)
10477
10824
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
10478
10825
  if (selectedSession) {
10479
10826
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
@@ -11016,6 +11363,10 @@
11016
11363
  list.innerHTML = html;
11017
11364
  }
11018
11365
 
11366
+ // Sync todo progress to native notification
11367
+ if (state.selectedId) {
11368
+ syncSessionProgressToNative(state.selectedId);
11369
+ }
11019
11370
  }
11020
11371
 
11021
11372
 
@@ -11143,161 +11494,6 @@
11143
11494
  })();
11144
11495
 
11145
11496
  // ===== Terminal copy button for mobile =====
11146
- // ===== Mobile DOM terminal view =====
11147
- function initMobileDomTerminal(container) {
11148
- // DOM terminal feature removed — always return
11149
- return;
11150
-
11151
- // Create DOM view container
11152
- var domView = document.createElement("div");
11153
- domView.className = "terminal-dom-view active";
11154
- container.appendChild(domView);
11155
-
11156
- // Always set font-size explicitly to match xterm
11157
- domView.style.fontSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
11158
-
11159
- // Hide xterm canvas but keep it in layout for FitAddon sizing.
11160
- // Use opacity:0 + pointer-events:none so the element still occupies
11161
- // space in the flex container and fit() can compute cols/rows correctly.
11162
- setTimeout(function() {
11163
- var xtermEl = container.querySelector(".xterm");
11164
- if (xtermEl) {
11165
- xtermEl.style.opacity = "0";
11166
- xtermEl.style.pointerEvents = "none";
11167
- }
11168
- }, 100);
11169
-
11170
- // Save reference
11171
- state.terminalDomView = domView;
11172
- state.terminalDomUpdateTimer = null;
11173
-
11174
- // Scroll events for auto-follow
11175
- domView.addEventListener("scroll", function() {
11176
- var distance = domView.scrollHeight - domView.clientHeight - domView.scrollTop;
11177
- if (distance <= state.terminalScrollThreshold) {
11178
- state.terminalAutoFollow = true;
11179
- clearTerminalScrollIdleTimer();
11180
- updateTerminalJumpToBottomButton();
11181
- } else {
11182
- setTerminalManualScrollActive();
11183
- }
11184
- }, { passive: true });
11185
-
11186
- domView.addEventListener("touchmove", function() {
11187
- setTerminalManualScrollActive();
11188
- }, { passive: true });
11189
-
11190
- // Trigger initial render
11191
- scheduleMobileDomUpdate();
11192
- }
11193
-
11194
- function updateMobileDomView() {
11195
- if (!state.terminalDomView || !state.serializeAddon) return;
11196
-
11197
- try {
11198
- // Serialize the entire buffer including scrollback history
11199
- var buf = state.terminal.buffer.active;
11200
- var totalRows = buf.length;
11201
- var html = state.serializeAddon.serializeAsHTML({
11202
- includeGlobalBackground: true,
11203
- range: { start: 0, end: totalRows }
11204
- });
11205
-
11206
- // Extract the <pre>...</pre> portion
11207
- var match = html.match(/<pre[\s\S]*<\/pre>/);
11208
- var preHtml = match ? match[0] : "";
11209
-
11210
- // Strip inline font-size/font-family from the serialized HTML
11211
- // so our CSS controls sizing and font consistently
11212
- preHtml = preHtml.replace(/font-size:\s*[^;"']+;?/g, "");
11213
- preHtml = preHtml.replace(/font-family:\s*[^;"']+;?/g, "");
11214
-
11215
- // Fix colors for dark background
11216
- preHtml = fixDarkTerminalColors(preHtml);
11217
-
11218
- // Skip update if content unchanged
11219
- if (preHtml === state._lastDomHtml) return;
11220
- state._lastDomHtml = preHtml;
11221
-
11222
- // Preserve scroll position for non-auto-follow mode
11223
- var wasAtBottom = state.terminalAutoFollow;
11224
- var scrollTop = state.terminalDomView.scrollTop;
11225
-
11226
- state.terminalDomView.innerHTML = preHtml;
11227
-
11228
- if (wasAtBottom) {
11229
- state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
11230
- } else {
11231
- state.terminalDomView.scrollTop = scrollTop;
11232
- }
11233
- } catch (e) {
11234
- // Fallback: plain text if serialize fails
11235
- if (state.terminal && state.terminal.buffer) {
11236
- var buf = state.terminal.buffer.active;
11237
- var lines = [];
11238
- for (var i = 0; i < buf.length; i++) {
11239
- var line = buf.getLine(i);
11240
- if (line) lines.push(line.translateToString(true));
11241
- }
11242
- var text = lines.join("\n").replace(/\n+$/, "");
11243
- state.terminalDomView.innerHTML = '<pre><div style="padding:8px 12px">' + escapeHtml(text) + '</div></pre>';
11244
- if (state.terminalAutoFollow) {
11245
- state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
11246
- }
11247
- }
11248
- }
11249
- }
11250
-
11251
- // Fix serialize addon's color issues on dark terminal background
11252
- function fixDarkTerminalColors(html) {
11253
- // Theme reference: bg=#1f1b17, fg=#f5eadc, black=#1f1b17, brightBlack=#625347
11254
- // 1. Hardcoded inverse: black text on gray → theme fg on theme brightBlack bg
11255
- html = html.replace(
11256
- /color:\s*#000000;\s*background-color:\s*#BFBFBF/g,
11257
- "color: #f5eadc; background-color: #625347"
11258
- );
11259
- // 2. Fix foreground colors that are too dark to read on #1f1b17 background.
11260
- // Process each style attribute: split into declarations, fix only "color:" (not "background-color:").
11261
- html = html.replace(/style='([^']*)'/g, function(_match, styles) {
11262
- var parts = styles.split(";");
11263
- for (var i = 0; i < parts.length; i++) {
11264
- var decl = parts[i].trim();
11265
- // Skip background-color declarations
11266
- if (/^background-color\s*:/.test(decl)) continue;
11267
- // Match standalone color declaration
11268
- if (/^color\s*:/.test(decl)) {
11269
- var hexMatch = decl.match(/#([0-9a-fA-F]{6})\b/);
11270
- if (hexMatch && isColorTooDark("#" + hexMatch[1])) {
11271
- parts[i] = parts[i].replace(/#[0-9a-fA-F]{6}/, "#625347");
11272
- }
11273
- }
11274
- }
11275
- return "style='" + parts.join(";") + "'";
11276
- });
11277
- return html;
11278
- }
11279
-
11280
- function isColorTooDark(hex) {
11281
- // Parse hex color and check relative luminance
11282
- var r = parseInt(hex.substring(1, 3), 16);
11283
- var g = parseInt(hex.substring(3, 5), 16);
11284
- var b = parseInt(hex.substring(5, 7), 16);
11285
- // Simple perceived brightness: if below threshold, it's too dark for #1f1b17 bg
11286
- var brightness = (r * 299 + g * 587 + b * 114) / 1000;
11287
- return brightness < 45; // #1f1b17 has brightness ~22, threshold catches colors close to it
11288
- }
11289
-
11290
- function scheduleMobileDomUpdate() {
11291
- if (!state.terminalDomView) return;
11292
- // Trailing-edge debounce: reset timer on each call to batch rapid updates
11293
- if (state.terminalDomUpdateTimer) {
11294
- clearTimeout(state.terminalDomUpdateTimer);
11295
- }
11296
- state.terminalDomUpdateTimer = setTimeout(function() {
11297
- state.terminalDomUpdateTimer = null;
11298
- updateMobileDomView();
11299
- }, 150);
11300
- }
11301
11497
 
11302
11498
  function isNoiseLine(line) {
11303
11499
  if (!line) return false;
@@ -13387,7 +13583,79 @@
13387
13583
  );
13388
13584
  }
13389
13585
 
13390
- /**
13586
+ // ── Native Live Progress Sync ──
13587
+
13588
+ var _progressSyncTimers = {};
13589
+ var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
13590
+
13591
+ function syncSessionProgressToNative(sessionId) {
13592
+ if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
13593
+ if (!sessionId) return;
13594
+ if (_progressSyncTimers[sessionId]) {
13595
+ clearTimeout(_progressSyncTimers[sessionId]);
13596
+ }
13597
+ _progressSyncTimers[sessionId] = setTimeout(function() {
13598
+ delete _progressSyncTimers[sessionId];
13599
+ _doSyncSessionProgress(sessionId);
13600
+ }, _PROGRESS_SYNC_DEBOUNCE_MS);
13601
+ }
13602
+
13603
+ function _doSyncSessionProgress(sessionId) {
13604
+ var session = state.sessions.find(function(s) { return s.id === sessionId; });
13605
+ if (!session) return;
13606
+
13607
+ var sessionLabel = session.summary || session.command || sessionId;
13608
+ var sessionStatus = session.status || "running";
13609
+
13610
+ // Clear notification for inactive sessions
13611
+ if (sessionStatus === "idle" || sessionStatus === "archived" || sessionStatus === "exited") {
13612
+ clearSessionProgressNative(sessionId);
13613
+ return;
13614
+ }
13615
+
13616
+ // Get latest todos from session messages
13617
+ var todos = null;
13618
+ var messages = session.messages || [];
13619
+ for (var i = messages.length - 1; i >= 0; i--) {
13620
+ var msg = messages[i];
13621
+ if (!msg.content || !Array.isArray(msg.content)) continue;
13622
+ for (var j = msg.content.length - 1; j >= 0; j--) {
13623
+ var block = msg.content[j];
13624
+ if (block.type === "tool_use" && block.name === "TodoWrite"
13625
+ && block.input && block.input.todos) {
13626
+ todos = block.input.todos;
13627
+ break;
13628
+ }
13629
+ }
13630
+ if (todos) break;
13631
+ }
13632
+
13633
+ // Get current task
13634
+ var currentTask = "";
13635
+ if (sessionId === state.selectedId && state.currentTask && state.currentTask.title) {
13636
+ currentTask = state.currentTask.title;
13637
+ }
13638
+
13639
+ var data = {
13640
+ sessionLabel: sessionLabel,
13641
+ status: sessionStatus,
13642
+ currentTask: currentTask,
13643
+ todos: todos || []
13644
+ };
13645
+
13646
+ try {
13647
+ WandNative.updateSessionProgress(sessionId, JSON.stringify(data));
13648
+ } catch (_e) {}
13649
+ }
13650
+
13651
+ function clearSessionProgressNative(sessionId) {
13652
+ if (!_hasNativeBridge || typeof WandNative.clearSessionProgress !== "function") return;
13653
+ if (_progressSyncTimers[sessionId]) {
13654
+ clearTimeout(_progressSyncTimers[sessionId]);
13655
+ delete _progressSyncTimers[sessionId];
13656
+ }
13657
+ try { WandNative.clearSessionProgress(sessionId); } catch (_e) {}
13658
+ }
13391
13659
 
13392
13660
  /**
13393
13661
  * Play a soft, rounded notification chime using Web Audio API.
@@ -13606,6 +13874,23 @@
13606
13874
  }, 2000);
13607
13875
  }
13608
13876
 
13877
+ function showAutoUpdateOverlay(currentVer, latestVer) {
13878
+ if (document.getElementById("restart-overlay")) return;
13879
+ var overlay = document.createElement("div");
13880
+ overlay.id = "restart-overlay";
13881
+ overlay.className = "restart-overlay";
13882
+ overlay.innerHTML =
13883
+ '<div class="restart-overlay-content">' +
13884
+ '<div class="restart-spinner"></div>' +
13885
+ '<div class="restart-title">\u81ea\u52a8\u66f4\u65b0\u4e2d</div>' +
13886
+ '<div class="restart-subtitle">' +
13887
+ escapeHtml(currentVer) + ' \u2192 ' + escapeHtml(latestVer) +
13888
+ '<br>\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\uff0c\u7a0d\u540e\u5c06\u81ea\u52a8\u91cd\u542f\u2026' +
13889
+ '</div>' +
13890
+ '</div>';
13891
+ document.body.appendChild(overlay);
13892
+ }
13893
+
13609
13894
  function escapeHtml(value) {
13610
13895
  return String(value)
13611
13896
  .replace(/&/g, "&amp;")