@co0ontty/wand 1.15.1 → 1.17.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.
@@ -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",
@@ -199,11 +201,13 @@
199
201
  claudeHistoryLoaded: false,
200
202
  claudeHistoryExpanded: true,
201
203
  claudeHistoryExpandedDirs: {},
204
+ archivedExpanded: false,
202
205
  sessionsManageMode: false,
203
206
  selectedSessionIds: {},
204
207
  selectedClaudeHistoryIds: {},
205
208
  askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
206
209
  queueEpoch: 0, // Monotonic counter for queue state freshness
210
+ pendingAttachments: [], // [{ file, previewUrl, name, size }]
207
211
  // Load last used working directory from localStorage
208
212
  workingDir: (function() {
209
213
  try {
@@ -889,16 +893,21 @@
889
893
  try {
890
894
  render({ skipShellChrome: true });
891
895
  } catch (_e) {
892
- // render() may fail if external scripts (xterm.js) failed to load;
896
+ // render() may fail if external scripts (wterm) failed to load;
893
897
  // continue with polling and session loading so the app remains functional
894
898
  }
895
899
  bindForegroundSyncListeners();
896
900
  startPolling();
897
901
  refreshAll();
902
+ fetchAvailableModels();
898
903
  requestNotificationPermission();
899
904
  if (config.updateAvailable && config.latestVersion) {
900
905
  notifyUpdateAvailable(config.currentVersion || "-", config.latestVersion);
901
906
  }
907
+ // APK auto-update check on startup
908
+ if (_apkVersion) {
909
+ checkApkAutoUpdate();
910
+ }
902
911
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
903
912
  loadClaudeHistory();
904
913
  }
@@ -952,6 +961,7 @@
952
961
  attachEventListeners();
953
962
  updateDrawerState();
954
963
  syncComposerModeSelect();
964
+ syncComposerModelSelect(getSelectedSession());
955
965
  applyCurrentView();
956
966
  if (!skipShellChrome) {
957
967
  updateShellChrome();
@@ -1106,12 +1116,21 @@
1106
1116
  '<span class="session-count" id="session-count">' + String(state.sessions.length) + '</span>' +
1107
1117
  '</div>' +
1108
1118
  '<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>' +
1119
+ '<div class="sidebar-header-more">' +
1120
+ '<button id="sidebar-more-btn" class="btn btn-ghost btn-sm" type="button" title="更多操作">' +
1121
+ '<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>' +
1122
+ '</button>' +
1123
+ '<div class="sidebar-header-overflow" id="sidebar-overflow-menu">' +
1124
+ '<button class="overflow-item" id="sidebar-home-btn" type="button">' +
1125
+ '<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>' +
1126
+ '<span>回到首页</span>' +
1127
+ '</button>' +
1128
+ '<button class="overflow-item" id="sidebar-refresh-btn" type="button">' +
1129
+ '<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>' +
1130
+ '<span>刷新页面</span>' +
1131
+ '</button>' +
1132
+ '</div>' +
1133
+ '</div>' +
1115
1134
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1116
1135
  '<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
1136
  '</button>' +
@@ -1126,15 +1145,21 @@
1126
1145
  '<div class="sidebar-footer">' +
1127
1146
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
1128
1147
  '<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>' +
1148
+ '<button id="file-panel-toggle-btn" class="btn btn-ghost btn-sm' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">' +
1149
+ '<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>' +
1150
+ '<span>文件</span>' +
1151
+ '</button>' +
1130
1152
  '<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> 设置' +
1153
+ '<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>' +
1154
+ '<span>设置</span>' +
1132
1155
  '</button>' +
1133
1156
  '<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> 安装' +
1157
+ '<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>' +
1158
+ '<span>安装</span>' +
1135
1159
  '</button>' +
1136
1160
  '<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> 退出' +
1161
+ '<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>' +
1162
+ '<span>退出</span>' +
1138
1163
  '</button>' +
1139
1164
  '</div>' +
1140
1165
  '</div>' +
@@ -1226,11 +1251,19 @@
1226
1251
  '</div>' +
1227
1252
  '<div class="input-composer">' +
1228
1253
  '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1254
+ '<div id="attachment-preview" class="attachment-preview hidden"></div>' +
1229
1255
  '<div class="input-composer-bar">' +
1230
1256
  '<div class="input-composer-left">' +
1257
+ '<button id="attach-btn" class="btn-circle btn-circle-attach" type="button" title="附加文件">' +
1258
+ '<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>' +
1259
+ '</button>' +
1260
+ '<input type="file" id="file-upload-input" multiple style="display:none">' +
1231
1261
  '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
1232
1262
  renderModeOptions(preferredTool, composerMode) +
1233
1263
  '</select>' +
1264
+ '<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1265
+ renderChatModelOptions(getEffectiveModel(selectedSession)) +
1266
+ '</select>' +
1234
1267
  '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1235
1268
  '<span class="permission-actions hidden" id="permission-actions">' +
1236
1269
  '<span class="permission-actions-divider"></span>' +
@@ -1387,6 +1420,14 @@
1387
1420
  '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
1388
1421
  '</div>' +
1389
1422
  '<p id="update-message" class="hint hidden"></p>' +
1423
+ '<div class="settings-auto-update-row" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1424
+ '<span class="settings-label">自动更新</span>' +
1425
+ '<label style="position:relative;cursor:pointer">' +
1426
+ '<input type="checkbox" id="auto-update-web-toggle" class="switch-toggle">' +
1427
+ '<span class="switch-slider"></span>' +
1428
+ '</label>' +
1429
+ '</div>' +
1430
+ '<p class="hint" style="margin-top:2px">开启后,检测到新版本将自动下载安装并重启服务。</p>' +
1390
1431
  '</div>' +
1391
1432
  '<div class="settings-update-section" id="android-apk-section">' +
1392
1433
  '<div id="android-apk-current-row" class="settings-about-row hidden">' +
@@ -1403,6 +1444,14 @@
1403
1444
  '<span class="settings-value" id="settings-android-apk-local" style="flex:1">-</span>' +
1404
1445
  '<button id="download-local-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1405
1446
  '</div>' +
1447
+ '<div id="android-auto-update-row" class="hidden" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1448
+ '<span class="settings-label">自动更新</span>' +
1449
+ '<label style="position:relative;cursor:pointer">' +
1450
+ '<input type="checkbox" id="auto-update-apk-toggle" class="switch-toggle">' +
1451
+ '<span class="switch-slider"></span>' +
1452
+ '</label>' +
1453
+ '</div>' +
1454
+ '<p id="android-auto-update-hint" class="hint hidden" style="margin-top:2px">开启后,检测到新版 APK 将自动下载安装。</p>' +
1406
1455
  '<p id="android-apk-message" class="hint hidden"></p>' +
1407
1456
  '</div>' +
1408
1457
  '<div class="settings-update-section" id="android-connect-section">' +
@@ -1516,6 +1565,16 @@
1516
1565
  '</div>' +
1517
1566
  '</div>' +
1518
1567
  '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
1568
+ '<div class="field">' +
1569
+ '<label class="field-label" for="cfg-default-model">默认模型</label>' +
1570
+ '<div style="display:flex;gap:8px;align-items:center;">' +
1571
+ '<select id="cfg-default-model" class="field-input" style="flex:1;">' +
1572
+ '<option value="">跟随 Claude Code 默认</option>' +
1573
+ '</select>' +
1574
+ '<button type="button" id="cfg-default-model-refresh" class="btn btn-ghost btn-sm" title="刷新模型列表">刷新</button>' +
1575
+ '</div>' +
1576
+ '<p class="field-hint" id="cfg-default-model-version" style="margin-top:4px;">新建会话时默认使用该模型;运行中的会话可在输入框切换。</p>' +
1577
+ '</div>' +
1519
1578
  '<div class="field">' +
1520
1579
  '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
1521
1580
  '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
@@ -1675,7 +1734,7 @@
1675
1734
  groups.push(renderRecentGroup(activeSessions, recentHistorySessions));
1676
1735
  }
1677
1736
  if (archivedSessions.length > 0) {
1678
- groups.push(renderSessionGroup("已归档", archivedSessions, "sessions"));
1737
+ groups.push(renderArchivedGroup(archivedSessions));
1679
1738
  }
1680
1739
  groups.push(renderClaudeHistorySection());
1681
1740
  if (activeSessions.length === 0 && archivedSessions.length === 0 && recentHistorySessions.length === 0) {
@@ -1696,12 +1755,17 @@
1696
1755
  var historyCount = getSelectedClaudeHistoryIds().length;
1697
1756
  var totalCount = sessionCount + historyCount;
1698
1757
  var hasAny = totalCount > 0;
1758
+ var selectable = countSelectableItems();
1759
+ var allSelected = selectable > 0 && totalCount >= selectable;
1760
+ var selectAllLabel = allSelected ? "取消全选" : "全选";
1761
+ var selectAllAction = allSelected ? "clear-selection" : "select-all-visible";
1762
+ var selectAllDisabled = selectable === 0 ? ' disabled' : '';
1699
1763
 
1700
1764
  return '<div class="session-manage-bar active">' +
1701
1765
  '<div class="session-manage-summary">已选择 ' + totalCount + ' 项</div>' +
1702
1766
  '<div class="session-manage-actions">' +
1703
- '<button class="session-manage-btn" data-action="select-all-visible" type="button">全选</button>' +
1704
- '<button class="session-manage-btn" data-action="clear-selection" type="button">清空</button>' +
1767
+ '<button class="session-manage-btn" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
1768
+ '<button class="session-manage-btn" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
1705
1769
  '<button class="session-manage-btn danger" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除所选</button>' +
1706
1770
  '<button class="session-manage-btn" data-action="toggle-manage-mode" type="button">完成</button>' +
1707
1771
  '</div>' +
@@ -1715,6 +1779,20 @@
1715
1779
  '</section>';
1716
1780
  }
1717
1781
 
1782
+ function renderArchivedGroup(archivedSessions) {
1783
+ var expanded = !!state.archivedExpanded;
1784
+ var chevron = expanded ? "&#9662;" : "&#9656;";
1785
+ var header = '<div class="session-group-title claude-history-toggle" data-action="toggle-archived-group">' +
1786
+ '<span class="chevron">' + chevron + '</span> 已归档 ' +
1787
+ '<span class="history-count">' + archivedSessions.length + '</span>' +
1788
+ '</div>';
1789
+ if (!expanded) {
1790
+ return '<section class="session-group">' + header + '</section>';
1791
+ }
1792
+ var items = archivedSessions.map(function(session) { return renderSessionItem(session, "sessions"); }).join("");
1793
+ return '<section class="session-group">' + header + items + '</section>';
1794
+ }
1795
+
1718
1796
  function renderRecentGroup(activeSessions, recentHistorySessions) {
1719
1797
  var html = '<section class="session-group">' +
1720
1798
  '<div class="session-group-title">最近</div>';
@@ -1806,11 +1884,19 @@
1806
1884
  updateSessionsList();
1807
1885
  }
1808
1886
 
1887
+ function getSelectableSessions() {
1888
+ return state.sessions.filter(function(session) {
1889
+ return session.archived || !session.resumedToSessionId;
1890
+ });
1891
+ }
1892
+
1893
+ function countSelectableItems() {
1894
+ return getSelectableSessions().length + getVisibleClaudeHistorySessions().length;
1895
+ }
1896
+
1809
1897
  function selectAllVisibleItems() {
1810
1898
  var nextSessionIds = {};
1811
- state.sessions.filter(function(session) {
1812
- return !session.resumedToSessionId;
1813
- }).forEach(function(session) {
1899
+ getSelectableSessions().forEach(function(session) {
1814
1900
  nextSessionIds[session.id] = true;
1815
1901
  });
1816
1902
  var nextHistoryIds = {};
@@ -2078,19 +2164,18 @@
2078
2164
  } catch (e) {}
2079
2165
  applyTerminalScale();
2080
2166
  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
2167
  }
2086
2168
 
2087
2169
  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";
2170
+ if (!state.terminal || !state.terminal.element) return;
2171
+ var newSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
2172
+ var newRowHeight = (state.terminalBaseFontSize * state.terminalScale * 1.5) + "px";
2173
+ state.terminal.element.style.setProperty("--term-font-size", newSize);
2174
+ state.terminal.element.style.setProperty("--term-row-height", newRowHeight);
2175
+ if (typeof state.terminal.remeasure === "function") {
2176
+ requestAnimationFrame(function() {
2177
+ if (state.terminal) state.terminal.remeasure();
2178
+ });
2094
2179
  }
2095
2180
  }
2096
2181
 
@@ -2450,15 +2535,8 @@
2450
2535
  // Ordered lists
2451
2536
  escaped = escaped.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>');
2452
2537
 
2453
- // Tables
2454
- escaped = escaped.replace(/\|(.+)\|/g, function(match) {
2455
- var cells = match.split("|").slice(1, -1);
2456
- if (cells.every(function(c) { return /^[\-:]+$/.test(c.trim()); })) {
2457
- return "";
2458
- }
2459
- return '<tr>' + cells.map(function(c) { return '<td>' + c.trim() + '</td>'; }).join("") + '</tr>';
2460
- });
2461
- escaped = escaped.replace(/(<tr>.*<\/tr>\n?)+/g, '<table>$&</table>');
2538
+ // Tables (GFM)
2539
+ escaped = parseMarkdownTables(escaped);
2462
2540
 
2463
2541
  // Paragraphs
2464
2542
  var paragraphs = escaped.split(/\n{2,}/);
@@ -3217,6 +3295,17 @@
3217
3295
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
3218
3296
  var pinBtn = document.getElementById("sidebar-pin-btn");
3219
3297
  if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
3298
+ var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
3299
+ var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
3300
+ if (sidebarMoreBtn && sidebarOverflow) {
3301
+ sidebarMoreBtn.addEventListener("click", function(e) {
3302
+ e.stopPropagation();
3303
+ sidebarOverflow.classList.toggle("open");
3304
+ });
3305
+ document.addEventListener("click", function() {
3306
+ sidebarOverflow.classList.remove("open");
3307
+ });
3308
+ }
3220
3309
  var homeBtn = document.getElementById("sidebar-home-btn");
3221
3310
  if (homeBtn) homeBtn.addEventListener("click", function() {
3222
3311
  state.selectedId = null;
@@ -3245,11 +3334,15 @@
3245
3334
  var settingsTabs = document.querySelectorAll(".settings-tab");
3246
3335
  for (var ti = 0; ti < settingsTabs.length; ti++) {
3247
3336
  settingsTabs[ti].addEventListener("click", function(e) {
3248
- switchSettingsTab(e.target.getAttribute("data-tab"));
3337
+ var btn = e.currentTarget || this;
3338
+ var tabName = btn && btn.getAttribute ? btn.getAttribute("data-tab") : null;
3339
+ if (tabName) switchSettingsTab(tabName);
3249
3340
  });
3250
3341
  }
3251
3342
  var saveConfigBtn = document.getElementById("save-config-button");
3252
3343
  if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
3344
+ var defaultModelRefreshBtn = document.getElementById("cfg-default-model-refresh");
3345
+ if (defaultModelRefreshBtn) defaultModelRefreshBtn.addEventListener("click", refreshAvailableModels);
3253
3346
  var saveDisplayBtn = document.getElementById("save-display-button");
3254
3347
  if (saveDisplayBtn) saveDisplayBtn.addEventListener("click", saveDisplaySettings);
3255
3348
  // App icon picker (APK only)
@@ -3282,6 +3375,14 @@
3282
3375
  if (doUpdateBtn) doUpdateBtn.addEventListener("click", performUpdate);
3283
3376
  var doRestartBtn = document.getElementById("do-restart-button");
3284
3377
  if (doRestartBtn) doRestartBtn.addEventListener("click", performSettingsRestart);
3378
+ var autoUpdateWebToggle = document.getElementById("auto-update-web-toggle");
3379
+ if (autoUpdateWebToggle) autoUpdateWebToggle.addEventListener("change", function() {
3380
+ toggleAutoUpdate("web", autoUpdateWebToggle.checked);
3381
+ });
3382
+ var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
3383
+ if (autoUpdateApkToggle) autoUpdateApkToggle.addEventListener("change", function() {
3384
+ toggleAutoUpdate("apk", autoUpdateApkToggle.checked);
3385
+ });
3285
3386
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
3286
3387
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
3287
3388
  var text = document.getElementById("android-connect-code");
@@ -3414,6 +3515,10 @@
3414
3515
  state.chatMode = this.value;
3415
3516
  showToast("新会话模式已切换为:" + getModeLabel(this.value), "info");
3416
3517
  });
3518
+ var modelSelect = document.getElementById("chat-model-select");
3519
+ if (modelSelect) modelSelect.addEventListener("change", function() {
3520
+ onChatModelChange(this.value);
3521
+ });
3417
3522
 
3418
3523
  var sessionModal = document.getElementById("session-modal");
3419
3524
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
@@ -3441,6 +3546,42 @@
3441
3546
  inputBox.addEventListener("blur", handleInputBoxBlur);
3442
3547
  }
3443
3548
 
3549
+ // Attach button & drag-drop
3550
+ var attachBtn = document.getElementById("attach-btn");
3551
+ var fileInput = document.getElementById("file-upload-input");
3552
+ if (attachBtn && fileInput) {
3553
+ attachBtn.addEventListener("click", function() { fileInput.click(); });
3554
+ fileInput.addEventListener("change", function() {
3555
+ var files = fileInput.files;
3556
+ if (files) {
3557
+ for (var i = 0; i < files.length; i++) addPendingAttachment(files[i]);
3558
+ }
3559
+ fileInput.value = "";
3560
+ });
3561
+ }
3562
+ var composer = document.querySelector(".input-composer");
3563
+ if (composer) {
3564
+ composer.addEventListener("dragover", function(e) {
3565
+ e.preventDefault();
3566
+ e.stopPropagation();
3567
+ composer.classList.add("drag-over");
3568
+ });
3569
+ composer.addEventListener("dragleave", function(e) {
3570
+ e.preventDefault();
3571
+ e.stopPropagation();
3572
+ composer.classList.remove("drag-over");
3573
+ });
3574
+ composer.addEventListener("drop", function(e) {
3575
+ e.preventDefault();
3576
+ e.stopPropagation();
3577
+ composer.classList.remove("drag-over");
3578
+ var files = e.dataTransfer && e.dataTransfer.files;
3579
+ if (files) {
3580
+ for (var i = 0; i < files.length; i++) addPendingAttachment(files[i]);
3581
+ }
3582
+ });
3583
+ }
3584
+
3444
3585
  // Terminal interactive toggle (both topbar and terminal-header)
3445
3586
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
3446
3587
  terminalInteractiveToggles.forEach(function(id) {
@@ -3515,7 +3656,16 @@
3515
3656
  if (scaleDownBtn) scaleDownBtn.addEventListener("click", function() { adjustTerminalScale(-0.25); });
3516
3657
  if (scaleUpBtn) scaleUpBtn.addEventListener("click", function() { adjustTerminalScale(0.25); });
3517
3658
  var pageRefreshBtn = document.getElementById("page-refresh-btn");
3518
- if (pageRefreshBtn) pageRefreshBtn.addEventListener("click", function() { location.reload(); });
3659
+ if (pageRefreshBtn) pageRefreshBtn.addEventListener("click", function(ev) {
3660
+ // Soft refresh: replay terminal buffer + rebuild chat view.
3661
+ // Fixes residual DOM from CSI cursor-jump sequences without losing page state.
3662
+ // Hold Shift to force a full page reload as an escape hatch.
3663
+ if (ev && ev.shiftKey) {
3664
+ location.reload();
3665
+ return;
3666
+ }
3667
+ softRefreshCurrentView();
3668
+ });
3519
3669
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
3520
3670
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
3521
3671
  maybeScrollTerminalToBottom("force");
@@ -4005,6 +4155,9 @@
4005
4155
  }
4006
4156
  } else if (actionButton.dataset.action === "clear-all-history") {
4007
4157
  clearAllClaudeHistory();
4158
+ } else if (actionButton.dataset.action === "toggle-archived-group") {
4159
+ state.archivedExpanded = !state.archivedExpanded;
4160
+ updateSessionsList();
4008
4161
  } else if (actionButton.dataset.action === "resume" && actionButton.dataset.sessionId) {
4009
4162
  handleResumeAction(actionButton);
4010
4163
  } else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
@@ -4107,10 +4260,7 @@
4107
4260
 
4108
4261
  function getTerminalViewport() {
4109
4262
  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");
4263
+ state.terminalViewportEl = state.terminal.element;
4114
4264
  return state.terminalViewportEl;
4115
4265
  }
4116
4266
 
@@ -4136,11 +4286,6 @@
4136
4286
  }
4137
4287
 
4138
4288
  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
4289
  var viewport = getTerminalViewport();
4145
4290
  if (!viewport) return true;
4146
4291
  var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
@@ -4149,25 +4294,13 @@
4149
4294
 
4150
4295
  function scrollTerminalToBottom(smooth) {
4151
4296
  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
- }
4297
+ var viewport = getTerminalViewport();
4298
+ if (!viewport) return;
4160
4299
  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
- }
4300
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
4301
+ } else {
4302
+ viewport.scrollTop = viewport.scrollHeight;
4169
4303
  }
4170
- state.terminal.scrollToBottom();
4171
4304
  }
4172
4305
 
4173
4306
  function scheduleTerminalResumeFollow() {
@@ -4388,6 +4521,43 @@
4388
4521
  requestSyncScrollbar();
4389
4522
  }
4390
4523
 
4524
+ function resetTerminal() {
4525
+ if (!state.terminal || typeof state.terminal.reset !== "function") return;
4526
+ state.terminal.reset();
4527
+ }
4528
+
4529
+ // Soft resync terminal: reset WASM grid and replay full output buffer.
4530
+ // Clears any stale DOM rows left over from CSI cursor-jump sequences
4531
+ // (e.g. Claude permission menus redrawing in place while user holds arrow keys).
4532
+ function softResyncTerminal() {
4533
+ if (!state.terminal || !state.terminalOutput) return false;
4534
+ resetTerminal();
4535
+ state.terminal.write(state.terminalOutput);
4536
+ state.lastTerminalResyncAt = Date.now();
4537
+ maybeScrollTerminalToBottom("output");
4538
+ return true;
4539
+ }
4540
+
4541
+ // Soft refresh the whole current view without losing page state:
4542
+ // - Replays terminal buffer to clear residue
4543
+ // - Clears chat render cache and forces a full rebuild
4544
+ // Used by the refresh button and by automatic triggers
4545
+ // (e.g. permission escalation appearing/disappearing).
4546
+ function softRefreshCurrentView() {
4547
+ softResyncTerminal();
4548
+ if (typeof resetChatRenderCache === "function") resetChatRenderCache();
4549
+ if (typeof scheduleChatRender === "function") scheduleChatRender(true);
4550
+ else if (typeof render === "function") render();
4551
+ }
4552
+
4553
+ function scheduleSoftResyncTerminal(delayMs) {
4554
+ if (state.softResyncTimer) clearTimeout(state.softResyncTimer);
4555
+ state.softResyncTimer = setTimeout(function() {
4556
+ state.softResyncTimer = null;
4557
+ softResyncTerminal();
4558
+ }, typeof delayMs === "number" ? delayMs : 150);
4559
+ }
4560
+
4391
4561
  function syncTerminalBuffer(sessionId, output, options) {
4392
4562
  if (!state.terminal) return false;
4393
4563
  var normalizedOutput = normalizeTerminalOutput(output || "");
@@ -4407,7 +4577,7 @@
4407
4577
  }
4408
4578
 
4409
4579
  if (sessionChanged) {
4410
- state.terminal.reset();
4580
+ resetTerminal();
4411
4581
  currentOutput = "";
4412
4582
  state.terminalOutput = "";
4413
4583
  state.terminalAutoFollow = true;
@@ -4417,18 +4587,15 @@
4417
4587
 
4418
4588
  if (mode === "replace") {
4419
4589
  if (normalizedOutput !== currentOutput) {
4420
- state.terminal.reset();
4590
+ resetTerminal();
4421
4591
  if (normalizedOutput) {
4422
4592
  state.terminal.write(normalizedOutput);
4423
4593
  }
4424
4594
  wrote = true;
4425
4595
  }
4426
4596
  } else if (normalizedOutput.length < currentOutput.length && !sessionChanged) {
4427
- // Ignore regressive snapshots for the active session; wait for an explicit replace.
4428
4597
  return false;
4429
4598
  } 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
4599
  return false;
4433
4600
  } else if (normalizedOutput.startsWith(currentOutput)) {
4434
4601
  var delta = normalizedOutput.slice(currentOutput.length);
@@ -4437,10 +4604,9 @@
4437
4604
  wrote = true;
4438
4605
  }
4439
4606
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
4440
- // Ignore shorter/stale snapshots from polling or reconnect races.
4441
4607
  return false;
4442
4608
  } else {
4443
- state.terminal.reset();
4609
+ resetTerminal();
4444
4610
  if (normalizedOutput) {
4445
4611
  state.terminal.write(normalizedOutput);
4446
4612
  }
@@ -4449,40 +4615,21 @@
4449
4615
 
4450
4616
  state.terminalSessionId = nextSessionId;
4451
4617
  state.terminalOutput = normalizedOutput;
4452
- scheduleMobileDomUpdate();
4453
4618
  if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
4454
4619
  maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
4455
4620
  } else {
4456
4621
  updateTerminalJumpToBottomButton();
4457
4622
  }
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);
4623
+ if (sessionChanged) {
4624
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
4463
4625
  }
4464
4626
  return wrote || sessionChanged;
4465
4627
  }
4466
4628
 
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
4629
  function initTerminal() {
4482
4630
  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
4631
+ if (!container || state.terminal || state.terminalInitializing) return;
4632
+ if (typeof WTermLib === "undefined" || !WTermLib.WTerm) {
4486
4633
  if (!state.terminalInitRetries) state.terminalInitRetries = 0;
4487
4634
  if (state.terminalInitRetries < 10) {
4488
4635
  state.terminalInitRetries++;
@@ -4491,160 +4638,80 @@
4491
4638
  return;
4492
4639
  }
4493
4640
  state.terminalInitRetries = 0;
4641
+ state.terminalInitializing = true;
4642
+
4643
+ var termWrap = document.createElement("div");
4644
+ termWrap.className = "terminal-scroll-wrap";
4645
+ container.appendChild(termWrap);
4494
4646
 
4495
- state.terminal = new Terminal({
4647
+ var term = new WTermLib.WTerm(termWrap, {
4496
4648
  cols: 120,
4497
4649
  rows: 36,
4498
- convertEol: true,
4499
- disableStdin: false,
4650
+ autoResize: true,
4500
4651
  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"
4652
+ onData: function(data) {
4653
+ if (state.terminalInteractive) return;
4654
+ queueDirectInput(data);
4655
+ },
4656
+ onResize: function(cols, rows) {
4657
+ sendTerminalResize(cols, rows);
4528
4658
  }
4529
4659
  });
4530
4660
 
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
- }
4661
+ term.init().then(function() {
4662
+ state.terminal = term;
4663
+ state.terminalInitializing = false;
4664
+ applyTerminalScale();
4665
+ state.terminalAutoFollow = true;
4666
+ clearTerminalScrollIdleTimer();
4567
4667
 
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
- });
4668
+ var viewport = getTerminalViewport();
4669
+ if (viewport) {
4670
+ state.terminalViewportScrollHandler = function() {
4671
+ if (isTerminalNearBottom()) {
4672
+ state.terminalAutoFollow = true;
4673
+ clearTerminalScrollIdleTimer();
4674
+ updateTerminalJumpToBottomButton();
4675
+ return;
4676
+ }
4677
+ setTerminalManualScrollActive();
4678
+ };
4679
+ state.terminalViewportTouchHandler = function() {
4680
+ setTerminalManualScrollActive();
4681
+ };
4682
+ viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
4683
+ viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
4585
4684
  }
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
4685
 
4596
- var viewport = getTerminalViewport();
4597
- if (viewport) {
4598
- state.terminalViewportScrollHandler = function() {
4599
- if (isTerminalNearBottom()) {
4600
- state.terminalAutoFollow = true;
4601
- clearTerminalScrollIdleTimer();
4602
- updateTerminalJumpToBottomButton();
4603
- return;
4686
+ state.terminalWheelHandler = function(e) {
4687
+ if (!isTerminalNearBottom() || e.deltaY < 0) {
4688
+ setTerminalManualScrollActive();
4604
4689
  }
4605
- setTerminalManualScrollActive();
4606
- };
4607
- state.terminalViewportTouchHandler = function() {
4608
- setTerminalManualScrollActive();
4690
+ e.stopPropagation();
4609
4691
  };
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 });
4692
+ container.addEventListener("wheel", state.terminalWheelHandler, { passive: true });
4620
4693
 
4621
- // Create custom scrollbar overlay
4622
- initTerminalScrollbar(container);
4694
+ initTerminalScrollbar(container);
4623
4695
 
4624
- // Terminal copy button for mobile
4625
- initMobileDomTerminal(container);
4626
-
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 });
4696
+ if (state.selectedId) {
4697
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
4698
+ if (session) {
4699
+ syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4700
+ }
4701
+ } else {
4702
+ term.write("点击上方「新对话」开始你的第一次对话。\r\n");
4631
4703
  }
4632
- } else {
4633
- state.terminal.writeln("点击上方「新对话」开始你的第一次对话。");
4634
- }
4635
4704
 
4636
- state.terminal.onData(function(data) {
4637
- if (state.terminalInteractive) return;
4638
- queueDirectInput(data);
4705
+ state.terminalClickHandler = focusInputBox;
4706
+ container.addEventListener("click", state.terminalClickHandler);
4707
+ updateTerminalJumpToBottomButton();
4708
+ initTerminalResizeHandle();
4709
+ observeTerminalResize();
4710
+ startTerminalHealthCheck();
4711
+ }).catch(function(err) {
4712
+ state.terminalInitializing = false;
4713
+ console.error("[wand] wterm init failed:", err);
4639
4714
  });
4640
-
4641
- container.addEventListener("click", focusInputBox);
4642
- updateTerminalJumpToBottomButton();
4643
-
4644
- // 初始化拖动调整大小
4645
- initTerminalResizeHandle();
4646
-
4647
- observeTerminalResize();
4648
4715
  }
4649
4716
 
4650
4717
  function login() {
@@ -4869,13 +4936,138 @@
4869
4936
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
4870
4937
  }
4871
4938
 
4939
+ function getEffectiveModel(session) {
4940
+ if (session && session.selectedModel) return session.selectedModel;
4941
+ if (state.chatModel) return state.chatModel;
4942
+ if (state.config && state.config.defaultModel) return state.config.defaultModel;
4943
+ return "";
4944
+ }
4945
+
4946
+ function renderChatModelOptions(selected) {
4947
+ var models = state.availableModels || [];
4948
+ var html = '<option value="">默认(跟随设置)</option>';
4949
+ for (var i = 0; i < models.length; i++) {
4950
+ var m = models[i];
4951
+ var label = m.label || m.id;
4952
+ html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
4953
+ }
4954
+ // If selected is unknown (custom value), prepend it as a sticky option
4955
+ if (selected && !models.some(function(m) { return m.id === selected; })) {
4956
+ html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
4957
+ }
4958
+ return html;
4959
+ }
4960
+
4961
+ function syncComposerModelSelect(session) {
4962
+ var select = document.getElementById("chat-model-select");
4963
+ if (!select) return;
4964
+ var effective = getEffectiveModel(session);
4965
+ select.innerHTML = renderChatModelOptions(effective);
4966
+ select.value = effective;
4967
+ }
4968
+
4969
+ function fetchAvailableModels() {
4970
+ return fetch("/api/models", { credentials: "same-origin" })
4971
+ .then(function(res) { return res.json(); })
4972
+ .then(function(data) {
4973
+ if (data && Array.isArray(data.models)) {
4974
+ state.availableModels = data.models;
4975
+ syncComposerModelSelect(getSelectedSession());
4976
+ updateSettingsDefaultModelSelect(data);
4977
+ }
4978
+ return data;
4979
+ })
4980
+ .catch(function() { return null; });
4981
+ }
4982
+
4983
+ function refreshAvailableModels() {
4984
+ if (state.modelsRefreshing) return Promise.resolve(null);
4985
+ state.modelsRefreshing = true;
4986
+ var btn = document.getElementById("cfg-default-model-refresh");
4987
+ if (btn) { btn.disabled = true; btn.textContent = "刷新中..."; }
4988
+ return fetch("/api/models/refresh", { method: "POST", credentials: "same-origin" })
4989
+ .then(function(res) { return res.json(); })
4990
+ .then(function(data) {
4991
+ if (data && Array.isArray(data.models)) {
4992
+ state.availableModels = data.models;
4993
+ syncComposerModelSelect(getSelectedSession());
4994
+ updateSettingsDefaultModelSelect(data);
4995
+ if (typeof showToast === "function") {
4996
+ showToast("模型列表已刷新" + (data.claudeVersion ? "(claude " + data.claudeVersion + ")" : ""), "success");
4997
+ }
4998
+ }
4999
+ return data;
5000
+ })
5001
+ .catch(function() {
5002
+ if (typeof showToast === "function") showToast("刷新模型列表失败", "error");
5003
+ return null;
5004
+ })
5005
+ .finally(function() {
5006
+ state.modelsRefreshing = false;
5007
+ if (btn) { btn.disabled = false; btn.textContent = "刷新"; }
5008
+ });
5009
+ }
5010
+
5011
+ function updateSettingsDefaultModelSelect(data) {
5012
+ var select = document.getElementById("cfg-default-model");
5013
+ if (!select) return;
5014
+ var previous = select.value;
5015
+ var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
5016
+ select.innerHTML = renderChatModelOptions(current);
5017
+ select.value = current;
5018
+ var versionEl = document.getElementById("cfg-default-model-version");
5019
+ if (versionEl && data) {
5020
+ versionEl.textContent = data.claudeVersion ? "已检测到 claude " + data.claudeVersion : "新建会话时默认使用该模型。";
5021
+ }
5022
+ }
5023
+
5024
+ function getSelectedSession() {
5025
+ if (!state.selectedId) return null;
5026
+ for (var i = 0; i < state.sessions.length; i++) {
5027
+ if (state.sessions[i].id === state.selectedId) return state.sessions[i];
5028
+ }
5029
+ return null;
5030
+ }
5031
+
5032
+ function onChatModelChange(value) {
5033
+ var normalized = (value || "").trim();
5034
+ state.chatModel = normalized;
5035
+ try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
5036
+ var session = getSelectedSession();
5037
+ if (!session) return;
5038
+ if (session.provider && session.provider !== "claude") return;
5039
+ fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
5040
+ method: "POST",
5041
+ headers: { "Content-Type": "application/json" },
5042
+ credentials: "same-origin",
5043
+ body: JSON.stringify({ model: normalized || null })
5044
+ })
5045
+ .then(function(res) { return res.json(); })
5046
+ .then(function(data) {
5047
+ if (data && data.error) {
5048
+ showToast(data.error, "error");
5049
+ return;
5050
+ }
5051
+ if (data && data.id) {
5052
+ updateSessionSnapshot(data);
5053
+ if (typeof showToast === "function") {
5054
+ var display = normalized || "默认";
5055
+ showToast("已切换模型 → " + display, "success");
5056
+ }
5057
+ }
5058
+ })
5059
+ .catch(function() { showToast("切换模型失败", "error"); });
5060
+ }
5061
+
4872
5062
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
5063
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
4873
5064
  var payload = {
4874
5065
  cwd: cwdOverride || getEffectiveCwd(),
4875
5066
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
4876
5067
  runner: state.structuredRunner || "claude-cli-print",
4877
5068
  prompt: prompt || undefined,
4878
- worktreeEnabled: worktreeEnabled === true
5069
+ worktreeEnabled: worktreeEnabled === true,
5070
+ model: modelPref || undefined
4879
5071
  };
4880
5072
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
4881
5073
  return fetch("/api/structured-sessions", {
@@ -5004,6 +5196,17 @@
5004
5196
  if (normalizedSnapshot.id === state.selectedId) {
5005
5197
  reconcileInteractiveState();
5006
5198
  updateTaskDisplay();
5199
+ // Escalation/permission toggles are the common trigger for CSI cursor-jump
5200
+ // redraw sequences from Claude CLI. When they appear or dismiss, schedule a
5201
+ // debounced terminal resync so residual DOM rows get cleaned up automatically
5202
+ // — same fix the user used to have to reach for via the refresh button.
5203
+ var prevEsc = prevSession && prevSession.pendingEscalation ? 1 : 0;
5204
+ var nextEsc = updatedSession && updatedSession.pendingEscalation ? 1 : 0;
5205
+ var prevBlocked = prevSession && prevSession.permissionBlocked ? 1 : 0;
5206
+ var nextBlocked = updatedSession && updatedSession.permissionBlocked ? 1 : 0;
5207
+ if (prevEsc !== nextEsc || prevBlocked !== nextBlocked) {
5208
+ scheduleSoftResyncTerminal(200);
5209
+ }
5007
5210
  }
5008
5211
  // When a session transitions to a non-running state, try flushing cross-session queue
5009
5212
  if (normalizedSnapshot.status && normalizedSnapshot.status !== "running" && state.crossSessionQueue.length > 0) {
@@ -5254,10 +5457,8 @@
5254
5457
  initTerminal();
5255
5458
  }
5256
5459
  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();
5460
+ teardownTerminal();
5461
+ initTerminal();
5261
5462
  }
5262
5463
 
5263
5464
  if (selectedSession && state.terminal) {
@@ -5286,6 +5487,7 @@
5286
5487
  if (inputPanel) inputPanel.classList.add("hidden");
5287
5488
  }
5288
5489
  syncComposerModeSelect();
5490
+ syncComposerModelSelect(getSelectedSession());
5289
5491
  applyCurrentView();
5290
5492
  reconcileInteractiveState();
5291
5493
  }
@@ -5316,6 +5518,15 @@
5316
5518
  updateSessionSnapshot(data);
5317
5519
  updateShellChrome();
5318
5520
 
5521
+ if (state.terminal && id === state.selectedId && data.output !== undefined) {
5522
+ syncTerminalBuffer(id, data.output, { mode: "append" });
5523
+ if (state.terminal.remeasure) {
5524
+ requestAnimationFrame(function() {
5525
+ if (state.terminal) state.terminal.remeasure();
5526
+ });
5527
+ }
5528
+ }
5529
+
5319
5530
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
5320
5531
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, data.output, false));
5321
5532
 
@@ -5944,6 +6155,13 @@
5944
6155
  }
5945
6156
  }
5946
6157
 
6158
+ // Auto-update toggles
6159
+ var autoUpdate = data.autoUpdate || {};
6160
+ var autoUpdateWebToggle = document.getElementById("auto-update-web-toggle");
6161
+ if (autoUpdateWebToggle) autoUpdateWebToggle.checked = !!autoUpdate.web;
6162
+ var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
6163
+ if (autoUpdateApkToggle) autoUpdateApkToggle.checked = !!autoUpdate.apk;
6164
+
5947
6165
  // ── Android APK version display ──
5948
6166
  var apkSection = document.getElementById("android-apk-section");
5949
6167
  var apkCurrentRow = document.getElementById("android-apk-current-row");
@@ -6005,6 +6223,11 @@
6005
6223
  apkMessageEl.textContent = "暂无可用更新";
6006
6224
  apkMessageEl.classList.remove("hidden");
6007
6225
  }
6226
+ // Show APK auto-update toggle in APK mode
6227
+ var apkAutoRow = document.getElementById("android-auto-update-row");
6228
+ var apkAutoHint = document.getElementById("android-auto-update-hint");
6229
+ if (apkAutoRow) apkAutoRow.classList.remove("hidden");
6230
+ if (apkAutoHint) apkAutoHint.classList.remove("hidden");
6008
6231
  } else {
6009
6232
  // ── 浏览器模式:显示线上版本 + 本地版本 + 下载按钮 ──
6010
6233
  if (androidApk.github && apkGithubRow && apkGithubEl) {
@@ -6067,6 +6290,13 @@
6067
6290
  var langEl = document.getElementById("cfg-language");
6068
6291
  if (langEl) langEl.value = cfg.language || "";
6069
6292
 
6293
+ // Default model
6294
+ state.configDefaultModel = cfg.defaultModel || "";
6295
+ updateSettingsDefaultModelSelect();
6296
+ fetchAvailableModels().then(function() {
6297
+ updateSettingsDefaultModelSelect();
6298
+ }).catch(function() {});
6299
+
6070
6300
  // Cert status
6071
6301
  var certStatus = document.getElementById("cert-status");
6072
6302
  if (certStatus) {
@@ -6117,8 +6347,12 @@
6117
6347
  defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
6118
6348
  shell: (document.getElementById("cfg-shell") || {}).value,
6119
6349
  language: (document.getElementById("cfg-language") || {}).value || "",
6350
+ defaultModel: (document.getElementById("cfg-default-model") || {}).value || "",
6120
6351
  };
6121
6352
 
6353
+ var previousDefaultModel = (state.config && state.config.defaultModel) || "";
6354
+ var nextDefaultModel = body.defaultModel || "";
6355
+
6122
6356
  fetch("/api/settings/config", {
6123
6357
  method: "POST",
6124
6358
  headers: { "Content-Type": "application/json" },
@@ -6137,6 +6371,15 @@
6137
6371
  }
6138
6372
  msgEl.classList.remove("hidden");
6139
6373
  }
6374
+ if (!data || !data.error) {
6375
+ if (state.config) state.config.defaultModel = nextDefaultModel;
6376
+ state.configDefaultModel = nextDefaultModel;
6377
+ if (nextDefaultModel !== previousDefaultModel) {
6378
+ state.chatModel = "";
6379
+ try { localStorage.removeItem("wand-chat-model"); } catch (e) {}
6380
+ syncComposerModelSelect(getSelectedSession());
6381
+ }
6382
+ }
6140
6383
  })
6141
6384
  .catch(function() {
6142
6385
  if (msgEl) {
@@ -6330,6 +6573,48 @@
6330
6573
  performRestart(restartBtn, msgEl);
6331
6574
  }
6332
6575
 
6576
+ function checkApkAutoUpdate() {
6577
+ fetch("/api/auto-update", { credentials: "same-origin" })
6578
+ .then(function(res) { return res.json(); })
6579
+ .then(function(autoData) {
6580
+ if (!autoData.apk) return;
6581
+ // Auto-update is enabled, check for APK update
6582
+ return fetch("/api/android-apk-update?currentVersion=" + encodeURIComponent(_apkVersion), { credentials: "same-origin" })
6583
+ .then(function(res) { return res.json(); })
6584
+ .then(function(data) {
6585
+ if (!data.updateAvailable || !data.downloadUrl) return;
6586
+ try {
6587
+ WandNative.downloadUpdate(data.downloadUrl, data.fileName || "wand-update.apk", data.source || "local");
6588
+ } catch (_e) {}
6589
+ });
6590
+ })
6591
+ .catch(function() {});
6592
+ }
6593
+
6594
+ function toggleAutoUpdate(type, enabled) {
6595
+ var body = {};
6596
+ body[type] = enabled;
6597
+ fetch("/api/auto-update", {
6598
+ method: "POST",
6599
+ headers: { "Content-Type": "application/json" },
6600
+ credentials: "same-origin",
6601
+ body: JSON.stringify(body),
6602
+ })
6603
+ .then(function(res) { return res.json(); })
6604
+ .then(function(data) {
6605
+ // Sync toggle state with server response
6606
+ var webToggle = document.getElementById("auto-update-web-toggle");
6607
+ var apkToggle = document.getElementById("auto-update-apk-toggle");
6608
+ if (webToggle) webToggle.checked = !!data.web;
6609
+ if (apkToggle) apkToggle.checked = !!data.apk;
6610
+ })
6611
+ .catch(function() {
6612
+ // Revert toggle on failure
6613
+ var toggle = document.getElementById("auto-update-" + type + "-toggle");
6614
+ if (toggle) toggle.checked = !enabled;
6615
+ });
6616
+ }
6617
+
6333
6618
  // ── Notification Settings Helpers ──
6334
6619
 
6335
6620
  function _updateAppIconSelection(activeIcon) {
@@ -6639,6 +6924,7 @@
6639
6924
  state.sessionTool = "claude";
6640
6925
  state.preferredCommand = "claude";
6641
6926
  syncComposerModeSelect();
6927
+ syncComposerModelSelect(getSelectedSession());
6642
6928
  return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
6643
6929
  .then(function(data) {
6644
6930
  saveWorkingDir(cwd);
@@ -6989,6 +7275,107 @@
6989
7275
  }
6990
7276
  }
6991
7277
 
7278
+ // ── Attachment helpers ──
7279
+
7280
+ var ATTACH_MAX_SIZE = 10 * 1024 * 1024;
7281
+
7282
+ function formatFileSize(bytes) {
7283
+ if (bytes < 1024) return bytes + " B";
7284
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
7285
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
7286
+ }
7287
+
7288
+ function isImageType(type) {
7289
+ return /^image\/(png|jpe?g|gif|webp|bmp|svg\+xml)/.test(type);
7290
+ }
7291
+
7292
+ function addPendingAttachment(file) {
7293
+ if (!file) return;
7294
+ if (file.size > ATTACH_MAX_SIZE) {
7295
+ showToast("文件过大(上限 10 MB): " + file.name, "error");
7296
+ return;
7297
+ }
7298
+ var entry = { file: file, name: file.name, size: file.size, previewUrl: null };
7299
+ if (isImageType(file.type)) {
7300
+ entry.previewUrl = URL.createObjectURL(file);
7301
+ }
7302
+ state.pendingAttachments.push(entry);
7303
+ renderAttachmentPreview();
7304
+ }
7305
+
7306
+ function removePendingAttachment(index) {
7307
+ var removed = state.pendingAttachments.splice(index, 1);
7308
+ if (removed.length && removed[0].previewUrl) {
7309
+ URL.revokeObjectURL(removed[0].previewUrl);
7310
+ }
7311
+ renderAttachmentPreview();
7312
+ }
7313
+
7314
+ function clearAttachments() {
7315
+ state.pendingAttachments.forEach(function(a) {
7316
+ if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
7317
+ });
7318
+ state.pendingAttachments = [];
7319
+ renderAttachmentPreview();
7320
+ }
7321
+
7322
+ function renderAttachmentPreview() {
7323
+ var bar = document.getElementById("attachment-preview");
7324
+ if (!bar) return;
7325
+ var items = state.pendingAttachments;
7326
+ if (items.length === 0) {
7327
+ bar.classList.add("hidden");
7328
+ bar.innerHTML = "";
7329
+ return;
7330
+ }
7331
+ bar.classList.remove("hidden");
7332
+ var html = "";
7333
+ for (var i = 0; i < items.length; i++) {
7334
+ var a = items[i];
7335
+ var thumb = a.previewUrl
7336
+ ? '<img src="' + escapeHtml(a.previewUrl) + '" alt="">'
7337
+ : '<span class="att-icon">📄</span>';
7338
+ html += '<span class="attachment-pill" data-index="' + i + '">' +
7339
+ thumb +
7340
+ '<span class="att-name" title="' + escapeHtml(a.name) + '">' + escapeHtml(a.name) + '</span>' +
7341
+ '<span class="att-size">' + formatFileSize(a.size) + '</span>' +
7342
+ '<button class="att-remove" data-index="' + i + '" title="移除">×</button>' +
7343
+ '</span>';
7344
+ }
7345
+ bar.innerHTML = html;
7346
+ bar.querySelectorAll(".att-remove").forEach(function(btn) {
7347
+ btn.addEventListener("click", function(e) {
7348
+ e.preventDefault();
7349
+ e.stopPropagation();
7350
+ removePendingAttachment(parseInt(btn.getAttribute("data-index"), 10));
7351
+ });
7352
+ });
7353
+ }
7354
+
7355
+ function uploadAttachments(sessionId) {
7356
+ if (!state.pendingAttachments.length) return Promise.resolve([]);
7357
+ var formData = new FormData();
7358
+ state.pendingAttachments.forEach(function(a) {
7359
+ formData.append("files", a.file, a.name);
7360
+ });
7361
+ return fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/upload", {
7362
+ method: "POST",
7363
+ body: formData,
7364
+ credentials: "same-origin"
7365
+ }).then(function(resp) {
7366
+ if (!resp.ok) return resp.json().then(function(e) { throw new Error(e.error || "上传失败"); });
7367
+ return resp.json();
7368
+ }).then(function(data) {
7369
+ return data.files || [];
7370
+ });
7371
+ }
7372
+
7373
+ function buildAttachmentPrefix(uploadedFiles) {
7374
+ if (!uploadedFiles || !uploadedFiles.length) return "";
7375
+ var paths = uploadedFiles.map(function(f) { return f.savedPath; });
7376
+ return "[附件已上传,请查看以下文件:\n" + paths.join("\n") + "]\n\n";
7377
+ }
7378
+
6992
7379
  function handleInteractiveTextInput(inputBox) {
6993
7380
  if (!state.terminalInteractive || !inputBox) return false;
6994
7381
  var value = inputBox.value || "";
@@ -7001,6 +7388,17 @@
7001
7388
  }
7002
7389
 
7003
7390
  function handleInputPaste(event) {
7391
+ var items = event.clipboardData && event.clipboardData.items;
7392
+ if (items && !state.terminalInteractive) {
7393
+ for (var i = 0; i < items.length; i++) {
7394
+ if (items[i].type.indexOf("image/") === 0) {
7395
+ event.preventDefault();
7396
+ var file = items[i].getAsFile();
7397
+ if (file) addPendingAttachment(file);
7398
+ return;
7399
+ }
7400
+ }
7401
+ }
7004
7402
  var pasted = event.clipboardData && event.clipboardData.getData("text");
7005
7403
  if (!pasted) return;
7006
7404
  event.preventDefault();
@@ -7483,12 +7881,9 @@
7483
7881
 
7484
7882
  if (!structured) {
7485
7883
  if (!state.terminal) initTerminal();
7486
- if (state.terminal && state.fitAddon) {
7487
- ensureTerminalFit();
7488
- }
7489
7884
  }
7490
7885
  applyCurrentView();
7491
- focusInputBox();
7886
+ focusInputBox(true);
7492
7887
  }
7493
7888
 
7494
7889
 
@@ -7501,7 +7896,9 @@
7501
7896
  var inputBox = document.getElementById("input-box");
7502
7897
  var value = inputBox ? inputBox.value : "";
7503
7898
  var selectedSession = getSelectedSession();
7504
- if (value) {
7899
+ var hasAttachments = state.pendingAttachments.length > 0;
7900
+
7901
+ if (value || hasAttachments) {
7505
7902
  console.log("[WAND] sendInputFromBox", {
7506
7903
  sessionId: state.selectedId,
7507
7904
  sessionStatus: selectedSession ? selectedSession.status : null,
@@ -7511,50 +7908,62 @@
7511
7908
  view: state.currentView,
7512
7909
  wsConnected: state.wsConnected,
7513
7910
  terminalInteractive: state.terminalInteractive,
7514
- inputLength: value.length
7911
+ inputLength: value.length,
7912
+ attachments: state.pendingAttachments.length
7515
7913
  });
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
7914
 
7520
- if (isStructuredSession(selectedSession)) {
7521
- return postStructuredInput(value, inputBox, selectedSession);
7522
- }
7915
+ var attachUpload = hasAttachments && state.selectedId
7916
+ ? uploadAttachments(state.selectedId)
7917
+ : Promise.resolve([]);
7523
7918
 
7524
- var submitChunks = getTerminalSubmitChunks(selectedSession, value);
7525
- var isOffline = !state.wsConnected;
7919
+ return attachUpload.then(function(uploadedFiles) {
7920
+ var prefix = buildAttachmentPrefix(uploadedFiles);
7921
+ var finalValue = prefix + (value || (uploadedFiles.length ? "请查看附件。" : ""));
7922
+ if (uploadedFiles.length) clearAttachments();
7526
7923
 
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
- }
7924
+ // Clear todo progress bar at the start of a new user turn
7925
+ var todoEl = document.getElementById("todo-progress");
7926
+ if (todoEl) todoEl.classList.add("hidden");
7537
7927
 
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;
7928
+ if (isStructuredSession(selectedSession)) {
7929
+ return postStructuredInput(finalValue, inputBox, selectedSession);
7543
7930
  }
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) {
7931
+
7932
+ var submitChunks = getTerminalSubmitChunks(selectedSession, finalValue);
7933
+ var isOffline = !state.wsConnected;
7934
+
7935
+ if (isOffline) {
7936
+ queueOfflineTerminalChunks(submitChunks);
7937
+ if (inputBox) {
7551
7938
  inputBox.value = "";
7552
7939
  autoResizeInput(inputBox);
7553
7940
  }
7554
7941
  setDraftValue("");
7942
+ return Promise.resolve();
7943
+ }
7944
+
7945
+ return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
7946
+ if (!readySession) {
7947
+ showToast("会话未就绪,将稍后重试。", "info");
7948
+ return null;
7949
+ }
7950
+ var submitView = state.currentView;
7951
+ if (readySession && readySession.provider === "codex" && state.selectedId !== readySession.id) {
7952
+ throw new Error("Codex session changed before input send.");
7953
+ }
7954
+ return sendTerminalChunks(submitChunks, "enter_text", 30, submitView).then(function() {
7955
+ if (inputBox && inputBox.value === value) {
7956
+ inputBox.value = "";
7957
+ autoResizeInput(inputBox);
7958
+ }
7959
+ setDraftValue("");
7960
+ });
7961
+ }).catch(function(err) {
7962
+ showToast(getInputErrorMessage(err), "error");
7963
+ throw err;
7555
7964
  });
7556
7965
  }).catch(function(err) {
7557
- showToast(getInputErrorMessage(err), "error");
7966
+ showToast("附件上传失败: " + (err.message || err), "error");
7558
7967
  throw err;
7559
7968
  });
7560
7969
  }
@@ -7805,10 +8214,9 @@
7805
8214
  }
7806
8215
 
7807
8216
  function getTerminalSubmitChunks(session, text) {
7808
- if (session && session.provider === "codex") {
7809
- return [text, String.fromCharCode(13)];
7810
- }
7811
- return [text + String.fromCharCode(13)];
8217
+ // 文本与回车分两个 chunk 发,避免 CLI bracketed paste 检测把末尾
8218
+ // \r 并入粘贴内容导致只换行不提交。
8219
+ return [text, String.fromCharCode(13)];
7812
8220
  }
7813
8221
 
7814
8222
  function sendTerminalChunks(chunks, shortcutKey, delayMs, viewOverride) {
@@ -7848,7 +8256,6 @@
7848
8256
  return postInput(input, shortcutKey, viewOverride).finally(function() {
7849
8257
  var idx = state.messageQueue.indexOf(input);
7850
8258
  if (idx > -1) state.messageQueue.splice(idx, 1);
7851
- scheduleMobileDomUpdate();
7852
8259
  });
7853
8260
  });
7854
8261
  return state.inputQueue;
@@ -8491,6 +8898,7 @@
8491
8898
  state.preferredCommand = command;
8492
8899
  state.chatMode = getSafeModeForTool(command, state.chatMode);
8493
8900
  }
8901
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8494
8902
  return fetch("/api/commands", {
8495
8903
  method: "POST",
8496
8904
  headers: { "Content-Type": "application/json" },
@@ -8498,7 +8906,8 @@
8498
8906
  body: JSON.stringify({
8499
8907
  command: command,
8500
8908
  cwd: cwd || "",
8501
- mode: state.chatMode || state.config.defaultMode || "default"
8909
+ mode: state.chatMode || state.config.defaultMode || "default",
8910
+ model: modelPref || undefined
8502
8911
  })
8503
8912
  })
8504
8913
  .then(function(res) { return res.json(); })
@@ -8618,6 +9027,7 @@
8618
9027
  var mode = state.chatMode || "managed";
8619
9028
  var defaultCwd = getEffectiveCwd();
8620
9029
  var preferredTool = getPreferredTool();
9030
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8621
9031
  fetch("/api/commands", {
8622
9032
  method: "POST",
8623
9033
  headers: { "Content-Type": "application/json" },
@@ -8626,7 +9036,8 @@
8626
9036
  command: preferredTool,
8627
9037
  cwd: defaultCwd,
8628
9038
  mode: mode,
8629
- initialInput: value
9039
+ initialInput: value,
9040
+ model: modelPref || undefined
8630
9041
  })
8631
9042
  })
8632
9043
  .then(function(res) { return res.json(); })
@@ -8654,6 +9065,7 @@
8654
9065
  var mode = state.chatMode || "managed";
8655
9066
  var defaultCwd = getEffectiveCwd();
8656
9067
  var preferredTool = getPreferredTool();
9068
+ var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8657
9069
  fetch("/api/commands", {
8658
9070
  method: "POST",
8659
9071
  headers: { "Content-Type": "application/json" },
@@ -8662,7 +9074,8 @@
8662
9074
  command: preferredTool,
8663
9075
  cwd: defaultCwd,
8664
9076
  mode: mode,
8665
- initialInput: value || undefined
9077
+ initialInput: value || undefined,
9078
+ model: modelPref || undefined
8666
9079
  })
8667
9080
  })
8668
9081
  .then(function(res) { return res.json(); })
@@ -8818,7 +9231,7 @@
8818
9231
  setTimeout(function() {
8819
9232
  window.scrollTo(0, 0);
8820
9233
  // On mobile, force terminal refit + scroll after keyboard dismissal.
8821
- // The container height restores but xterm needs an explicit refit to
9234
+ // The container height restores but terminal needs time to
8822
9235
  // fill the expanded space, and the scroll position needs resetting.
8823
9236
  if (isTouchDevice()) {
8824
9237
  ensureTerminalFit();
@@ -9487,9 +9900,8 @@
9487
9900
  if (!output) return;
9488
9901
  output.setAttribute("tabindex", "0");
9489
9902
  output.focus();
9490
- var terminalTextarea = output.querySelector(".xterm-helper-textarea");
9491
- if (terminalTextarea && typeof terminalTextarea.focus === "function") {
9492
- terminalTextarea.focus();
9903
+ if (state.terminal && state.terminal.focus) {
9904
+ state.terminal.focus();
9493
9905
  }
9494
9906
  }
9495
9907
 
@@ -9655,11 +10067,6 @@
9655
10067
  function observeTerminalResize() {
9656
10068
  var output = document.getElementById("output");
9657
10069
  if (!output) return;
9658
-
9659
- if (typeof ResizeObserver === "function") {
9660
- state.resizeObserver = new ResizeObserver(function() { scheduleTerminalResize(true); });
9661
- state.resizeObserver.observe(output);
9662
- }
9663
10070
  var lastKnownDesktop = !isMobileLayout();
9664
10071
  state.resizeHandler = function() {
9665
10072
  scheduleTerminalResize(true);
@@ -9685,7 +10092,36 @@
9685
10092
  requestAnimationFrame(function() { scheduleTerminalResize(true); });
9686
10093
  }
9687
10094
 
10095
+ function startTerminalHealthCheck() {
10096
+ if (state.terminalHealthTimer) return;
10097
+ state.terminalHealthTimer = setInterval(function() {
10098
+ if (!state.terminal || state.currentView !== "terminal" || document.hidden) return;
10099
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
10100
+ if (!selectedSession || selectedSession.sessionKind === "structured") return;
10101
+ // Lightweight remeasure every 5s
10102
+ if (state.terminal.remeasure) state.terminal.remeasure();
10103
+ // Full re-sync every 30s during output pauses
10104
+ var now = Date.now();
10105
+ var chunkPause = state.lastChunkAt > 0 && (now - state.lastChunkAt > 300);
10106
+ var resyncDue = (now - state.lastTerminalResyncAt) > 30000;
10107
+ if (resyncDue && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
10108
+ state.lastTerminalResyncAt = now;
10109
+ state.terminal.reset();
10110
+ state.terminal.write(state.terminalOutput);
10111
+ maybeScrollTerminalToBottom("output");
10112
+ }
10113
+ }, 5000);
10114
+ }
10115
+
10116
+ function stopTerminalHealthCheck() {
10117
+ if (state.terminalHealthTimer) {
10118
+ clearInterval(state.terminalHealthTimer);
10119
+ state.terminalHealthTimer = null;
10120
+ }
10121
+ }
10122
+
9688
10123
  function teardownTerminal() {
10124
+ stopTerminalHealthCheck();
9689
10125
  if (state.resizeTimer) {
9690
10126
  clearTimeout(state.resizeTimer);
9691
10127
  state.resizeTimer = null;
@@ -9711,6 +10147,7 @@
9711
10147
  }
9712
10148
  });
9713
10149
  clearTerminalScrollIdleTimer();
10150
+ var output = document.getElementById("output");
9714
10151
  if (state.terminalViewportEl) {
9715
10152
  if (state.terminalViewportScrollHandler) {
9716
10153
  state.terminalViewportEl.removeEventListener("scroll", state.terminalViewportScrollHandler);
@@ -9719,27 +10156,30 @@
9719
10156
  state.terminalViewportEl.removeEventListener("touchmove", state.terminalViewportTouchHandler);
9720
10157
  }
9721
10158
  }
10159
+ if (output) {
10160
+ if (state.terminalWheelHandler) {
10161
+ output.removeEventListener("wheel", state.terminalWheelHandler);
10162
+ }
10163
+ if (state.terminalClickHandler) {
10164
+ output.removeEventListener("click", state.terminalClickHandler);
10165
+ }
10166
+ }
9722
10167
  state.terminalViewportEl = null;
9723
10168
  state.terminalViewportScrollHandler = null;
9724
10169
  state.terminalViewportTouchHandler = null;
10170
+ state.terminalWheelHandler = null;
10171
+ state.terminalClickHandler = null;
10172
+ if (state.terminalScrollbarEl && state.terminalScrollbarEl.parentNode) {
10173
+ state.terminalScrollbarEl.parentNode.removeChild(state.terminalScrollbarEl);
10174
+ }
10175
+ state.terminalScrollbarEl = null;
10176
+ state.terminalScrollbarThumbEl = null;
9725
10177
  if (state.terminal) {
9726
- state.terminal.dispose();
10178
+ state.terminal.destroy();
9727
10179
  state.terminal = null;
9728
10180
  }
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
10181
  state.terminalSessionId = null;
9741
10182
  state.terminalOutput = "";
9742
- state.terminalViewportSize = { width: 0, height: 0 };
9743
10183
  state.terminalAutoFollow = true;
9744
10184
  state.showTerminalJumpToBottom = false;
9745
10185
  updateTerminalJumpToBottomButton();
@@ -9762,39 +10202,9 @@
9762
10202
  }
9763
10203
 
9764
10204
  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);
10205
+ if (!state.terminal) return;
10206
+ maybeScrollTerminalToBottom("resize");
10207
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9798
10208
  }
9799
10209
 
9800
10210
  function scheduleTerminalResize(immediate) {
@@ -9810,16 +10220,11 @@
9810
10220
  }
9811
10221
 
9812
10222
  function syncTerminalSize() {
9813
- var output = document.getElementById("output");
9814
- if (!state.terminal || !state.fitAddon || !output) return;
9815
- if (!shouldResizeTerminalViewport()) return;
9816
-
10223
+ if (!state.terminal) return;
9817
10224
  var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
9818
- state.fitAddon.fit();
9819
10225
  if (shouldFollow) {
9820
10226
  maybeScrollTerminalToBottom("resize");
9821
10227
  }
9822
-
9823
10228
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
9824
10229
  }
9825
10230
 
@@ -9865,9 +10270,8 @@
9865
10270
  flushPendingMessages();
9866
10271
  // Re-fit terminal on reconnect — the viewport may have changed
9867
10272
  // 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();
10273
+ if (state.terminal) {
10274
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9871
10275
  }
9872
10276
  };
9873
10277
 
@@ -9947,8 +10351,22 @@
9947
10351
  snapshot.messages = msg.data.messages;
9948
10352
  }
9949
10353
 
9950
- // Only update if we have meaningful data
9951
- if (snapshot.output !== undefined || snapshot.messages || isIncremental || msg.data.permissionBlocked !== undefined) {
10354
+ // Fast path: chunk-only incremental events skip expensive chat update
10355
+ var isChunkOnly = isIncremental && msg.data.chunk
10356
+ && !msg.data.lastMessage && !snapshot.messages
10357
+ && snapshot.output === undefined
10358
+ && !msg.data.structuredState && !msg.data.sessionKind;
10359
+
10360
+ if (isChunkOnly) {
10361
+ // Only update permissionBlocked if it actually changed
10362
+ if (msg.data.permissionBlocked !== undefined) {
10363
+ var existingPB = state.sessions.find(function(s) { return s.id === msg.sessionId; });
10364
+ if (existingPB && !!existingPB.permissionBlocked !== !!msg.data.permissionBlocked) {
10365
+ updateSessionSnapshot(snapshot);
10366
+ if (msg.sessionId === state.selectedId) updateTaskDisplay();
10367
+ }
10368
+ }
10369
+ } else if (snapshot.output !== undefined || snapshot.messages || isIncremental || msg.data.permissionBlocked !== undefined) {
9952
10370
  updateSessionSnapshot(snapshot);
9953
10371
  if (msg.sessionId === state.selectedId) {
9954
10372
  var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
@@ -9966,21 +10384,20 @@
9966
10384
  // Real-time terminal output
9967
10385
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
9968
10386
  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.
10387
+ // Fast path: write chunk directly to avoid full-output comparison.
10388
+ state.lastChunkAt = Date.now();
9971
10389
  state.terminalLiveStreamSessions[msg.sessionId] = true;
9972
10390
  state.terminal.write(msg.data.chunk);
9973
10391
  state.terminalSessionId = msg.sessionId;
9974
10392
  if (msg.data.output) {
9975
10393
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
9976
10394
  } else {
9977
- state.terminalOutput = normalizeTerminalOutput((state.terminalOutput || "") + msg.data.chunk);
10395
+ state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
9978
10396
  }
9979
10397
  maybeScrollTerminalToBottom("output");
9980
10398
  updateTerminalJumpToBottomButton();
9981
- scheduleMobileDomUpdate();
9982
10399
  } 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)
10400
+ // Fallback: no chunk available, use full-output comparison.
9984
10401
  syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
9985
10402
  }
9986
10403
  }
@@ -10030,6 +10447,7 @@
10030
10447
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
10031
10448
  }
10032
10449
  notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
10450
+ clearSessionProgressNative(msg.sessionId);
10033
10451
  if (msg.sessionId !== state.selectedId || document.hidden) {
10034
10452
  showNotificationBubble({
10035
10453
  title: endedNotifTitle,
@@ -10104,6 +10522,11 @@
10104
10522
  updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
10105
10523
  // Ensure terminal is properly fitted after receiving initial data
10106
10524
  scheduleTerminalResize(true);
10525
+ if (state.terminal && state.terminal.remeasure) {
10526
+ requestAnimationFrame(function() {
10527
+ if (state.terminal) state.terminal.remeasure();
10528
+ });
10529
+ }
10107
10530
  }
10108
10531
  break;
10109
10532
  case 'usage':
@@ -10116,6 +10539,7 @@
10116
10539
  updateTaskDisplay();
10117
10540
  }
10118
10541
  notifyTaskProgress(msg.sessionId, msg.data || null);
10542
+ syncSessionProgressToNative(msg.sessionId);
10119
10543
  // Update session list to reflect current activity (debounced)
10120
10544
  scheduleSessionListUpdate();
10121
10545
  break;
@@ -10185,6 +10609,7 @@
10185
10609
  statusUpdate.approvalStats = msg.data.approvalStats;
10186
10610
  }
10187
10611
  updateSessionSnapshot(statusUpdate);
10612
+ syncSessionProgressToNative(msg.sessionId);
10188
10613
  if (msg.sessionId === state.selectedId) {
10189
10614
  updateTaskDisplay();
10190
10615
  if (msg.data.approvalStats) {
@@ -10207,6 +10632,10 @@
10207
10632
  if (msg.data) {
10208
10633
  if (msg.data.kind === "update") {
10209
10634
  notifyUpdateAvailable(msg.data.current || "-", msg.data.latest || "-");
10635
+ } else if (msg.data.kind === "auto-update-start") {
10636
+ showAutoUpdateOverlay(msg.data.current || "-", msg.data.latest || "-");
10637
+ } else if (msg.data.kind === "auto-update-restart") {
10638
+ showRestartOverlay();
10210
10639
  } else if (msg.data.kind === "restart") {
10211
10640
  showRestartOverlay();
10212
10641
  }
@@ -10440,8 +10869,12 @@
10440
10869
  }
10441
10870
  updateTerminalJumpToBottomButton();
10442
10871
  if (state.currentView === "terminal") {
10443
- state.terminalViewportSize = { width: 0, height: 0 };
10444
10872
  scheduleTerminalResize(true);
10873
+ if (state.terminal && state.terminal.remeasure) {
10874
+ requestAnimationFrame(function() {
10875
+ if (state.terminal) state.terminal.remeasure();
10876
+ });
10877
+ }
10445
10878
  }
10446
10879
  }
10447
10880
 
@@ -10467,13 +10900,15 @@
10467
10900
  if (chatRenderTimer) clearTimeout(chatRenderTimer);
10468
10901
  if (immediate) {
10469
10902
  chatRenderTimer = null;
10470
- // Messages already updated in handleWebSocketMessage, just render
10471
10903
  renderChat();
10472
10904
  return;
10473
10905
  }
10906
+ var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
10907
+ var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
10908
+ && selectedForDelay.sessionKind !== "structured";
10909
+ var delay = isActiveStream ? 150 : 30;
10474
10910
  chatRenderTimer = setTimeout(function() {
10475
10911
  chatRenderTimer = null;
10476
- // Re-parse messages from the latest session output (fallback for edge cases)
10477
10912
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
10478
10913
  if (selectedSession) {
10479
10914
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
@@ -11016,6 +11451,10 @@
11016
11451
  list.innerHTML = html;
11017
11452
  }
11018
11453
 
11454
+ // Sync todo progress to native notification
11455
+ if (state.selectedId) {
11456
+ syncSessionProgressToNative(state.selectedId);
11457
+ }
11019
11458
  }
11020
11459
 
11021
11460
 
@@ -11143,161 +11582,6 @@
11143
11582
  })();
11144
11583
 
11145
11584
  // ===== 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
11585
 
11302
11586
  function isNoiseLine(line) {
11303
11587
  if (!line) return false;
@@ -12917,6 +13201,65 @@
12917
13201
  return deduped.join(newline);
12918
13202
  }
12919
13203
 
13204
+ function parseMarkdownTables(source) {
13205
+ var NL = "\n";
13206
+ var lines = source.split(NL);
13207
+ var out = [];
13208
+ var i = 0;
13209
+
13210
+ function splitRow(line) {
13211
+ var s = line.trim();
13212
+ if (s.charAt(0) === "|") s = s.slice(1);
13213
+ if (s.charAt(s.length - 1) === "|") s = s.slice(0, -1);
13214
+ return s.split("|");
13215
+ }
13216
+ function styleAttr(a) { return a ? ' style="text-align:' + a + '"' : ""; }
13217
+ function buildTable(headers, aligns, rows) {
13218
+ var thead = "<thead><tr>" + headers.map(function(c, idx) {
13219
+ return "<th" + styleAttr(aligns[idx]) + ">" + c.trim() + "</th>";
13220
+ }).join("") + "</tr></thead>";
13221
+ var tbody = rows.length ? ("<tbody>" + rows.map(function(r) {
13222
+ return "<tr>" + r.map(function(c, idx) {
13223
+ return "<td" + styleAttr(aligns[idx]) + ">" + c.trim() + "</td>";
13224
+ }).join("") + "</tr>";
13225
+ }).join("") + "</tbody>") : "";
13226
+ return '<div class="md-table-wrap"><table class="md-table">' + thead + tbody + "</table></div>";
13227
+ }
13228
+
13229
+ while (i < lines.length) {
13230
+ var header = lines[i];
13231
+ if (header.indexOf("|") !== -1 && i + 1 < lines.length) {
13232
+ var sep = lines[i + 1].trim();
13233
+ if (/^\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?$/.test(sep)) {
13234
+ var headers = splitRow(header);
13235
+ var aligns = splitRow(sep).map(function(c) {
13236
+ var t = c.trim();
13237
+ var L = t.charAt(0) === ":";
13238
+ var R = t.charAt(t.length - 1) === ":";
13239
+ if (L && R) return "center";
13240
+ if (R) return "right";
13241
+ if (L) return "left";
13242
+ return "";
13243
+ });
13244
+ var rows = [];
13245
+ var j = i + 2;
13246
+ while (j < lines.length) {
13247
+ var trimmed = lines[j].trim();
13248
+ if (!trimmed || trimmed.indexOf("|") === -1) break;
13249
+ rows.push(splitRow(lines[j]));
13250
+ j += 1;
13251
+ }
13252
+ out.push("", buildTable(headers, aligns, rows), "");
13253
+ i = j;
13254
+ continue;
13255
+ }
13256
+ }
13257
+ out.push(header);
13258
+ i += 1;
13259
+ }
13260
+ return out.join(NL);
13261
+ }
13262
+
12920
13263
  function renderMarkdown(text) {
12921
13264
  if (!text) return "";
12922
13265
 
@@ -13041,6 +13384,7 @@
13041
13384
  result = replaceLinePrefix(result, "- ", '<li>', '</li>');
13042
13385
  result = replaceLinePrefix(result, "* ", '<li>', '</li>');
13043
13386
  result = replaceOrderedList(result);
13387
+ result = parseMarkdownTables(result);
13044
13388
 
13045
13389
  var lines = result.split(newline);
13046
13390
  var grouped = [];
@@ -13387,7 +13731,79 @@
13387
13731
  );
13388
13732
  }
13389
13733
 
13390
- /**
13734
+ // ── Native Live Progress Sync ──
13735
+
13736
+ var _progressSyncTimers = {};
13737
+ var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
13738
+
13739
+ function syncSessionProgressToNative(sessionId) {
13740
+ if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
13741
+ if (!sessionId) return;
13742
+ if (_progressSyncTimers[sessionId]) {
13743
+ clearTimeout(_progressSyncTimers[sessionId]);
13744
+ }
13745
+ _progressSyncTimers[sessionId] = setTimeout(function() {
13746
+ delete _progressSyncTimers[sessionId];
13747
+ _doSyncSessionProgress(sessionId);
13748
+ }, _PROGRESS_SYNC_DEBOUNCE_MS);
13749
+ }
13750
+
13751
+ function _doSyncSessionProgress(sessionId) {
13752
+ var session = state.sessions.find(function(s) { return s.id === sessionId; });
13753
+ if (!session) return;
13754
+
13755
+ var sessionLabel = session.summary || session.command || sessionId;
13756
+ var sessionStatus = session.status || "running";
13757
+
13758
+ // Clear notification for inactive sessions
13759
+ if (sessionStatus === "idle" || sessionStatus === "archived" || sessionStatus === "exited") {
13760
+ clearSessionProgressNative(sessionId);
13761
+ return;
13762
+ }
13763
+
13764
+ // Get latest todos from session messages
13765
+ var todos = null;
13766
+ var messages = session.messages || [];
13767
+ for (var i = messages.length - 1; i >= 0; i--) {
13768
+ var msg = messages[i];
13769
+ if (!msg.content || !Array.isArray(msg.content)) continue;
13770
+ for (var j = msg.content.length - 1; j >= 0; j--) {
13771
+ var block = msg.content[j];
13772
+ if (block.type === "tool_use" && block.name === "TodoWrite"
13773
+ && block.input && block.input.todos) {
13774
+ todos = block.input.todos;
13775
+ break;
13776
+ }
13777
+ }
13778
+ if (todos) break;
13779
+ }
13780
+
13781
+ // Get current task
13782
+ var currentTask = "";
13783
+ if (sessionId === state.selectedId && state.currentTask && state.currentTask.title) {
13784
+ currentTask = state.currentTask.title;
13785
+ }
13786
+
13787
+ var data = {
13788
+ sessionLabel: sessionLabel,
13789
+ status: sessionStatus,
13790
+ currentTask: currentTask,
13791
+ todos: todos || []
13792
+ };
13793
+
13794
+ try {
13795
+ WandNative.updateSessionProgress(sessionId, JSON.stringify(data));
13796
+ } catch (_e) {}
13797
+ }
13798
+
13799
+ function clearSessionProgressNative(sessionId) {
13800
+ if (!_hasNativeBridge || typeof WandNative.clearSessionProgress !== "function") return;
13801
+ if (_progressSyncTimers[sessionId]) {
13802
+ clearTimeout(_progressSyncTimers[sessionId]);
13803
+ delete _progressSyncTimers[sessionId];
13804
+ }
13805
+ try { WandNative.clearSessionProgress(sessionId); } catch (_e) {}
13806
+ }
13391
13807
 
13392
13808
  /**
13393
13809
  * Play a soft, rounded notification chime using Web Audio API.
@@ -13606,6 +14022,23 @@
13606
14022
  }, 2000);
13607
14023
  }
13608
14024
 
14025
+ function showAutoUpdateOverlay(currentVer, latestVer) {
14026
+ if (document.getElementById("restart-overlay")) return;
14027
+ var overlay = document.createElement("div");
14028
+ overlay.id = "restart-overlay";
14029
+ overlay.className = "restart-overlay";
14030
+ overlay.innerHTML =
14031
+ '<div class="restart-overlay-content">' +
14032
+ '<div class="restart-spinner"></div>' +
14033
+ '<div class="restart-title">\u81ea\u52a8\u66f4\u65b0\u4e2d</div>' +
14034
+ '<div class="restart-subtitle">' +
14035
+ escapeHtml(currentVer) + ' \u2192 ' + escapeHtml(latestVer) +
14036
+ '<br>\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\uff0c\u7a0d\u540e\u5c06\u81ea\u52a8\u91cd\u542f\u2026' +
14037
+ '</div>' +
14038
+ '</div>';
14039
+ document.body.appendChild(overlay);
14040
+ }
14041
+
13609
14042
  function escapeHtml(value) {
13610
14043
  return String(value)
13611
14044
  .replace(/&/g, "&amp;")