@co0ontty/wand 1.18.12 → 1.21.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.
Files changed (39) hide show
  1. package/dist/claude-pty-bridge.d.ts +8 -0
  2. package/dist/claude-pty-bridge.js +34 -11
  3. package/dist/cli.js +72 -5
  4. package/dist/ensure-node-pty-helper.d.ts +1 -0
  5. package/dist/ensure-node-pty-helper.js +51 -0
  6. package/dist/git-quick-commit.d.ts +18 -0
  7. package/dist/git-quick-commit.js +381 -0
  8. package/dist/models.d.ts +3 -1
  9. package/dist/models.js +45 -7
  10. package/dist/process-manager.d.ts +6 -8
  11. package/dist/process-manager.js +90 -176
  12. package/dist/prompt-optimizer.d.ts +5 -0
  13. package/dist/prompt-optimizer.js +72 -0
  14. package/dist/pty-text-utils.d.ts +25 -1
  15. package/dist/pty-text-utils.js +158 -2
  16. package/dist/server-session-routes.d.ts +2 -2
  17. package/dist/server-session-routes.js +94 -8
  18. package/dist/server.d.ts +22 -1
  19. package/dist/server.js +138 -16
  20. package/dist/session-logger.d.ts +15 -4
  21. package/dist/session-logger.js +52 -4
  22. package/dist/structured-session-manager.d.ts +12 -2
  23. package/dist/structured-session-manager.js +465 -22
  24. package/dist/tui/index.d.ts +24 -0
  25. package/dist/tui/index.js +138 -0
  26. package/dist/tui/layout.d.ts +25 -0
  27. package/dist/tui/layout.js +198 -0
  28. package/dist/tui/log-bus.d.ts +23 -0
  29. package/dist/tui/log-bus.js +111 -0
  30. package/dist/tui/relative-time.d.ts +4 -0
  31. package/dist/tui/relative-time.js +27 -0
  32. package/dist/tui/session-formatter.d.ts +17 -0
  33. package/dist/tui/session-formatter.js +111 -0
  34. package/dist/types.d.ts +55 -2
  35. package/dist/web-ui/content/scripts.js +1371 -261
  36. package/dist/web-ui/content/styles.css +436 -9
  37. package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
  38. package/dist/ws-broadcast.js +74 -12
  39. package/package.json +3 -1
@@ -119,6 +119,7 @@
119
119
  try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
120
120
  })(),
121
121
  availableModels: [],
122
+ availableCodexModels: [],
122
123
  modelsRefreshing: false,
123
124
  sessionCreateKind: "structured",
124
125
  sessionCreateWorktree: false,
@@ -149,6 +150,10 @@
149
150
  try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
150
151
  })(),
151
152
  toolContentCache: {},
153
+ // Per-session WS output sequence tracker. Reset on connect/reconnect.
154
+ // Used to detect gaps caused by server-side backpressure drops and
155
+ // request a fresh snapshot.
156
+ lastSeqBySession: {},
152
157
  currentView: "terminal",
153
158
  terminalScale: (function() {
154
159
  try {
@@ -168,6 +173,16 @@
168
173
  }
169
174
  })(),
170
175
  topbarMoreOpen: false,
176
+ gitStatus: null,
177
+ gitStatusSessionId: null,
178
+ gitStatusLoading: false,
179
+ gitStatusInflight: null,
180
+ gitStatusLastFetchAt: 0,
181
+ quickCommitOpen: false,
182
+ quickCommitSubmitting: false,
183
+ quickCommitGenerating: false,
184
+ quickCommitError: "",
185
+ quickCommitForm: { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false },
171
186
  chatAutoFollow: (function() {
172
187
  try {
173
188
  var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
@@ -225,10 +240,90 @@
225
240
  // ── Structured session status bar (in-flight timer) ──
226
241
  var _statusBarTimerId = null;
227
242
  var _statusBarStartTime = 0;
243
+ var _runningIndicatorsTimerId = null;
244
+ var _runningIndicatorsStartTime = 0;
245
+
246
+ // 计算会话整体的"在跑"信号,统一驱动顶部进度条/徽章计时/气泡呼吸条。
247
+ function computeRunningSignal(session) {
248
+ if (!session) return { active: false };
249
+ if (session.archived) return { active: false };
250
+ var permBlocked = !!session.permissionBlocked;
251
+ var inFlight = !!(isStructuredSession(session)
252
+ && session.structuredState && session.structuredState.inFlight);
253
+ var ptyRunning = !isStructuredSession(session) && session.status === "running";
254
+ return {
255
+ active: inFlight || ptyRunning || permBlocked,
256
+ inFlight: inFlight,
257
+ ptyRunning: ptyRunning,
258
+ permissionBlocked: permBlocked,
259
+ };
260
+ }
261
+
262
+ function formatElapsedShort(ms) {
263
+ var s = Math.max(0, Math.floor(ms / 1000));
264
+ if (s < 60) return s + "s";
265
+ var m = Math.floor(s / 60);
266
+ var rs = s % 60;
267
+ if (m < 60) return m + "m" + (rs ? " " + rs + "s" : "");
268
+ var h = Math.floor(m / 60);
269
+ var rm = m % 60;
270
+ return h + "h" + (rm ? " " + rm + "m" : "");
271
+ }
272
+
273
+ // 集中刷新:顶部进度条 + 顶部徽章计时 + 助手气泡左侧呼吸条。
274
+ function updateRunningIndicators(session) {
275
+ var sig = computeRunningSignal(session);
276
+ var headerRow = document.querySelector(".main-header-row");
277
+ var pill = headerRow ? headerRow.querySelector(".session-status-pill") : null;
278
+ var chatMessages = document.querySelector(".chat-messages");
279
+
280
+ // A. 顶部进度条
281
+ if (headerRow) {
282
+ headerRow.classList.toggle("is-running", sig.active);
283
+ headerRow.classList.toggle("is-permission-blocked", sig.permissionBlocked);
284
+ }
285
+
286
+ // B. 顶部徽章计时(仅 inFlight 显示,PTY running 不强制显示)
287
+ if (pill) {
288
+ var elapsedEl = pill.querySelector(".session-status-elapsed");
289
+ if (sig.inFlight) {
290
+ if (!_runningIndicatorsStartTime) {
291
+ // 优先复用 renderStructuredStatusBar 已记录的真实起点
292
+ _runningIndicatorsStartTime = _statusBarStartTime > 0 ? _statusBarStartTime : Date.now();
293
+ }
294
+ var label = formatElapsedShort(Date.now() - _runningIndicatorsStartTime);
295
+ if (!elapsedEl) {
296
+ elapsedEl = document.createElement("span");
297
+ elapsedEl.className = "session-status-elapsed";
298
+ pill.appendChild(elapsedEl);
299
+ }
300
+ elapsedEl.textContent = label;
301
+ } else {
302
+ _runningIndicatorsStartTime = 0;
303
+ if (elapsedEl) elapsedEl.remove();
304
+ }
305
+ }
306
+
307
+ // 维持每秒一次的刷新心跳,让 elapsed 数字持续滚动
308
+ if (sig.active) {
309
+ if (!_runningIndicatorsTimerId) {
310
+ _runningIndicatorsTimerId = setInterval(function() {
311
+ var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
312
+ updateRunningIndicators(sel);
313
+ }, 1000);
314
+ }
315
+ } else if (_runningIndicatorsTimerId) {
316
+ clearInterval(_runningIndicatorsTimerId);
317
+ _runningIndicatorsTimerId = null;
318
+ }
319
+ }
228
320
 
229
321
  function renderStructuredStatusBar(chatMessages, session) {
230
- // Status bar now lives above the input-composer, inside .input-panel
231
- var inputPanel = document.querySelector(".input-panel");
322
+ // 先驱动跨视图的运行指示器(顶部进度条/徽章计时/气泡呼吸条)
323
+ updateRunningIndicators(session);
324
+
325
+ // Status bar now lives in .composer-top-row alongside the todo-progress collapse bar
326
+ var topRow = document.querySelector(".composer-top-row");
232
327
  var existing = document.querySelector(".structured-status-bar");
233
328
  var composer = document.querySelector(".input-composer");
234
329
  if (!session || !isStructuredSession(session)) {
@@ -250,15 +345,15 @@
250
345
  // Add glow to input composer
251
346
  if (composer) composer.classList.add("in-flight");
252
347
 
253
- if (!existing && inputPanel && composer) {
348
+ if (!existing && topRow) {
254
349
  var bar = document.createElement("div");
255
350
  bar.className = "structured-status-bar";
256
351
  bar.innerHTML =
257
352
  '<span class="status-bar-dot"></span>' +
258
353
  '<span class="status-bar-label">回复中</span>' +
259
354
  '<span class="status-bar-timer">0.0s</span>';
260
- // Insert right before the input-composer element
261
- inputPanel.insertBefore(bar, composer);
355
+ // Append as last child of the top row so it sits to the right of the todo bar
356
+ topRow.appendChild(bar);
262
357
  existing = bar;
263
358
  } else if (existing && existing.classList.contains("completed")) {
264
359
  // Was completed, now in-flight again — reset
@@ -1023,6 +1118,17 @@
1023
1118
  syncSessionModalUI();
1024
1119
  }
1025
1120
  }
1121
+
1122
+ // 初始加载或会话切换后惰性触发 git 状态拉取(loadGitStatus 自带节流)。
1123
+ if (isLoggedIn && state.selectedId && state.gitStatusSessionId !== state.selectedId) {
1124
+ loadGitStatus(state.selectedId);
1125
+ }
1126
+
1127
+ // DOM 整体重渲后,重新挂上"运行中"指示器(顶部进度条/徽章计时/气泡呼吸条)
1128
+ if (isLoggedIn) {
1129
+ var __sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
1130
+ updateRunningIndicators(__sel);
1131
+ }
1026
1132
  }
1027
1133
 
1028
1134
  function renderShortcutKeys() {
@@ -1225,6 +1331,7 @@
1225
1331
  '</div>' +
1226
1332
  '<div class="topbar-right">' +
1227
1333
  (selectedSession && selectedSession.cwd ? '<button id="topbar-file-button" class="topbar-btn square' + (state.filePanelOpen ? ' active' : '') + '" type="button" aria-label="文件" title="文件"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>' : '') +
1334
+ '<span id="topbar-git-slot" class="topbar-git-slot">' + renderTopbarGitBadgeHtml() + '</span>' +
1228
1335
  '<div class="topbar-more-wrap">' +
1229
1336
  '<button id="topbar-more-button" class="topbar-btn square' + (state.topbarMoreOpen ? ' active' : '') + '" type="button" aria-label="更多" aria-haspopup="menu" aria-expanded="' + (state.topbarMoreOpen ? 'true' : 'false') + '" title="更多"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>' +
1230
1337
  '<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
@@ -1299,20 +1406,31 @@
1299
1406
  '</div>' +
1300
1407
  '</div>' +
1301
1408
  '<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
1302
- '<div id="todo-progress" class="todo-progress hidden">' +
1303
- '<div class="todo-progress-header" id="todo-progress-toggle">' +
1304
- '<div class="todo-progress-left">' +
1305
- '<span class="todo-progress-spinner"></span>' +
1306
- '<span class="todo-progress-counter" id="todo-progress-counter">0/0</span>' +
1307
- '<span class="todo-progress-task" id="todo-progress-task"></span>' +
1409
+ '<div class="composer-top-row">' +
1410
+ '<div id="todo-progress" class="todo-progress hidden">' +
1411
+ '<div class="todo-progress-header" id="todo-progress-toggle">' +
1412
+ '<div class="todo-progress-left">' +
1413
+ '<span class="todo-progress-spinner"></span>' +
1414
+ '<span class="todo-progress-counter" id="todo-progress-counter">0/0</span>' +
1415
+ '<span class="todo-progress-task" id="todo-progress-task"></span>' +
1416
+ '</div>' +
1417
+ '<svg class="todo-progress-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>' +
1418
+ '</div>' +
1419
+ '<div class="todo-progress-body hidden" id="todo-progress-body">' +
1420
+ '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1308
1421
  '</div>' +
1309
- '<svg class="todo-progress-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>' +
1310
- '</div>' +
1311
- '<div class="todo-progress-body hidden" id="todo-progress-body">' +
1312
- '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1313
1422
  '</div>' +
1314
1423
  '</div>' +
1315
1424
  '<div class="input-composer">' +
1425
+ '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
1426
+ '<svg class="prompt-optimize-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1427
+ '<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z" fill="currentColor" opacity="0.25"/>' +
1428
+ '<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z"/>' +
1429
+ '<path d="M19 14l.7 1.9L21.6 17l-1.9.7L19 19.6l-.7-1.9L16.4 17l1.9-.7z" fill="currentColor" opacity="0.35"/>' +
1430
+ '<path d="M5 4l.5 1.4L7 6l-1.5.6L5 8l-.5-1.4L3 6l1.5-.6z" fill="currentColor" opacity="0.35"/>' +
1431
+ '</svg>' +
1432
+ '<span class="prompt-optimize-spinner" aria-hidden="true"></span>' +
1433
+ '</button>' +
1316
1434
  '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1317
1435
  '<div id="attachment-preview" class="attachment-preview hidden"></div>' +
1318
1436
  '<div class="input-composer-bar">' +
@@ -1325,7 +1443,7 @@
1325
1443
  renderModeOptions(preferredTool, composerMode) +
1326
1444
  '</select>' +
1327
1445
  '<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1328
- renderChatModelOptions(getEffectiveModel(selectedSession)) +
1446
+ renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1329
1447
  '</select>' +
1330
1448
  '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1331
1449
  '<span class="permission-actions hidden" id="permission-actions">' +
@@ -1389,7 +1507,322 @@
1389
1507
  '</section>' +
1390
1508
  '</main>' +
1391
1509
  '</div>' +
1392
- '</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal();
1510
+ '</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal() + renderQuickCommitModal();
1511
+ }
1512
+
1513
+ function renderTopbarGitBadgeHtml() {
1514
+ if (!state.selectedId || !state.gitStatus || !state.gitStatus.isGit) return "";
1515
+ if (state.gitStatusSessionId !== state.selectedId) return "";
1516
+ var branch = state.gitStatus.branch || "?";
1517
+ var count = state.gitStatus.modifiedCount || 0;
1518
+ var titleText = branch + (count ? " · " + count + " 个文件待提交" : " · 工作区干净");
1519
+ return '<button id="topbar-git-badge" class="topbar-git-badge" type="button" title="' + escapeHtml(titleText) + '" aria-label="快捷提交">'
1520
+ + '<svg class="topbar-git-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="9" r="2"/><path d="M6 8v8"/><path d="M18 11v1a3 3 0 0 1-3 3H9"/></svg>'
1521
+ + '<span class="topbar-git-branch">' + escapeHtml(branch) + '</span>'
1522
+ + (count > 0
1523
+ ? '<span class="topbar-git-count">·' + count + '</span>'
1524
+ : '<span class="topbar-git-clean" aria-hidden="true">✓</span>')
1525
+ + '</button>';
1526
+ }
1527
+
1528
+ function updateTopbarGitBadge() {
1529
+ var slot = document.getElementById("topbar-git-slot");
1530
+ if (!slot) return;
1531
+ slot.innerHTML = renderTopbarGitBadgeHtml();
1532
+ var btn = document.getElementById("topbar-git-badge");
1533
+ if (btn) {
1534
+ btn.addEventListener("click", function(e) {
1535
+ e.preventDefault();
1536
+ openQuickCommitModal();
1537
+ });
1538
+ }
1539
+ }
1540
+
1541
+ function loadGitStatus(sessionId, options) {
1542
+ if (!sessionId) return Promise.resolve(null);
1543
+ var force = options && options.force;
1544
+ // Same session, fetched within 1s, and no force → skip.
1545
+ var now = Date.now();
1546
+ if (!force && state.gitStatusSessionId === sessionId && state.gitStatus && (now - state.gitStatusLastFetchAt) < 1000) {
1547
+ return Promise.resolve(state.gitStatus);
1548
+ }
1549
+ if (state.gitStatusInflight && state.gitStatusInflight.sessionId === sessionId) {
1550
+ return state.gitStatusInflight.promise;
1551
+ }
1552
+ state.gitStatusLoading = true;
1553
+ var promise = fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/git-status", {
1554
+ credentials: "same-origin"
1555
+ })
1556
+ .then(function(res) { return res.ok ? res.json() : { isGit: false }; })
1557
+ .then(function(data) {
1558
+ state.gitStatus = data || { isGit: false };
1559
+ state.gitStatusSessionId = sessionId;
1560
+ state.gitStatusLastFetchAt = Date.now();
1561
+ updateTopbarGitBadge();
1562
+ return data;
1563
+ })
1564
+ .catch(function() {
1565
+ state.gitStatus = { isGit: false };
1566
+ state.gitStatusSessionId = sessionId;
1567
+ state.gitStatusLastFetchAt = Date.now();
1568
+ updateTopbarGitBadge();
1569
+ return null;
1570
+ })
1571
+ .finally(function() {
1572
+ state.gitStatusLoading = false;
1573
+ if (state.gitStatusInflight && state.gitStatusInflight.sessionId === sessionId) {
1574
+ state.gitStatusInflight = null;
1575
+ }
1576
+ });
1577
+ state.gitStatusInflight = { sessionId: sessionId, promise: promise };
1578
+ return promise;
1579
+ }
1580
+
1581
+ var quickCommitEscHandler = null;
1582
+
1583
+ function openQuickCommitModal() {
1584
+ if (!state.selectedId) return;
1585
+ state.quickCommitOpen = true;
1586
+ state.quickCommitSubmitting = false;
1587
+ state.quickCommitError = "";
1588
+ state.quickCommitForm = { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
1589
+ closeWorktreeMergeModal();
1590
+ closeSessionModal();
1591
+ closeSettingsModal();
1592
+ rerenderQuickCommitModal();
1593
+ var modal = document.getElementById("quick-commit-modal");
1594
+ if (modal) {
1595
+ modal.classList.remove("hidden");
1596
+ lastFocusedElement = document.activeElement;
1597
+ setupFocusTrap(modal);
1598
+ }
1599
+ if (quickCommitEscHandler) document.removeEventListener("keydown", quickCommitEscHandler);
1600
+ quickCommitEscHandler = function(e) {
1601
+ if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting) {
1602
+ closeQuickCommitModal();
1603
+ }
1604
+ };
1605
+ document.addEventListener("keydown", quickCommitEscHandler);
1606
+ loadGitStatus(state.selectedId, { force: true }).then(function() {
1607
+ if (!state.quickCommitOpen) return;
1608
+ rerenderQuickCommitModal();
1609
+ });
1610
+ }
1611
+
1612
+ function closeQuickCommitModal() {
1613
+ state.quickCommitOpen = false;
1614
+ state.quickCommitSubmitting = false;
1615
+ state.quickCommitError = "";
1616
+ var modal = document.getElementById("quick-commit-modal");
1617
+ if (modal) modal.classList.add("hidden");
1618
+ if (focusTrapHandler) {
1619
+ document.removeEventListener("keydown", focusTrapHandler);
1620
+ focusTrapHandler = null;
1621
+ }
1622
+ if (quickCommitEscHandler) {
1623
+ document.removeEventListener("keydown", quickCommitEscHandler);
1624
+ quickCommitEscHandler = null;
1625
+ }
1626
+ if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
1627
+ lastFocusedElement.focus();
1628
+ }
1629
+ }
1630
+
1631
+ function rerenderQuickCommitModal() {
1632
+ var modal = document.getElementById("quick-commit-modal");
1633
+ if (!modal) return;
1634
+ var html = renderQuickCommitModal();
1635
+ var temp = document.createElement("div");
1636
+ temp.innerHTML = html;
1637
+ var fresh = temp.querySelector("#quick-commit-modal");
1638
+ if (!fresh) return;
1639
+ modal.innerHTML = fresh.innerHTML;
1640
+ attachQuickCommitModalListeners();
1641
+ }
1642
+
1643
+ function attachQuickCommitModalListeners() {
1644
+ var closeBtn = document.getElementById("quick-commit-close-btn");
1645
+ if (closeBtn) closeBtn.addEventListener("click", closeQuickCommitModal);
1646
+ var cancelBtn = document.getElementById("quick-commit-cancel-btn");
1647
+ if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
1648
+ var submitBtn = document.getElementById("quick-commit-submit-btn");
1649
+ if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
1650
+ var aiBtn = document.getElementById("quick-commit-ai-btn");
1651
+ if (aiBtn) aiBtn.addEventListener("click", generateCommitMessageAI);
1652
+ var msgEl = document.getElementById("quick-commit-message");
1653
+ if (msgEl) msgEl.addEventListener("input", function() {
1654
+ state.quickCommitForm.customMessage = msgEl.value;
1655
+ });
1656
+ var tagCb = document.getElementById("quick-commit-make-tag");
1657
+ if (tagCb) tagCb.addEventListener("change", function() {
1658
+ state.quickCommitForm.makeTag = tagCb.checked;
1659
+ var row = document.getElementById("quick-commit-tag-row");
1660
+ if (row) row.classList.toggle("hidden", !tagCb.checked);
1661
+ });
1662
+ var tagInput = document.getElementById("quick-commit-tag");
1663
+ if (tagInput) tagInput.addEventListener("input", function() {
1664
+ state.quickCommitForm.tag = tagInput.value;
1665
+ });
1666
+ var pushCb = document.getElementById("quick-commit-push");
1667
+ if (pushCb) pushCb.addEventListener("change", function() {
1668
+ state.quickCommitForm.push = pushCb.checked;
1669
+ });
1670
+ }
1671
+
1672
+ function generateCommitMessageAI() {
1673
+ if (!state.selectedId || state.quickCommitGenerating) return;
1674
+ var msgEl = document.getElementById("quick-commit-message");
1675
+ if (msgEl) state.quickCommitForm.customMessage = msgEl.value;
1676
+ state.quickCommitGenerating = true;
1677
+ state.quickCommitError = "";
1678
+ rerenderQuickCommitModal();
1679
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/generate-commit-message", {
1680
+ method: "POST",
1681
+ credentials: "same-origin",
1682
+ headers: { "Content-Type": "application/json" },
1683
+ body: JSON.stringify({})
1684
+ })
1685
+ .then(function(res) {
1686
+ return res.json().then(function(data) { return { ok: res.ok, data: data }; });
1687
+ })
1688
+ .then(function(result) {
1689
+ if (!result.ok) throw new Error((result.data && result.data.error) || "AI 生成失败。");
1690
+ state.quickCommitForm.customMessage = (result.data && result.data.message) || "";
1691
+ var currentMsgEl = document.getElementById("quick-commit-message");
1692
+ if (currentMsgEl) currentMsgEl.value = state.quickCommitForm.customMessage;
1693
+ })
1694
+ .catch(function(error) {
1695
+ state.quickCommitError = (error && error.message) || "AI 生成失败。";
1696
+ })
1697
+ .finally(function() {
1698
+ state.quickCommitGenerating = false;
1699
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
1700
+ });
1701
+ }
1702
+
1703
+ function submitQuickCommit() {
1704
+ if (!state.selectedId || state.quickCommitSubmitting) return;
1705
+ var msgEl = document.getElementById("quick-commit-message");
1706
+ if (msgEl) state.quickCommitForm.customMessage = msgEl.value;
1707
+ var form = state.quickCommitForm || {};
1708
+ var userTag = form.makeTag ? (form.tag || "").trim() : "";
1709
+ var message = (form.customMessage || "").trim();
1710
+ var payload = {
1711
+ autoMessage: false,
1712
+ customMessage: message,
1713
+ tag: userTag,
1714
+ autoTag: form.makeTag && !userTag,
1715
+ push: !!form.push
1716
+ };
1717
+ if (!message) {
1718
+ state.quickCommitError = "请填写 commit message,或点击 AI 生成。";
1719
+ rerenderQuickCommitModal();
1720
+ return;
1721
+ }
1722
+ state.quickCommitSubmitting = true;
1723
+ state.quickCommitError = "";
1724
+ rerenderQuickCommitModal();
1725
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/quick-commit", {
1726
+ method: "POST",
1727
+ credentials: "same-origin",
1728
+ headers: { "Content-Type": "application/json" },
1729
+ body: JSON.stringify(payload)
1730
+ })
1731
+ .then(function(res) {
1732
+ return res.json().then(function(data) { return { ok: res.ok, data: data }; });
1733
+ })
1734
+ .then(function(result) {
1735
+ if (!result.ok) throw new Error((result.data && result.data.error) || "快捷提交失败。");
1736
+ var data = result.data || {};
1737
+ var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
1738
+ var tagName = data.tag && data.tag.name ? data.tag.name : "";
1739
+ var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
1740
+ var pushRequested = !!payload.push;
1741
+ if (pushRequested && data.pushError) {
1742
+ var msg = base + ";push 失败:" + data.pushError;
1743
+ if (typeof showToast === "function") showToast(msg, "error");
1744
+ } else {
1745
+ var okMsg = base + (data.pushed ? ",已 push" : "");
1746
+ if (typeof showToast === "function") showToast(okMsg, "success");
1747
+ }
1748
+ closeQuickCommitModal();
1749
+ if (state.selectedId) loadGitStatus(state.selectedId, { force: true });
1750
+ })
1751
+ .catch(function(error) {
1752
+ state.quickCommitError = (error && error.message) || "快捷提交失败。";
1753
+ })
1754
+ .finally(function() {
1755
+ state.quickCommitSubmitting = false;
1756
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
1757
+ });
1758
+ }
1759
+
1760
+ function renderQuickCommitModal() {
1761
+ var s = state.gitStatus || {};
1762
+ var f = state.quickCommitForm || { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
1763
+ var langValue = (state.config && (state.config.language || "")) || "";
1764
+ var langLabel = langValue ? langValue : "中文";
1765
+ var files = Array.isArray(s.files) ? s.files : [];
1766
+ var fileRows = files.map(function(item) {
1767
+ var status = (item.status || " ").substring(0, 2);
1768
+ var flag = status.trim() || "?";
1769
+ var cls = "qc-flag";
1770
+ if (flag === "A" || status[0] === "A") cls += " qc-flag-add";
1771
+ else if (flag === "D" || status[0] === "D") cls += " qc-flag-del";
1772
+ else if (flag === "M" || status[0] === "M") cls += " qc-flag-mod";
1773
+ else if (flag === "??" || status === "??") cls += " qc-flag-untracked";
1774
+ else if (flag === "R") cls += " qc-flag-ren";
1775
+ var subBadge = "";
1776
+ if (item.isSubmodule) {
1777
+ var st = item.submoduleState || {};
1778
+ var parts = [];
1779
+ if (st.commitChanged) parts.push("新指针");
1780
+ if (st.hasTrackedChanges) parts.push("dirty");
1781
+ if (st.hasUntracked) parts.push("未跟踪");
1782
+ var label = parts.length ? "submodule · " + parts.join(" / ") : "submodule";
1783
+ subBadge = '<span class="qc-submodule-badge">' + escapeHtml(label) + '</span>';
1784
+ }
1785
+ return '<div class="qc-file-row"><span class="' + cls + '">' + escapeHtml(status) + '</span><span class="qc-file-path">' + escapeHtml(item.path || "") + '</span>' + subBadge + '</div>';
1786
+ }).join("");
1787
+ if (!fileRows) fileRows = '<div class="qc-empty">工作区干净,没有可提交的改动。</div>';
1788
+ var hasChanges = (s.modifiedCount || 0) > 0;
1789
+
1790
+ return '<section id="quick-commit-modal" class="modal-backdrop' + (state.quickCommitOpen ? '' : ' hidden') + '">' +
1791
+ '<div class="modal quick-commit-modal" role="dialog" aria-labelledby="quick-commit-title">' +
1792
+ '<div class="modal-header">' +
1793
+ '<div>' +
1794
+ '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
1795
+ '<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
1796
+ '</div>' +
1797
+ '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon" type="button" aria-label="关闭">&times;</button>' +
1798
+ '</div>' +
1799
+ '<div class="modal-body">' +
1800
+ '<div class="qc-files-wrap">' + fileRows + '</div>' +
1801
+ '<div class="qc-message-row" id="quick-commit-message-row">' +
1802
+ '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
1803
+ '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
1804
+ '</div>' +
1805
+ '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成">' + escapeHtml(f.customMessage || "") + '</textarea>' +
1806
+ '</div>' +
1807
+ '<label class="qc-checkbox-row">' +
1808
+ '<input type="checkbox" id="quick-commit-make-tag"' + (f.makeTag ? ' checked' : '') + '>' +
1809
+ '<span>提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</span>' +
1810
+ '</label>' +
1811
+ '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
1812
+ '<input type="text" id="quick-commit-tag" class="field-input" placeholder="留空自动 bump patch' + (s.suggestedNextTag ? '(如 ' + escapeHtml(s.suggestedNextTag) + ')' : '') + '" value="' + escapeHtml(f.tag || "") + '">' +
1813
+ '</div>' +
1814
+ '<label class="qc-checkbox-row">' +
1815
+ '<input type="checkbox" id="quick-commit-push"' + (f.push ? ' checked' : '') + '>' +
1816
+ '<span>提交后 push 到远端</span>' +
1817
+ '</label>' +
1818
+ '<p id="quick-commit-error" class="error-message' + (state.quickCommitError ? '' : ' hidden') + '">' + escapeHtml(state.quickCommitError || "") + '</p>' +
1819
+ '<div class="worktree-merge-actions">' +
1820
+ '<button id="quick-commit-cancel-btn" class="btn btn-secondary" type="button">取消</button>' +
1821
+ '<button id="quick-commit-submit-btn" class="btn btn-primary" type="button"' + (hasChanges && !state.quickCommitSubmitting ? '' : ' disabled') + '>' + (state.quickCommitSubmitting ? '提交中…' : '执行') + '</button>' +
1822
+ '</div>' +
1823
+ '</div>' +
1824
+ '</div>' +
1825
+ '</section>';
1393
1826
  }
1394
1827
 
1395
1828
  function renderWorktreeMergeModal() {
@@ -2332,10 +2765,16 @@
2332
2765
 
2333
2766
  function applyTerminalScale() {
2334
2767
  if (!state.terminal || !state.terminal.element) return;
2335
- var newSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
2336
- var newRowHeight = (state.terminalBaseFontSize * state.terminalScale * 1.5) + "px";
2337
- state.terminal.element.style.setProperty("--term-font-size", newSize);
2338
- state.terminal.element.style.setProperty("--term-row-height", newRowHeight);
2768
+ // 字号和行高都向上取整到整数像素:PC DPR 下浏览器对亚像素
2769
+ // 字号/行高的舍入策略不一致(fontSize 16.25 16 17,行高
2770
+ // 19.5 → 19 或 20),相邻行/列的吸附方向不同就会让 wterm 网格
2771
+ // 错位。强制整数 px 让 cell 高度、字符高度都稳定一致,等价于
2772
+ // 之前桌面端必须按右上角缩放才能恢复的"整像素重排"路径。
2773
+ var rawFontSize = state.terminalBaseFontSize * state.terminalScale;
2774
+ var fontPx = Math.max(1, Math.round(rawFontSize));
2775
+ var rowPx = Math.max(1, Math.round(rawFontSize * 1.5));
2776
+ state.terminal.element.style.setProperty("--term-font-size", fontPx + "px");
2777
+ state.terminal.element.style.setProperty("--term-row-height", rowPx + "px");
2339
2778
  if (typeof state.terminal.remeasure === "function") {
2340
2779
  requestAnimationFrame(function() {
2341
2780
  if (state.terminal) state.terminal.remeasure();
@@ -2795,7 +3234,9 @@
2795
3234
  if (!session) return "";
2796
3235
  if (session.archived) return "已归档";
2797
3236
  if (session.permissionBlocked) return "等待授权";
3237
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "思考中";
2798
3238
  var statusMap = {
3239
+ "idle": "空闲",
2799
3240
  "stopped": "已停止",
2800
3241
  "running": "运行中",
2801
3242
  "exited": "已退出",
@@ -2808,6 +3249,7 @@
2808
3249
  if (!session) return "";
2809
3250
  if (session.archived) return "archived";
2810
3251
  if (session.permissionBlocked) return "permission-blocked";
3252
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "running";
2811
3253
  return session.status || "";
2812
3254
  }
2813
3255
 
@@ -2981,7 +3423,7 @@
2981
3423
  function renderProviderOptions(selectedTool) {
2982
3424
  var tools = [
2983
3425
  { id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
2984
- { id: "codex", label: "Codex", desc: "PTY 透传,全权限启动" }
3426
+ { id: "codex", label: "Codex", desc: "结构化 JSONL 或 PTY 会话" }
2985
3427
  ];
2986
3428
  return tools.map(function(tool) {
2987
3429
  var active = tool.id === selectedTool ? " active" : "";
@@ -2999,7 +3441,7 @@
2999
3441
  ];
3000
3442
  return kinds.map(function(kind) {
3001
3443
  var active = kind.id === selectedKind ? " active" : "";
3002
- var disabled = (state.sessionTool === "codex" && kind.id === "structured") ? " disabled" : "";
3444
+ var disabled = "";
3003
3445
  return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
3004
3446
  '<span class="mode-card-label">' + kind.label + '</span>' +
3005
3447
  '<span class="mode-card-desc">' + kind.desc + '</span>' +
@@ -3017,10 +3459,12 @@
3017
3459
  function getSessionKindHint(kind) {
3018
3460
  var tool = state.sessionTool || "claude";
3019
3461
  if (kind === "structured") {
3020
- return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3462
+ return tool === "codex"
3463
+ ? "Codex JSONL 结构化聊天界面,支持多轮对话和工具调用展示。"
3464
+ : "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3021
3465
  }
3022
3466
  if (tool === "codex") {
3023
- return "Codex 仅支持 PTY;terminal 是原始输出,chat 是解析后的阅读视图。";
3467
+ return "Codex PTY 终端会话;terminal 是原始输出,chat 是解析后的阅读视图。";
3024
3468
  }
3025
3469
  return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
3026
3470
  }
@@ -3408,10 +3852,9 @@
3408
3852
  if (provider) {
3409
3853
  state.sessionTool = provider;
3410
3854
  state.preferredCommand = provider;
3411
- if (provider === "codex") {
3412
- state.sessionCreateKind = "pty";
3413
- state.modeValue = "full-access";
3414
- }
3855
+ // Codex 现在同时支持 PTY 与结构化 runner,不再强制把 kind 切成 pty。
3856
+ // mode 由 syncSessionModalUI() 调用 getSafeModeForTool() 自动 clamp,
3857
+ // 不在这里硬写。
3415
3858
  syncSessionModalUI();
3416
3859
  }
3417
3860
  });
@@ -3752,6 +4195,11 @@
3752
4195
  fileInput.value = "";
3753
4196
  });
3754
4197
  }
4198
+
4199
+ var promptOptimizeBtn = document.getElementById("prompt-optimize-btn");
4200
+ if (promptOptimizeBtn) {
4201
+ promptOptimizeBtn.addEventListener("click", function() { optimizePromptText(); });
4202
+ }
3755
4203
  var composer = document.querySelector(".input-composer");
3756
4204
  if (composer) {
3757
4205
  composer.addEventListener("dragover", function(e) {
@@ -3939,7 +4387,9 @@
3939
4387
  location.reload();
3940
4388
  return;
3941
4389
  }
3942
- softRefreshCurrentView();
4390
+ softResyncTerminal();
4391
+ resetChatRenderCache();
4392
+ scheduleChatRender(true);
3943
4393
  });
3944
4394
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
3945
4395
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
@@ -4335,6 +4785,23 @@
4335
4785
  });
4336
4786
  }
4337
4787
 
4788
+ var topbarGitBadge = document.getElementById("topbar-git-badge");
4789
+ if (topbarGitBadge) {
4790
+ topbarGitBadge.addEventListener("click", function(e) {
4791
+ e.preventDefault();
4792
+ openQuickCommitModal();
4793
+ });
4794
+ }
4795
+ var quickCommitModal = document.getElementById("quick-commit-modal");
4796
+ if (quickCommitModal) {
4797
+ quickCommitModal.addEventListener("click", function(e) {
4798
+ if (e.target.id === "quick-commit-modal" && !state.quickCommitSubmitting) {
4799
+ closeQuickCommitModal();
4800
+ }
4801
+ });
4802
+ }
4803
+ attachQuickCommitModalListeners();
4804
+
4338
4805
  initTerminal();
4339
4806
  setupMobileKeyboardHandlers();
4340
4807
  setupVisualViewportHandlers();
@@ -4565,6 +5032,17 @@
4565
5032
  return distance <= state.terminalScrollThreshold;
4566
5033
  }
4567
5034
 
5035
+ // 严格"真正到底"判定(仅亚像素 jitter 容忍):用于把 autoFollow 从 false
5036
+ // 翻回 true。不能用 isTerminalNearBottom 的 12px 阈值,否则用户在底部小幅
5037
+ // 向上滚时,wheel handler 把 autoFollow 设 false 后紧接着触发的 scroll
5038
+ // 事件会因为"还没滚出阈值"而把 autoFollow 反转回 true,丢失用户意图。
5039
+ function isTerminalAtBottom() {
5040
+ var viewport = getTerminalViewport();
5041
+ if (!viewport) return true;
5042
+ var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
5043
+ return distance <= 2;
5044
+ }
5045
+
4568
5046
  function scrollTerminalToBottom(smooth) {
4569
5047
  if (!state.terminal) return;
4570
5048
  var viewport = getTerminalViewport();
@@ -4605,11 +5083,13 @@
4605
5083
  updateTerminalJumpToBottomButton();
4606
5084
  return;
4607
5085
  }
4608
- if (!state.terminalAutoFollow && !isTerminalNearBottom()) {
5086
+ // 只看 autoFollow 标志:用户主动 wheel/touch 后该标志被设为 false,
5087
+ // 即使当前位置仍在底部 12px 阈值内也不再强行滚回,避免把用户刚滚上去
5088
+ // 的几像素吞掉。autoFollow 由 scroll handler 在"真正到底"时恢复。
5089
+ if (!state.terminalAutoFollow) {
4609
5090
  updateTerminalJumpToBottomButton();
4610
5091
  return;
4611
5092
  }
4612
- state.terminalAutoFollow = true;
4613
5093
  scrollTerminalToBottom(false);
4614
5094
  updateTerminalJumpToBottomButton();
4615
5095
  }
@@ -4898,6 +5378,13 @@
4898
5378
  if (!terminal || data == null) return;
4899
5379
  if (!state.wideParserState) state.wideParserState = createWideParserState();
4900
5380
  terminal.write(widePadAnsi(data, state.wideParserState));
5381
+ // wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
5382
+ // scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
5383
+ // true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
5384
+ // 的 autoFollow 状态,让 autoFollow 成为唯一真相。
5385
+ if ("_shouldScrollToBottom" in terminal) {
5386
+ terminal._shouldScrollToBottom = state.terminalAutoFollow !== false;
5387
+ }
4901
5388
  }
4902
5389
 
4903
5390
  function resetWideParserState() {
@@ -4926,49 +5413,151 @@
4926
5413
  }
4927
5414
  stripWideFillerForCopy();
4928
5415
 
5416
+ // ── PTY 链路时序参数索引 ──────────────────────────────────────────
5417
+ // 本文件中所有影响 PTY 输入/输出节流的常量集中说明(值仍在使用处定义,
5418
+ // 方便阅读上下文,但请保持一致命名以便此索引可 grep 跳转):
5419
+ //
5420
+ // 服务端(src/ws-broadcast.ts、src/process-manager.ts):
5421
+ // OUTPUT_DEBOUNCE_MS = 16 PTY data → ws 推送 debounce
5422
+ // OUTPUT_MAX_SIZE = 200_000 record.output ring buffer 字节上限
5423
+ //
5424
+ // 客户端(本文件):
5425
+ // CLIENT_OUTPUT_MAX / CLIENT_OUTPUT_TRIM_AT state.terminalOutput 窗口
5426
+ // RESYNC_THROTTLE_MS = 400 chunk → softResync 节流最小间隔
5427
+ // RESYNC_TAIL_MS = 350 节流尾巴 timer 等待
5428
+ // RESYNC_BUDGET_* 5s 内 resync 频次告警阈值
5429
+ // CHAT_RENDER_LIVE_MS = 150 活跃流时 renderChat debounce
5430
+ // CHAT_RENDER_IDLE_MS = 30 空闲时 renderChat debounce
5431
+ // PENDING_INPUT_TTL_MS = 5000 ws 离线输入队列 TTL
5432
+ // PENDING_INPUT_MAX = 100 离线队列长度上限
5433
+ //
5434
+ // 调参时的关键不变式:
5435
+ // OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
5436
+ // RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
5437
+ // 否则会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
5438
+ var CHAT_RENDER_LIVE_MS = 150;
5439
+ var CHAT_RENDER_IDLE_MS = 30;
5440
+
5441
+ // 客户端的 state.terminalOutput 仅用于 softResyncTerminal 时重放给
5442
+ // wterm 作状态恢复,并不是用户能看到的 scrollback —— wterm 自己有
5443
+ // 独立 scrollback。但如果不限长,长跑会话累加几 MB 后每次 resync
5444
+ // 都会把整段重新喂给 wterm,CPU/内存随时间线性变差。
5445
+ //
5446
+ // 服务端 record.output 用 appendWindow(..., 200_000) 限了 200KB,这里
5447
+ // 客户端给一个稍宽的上限做兜底;超过就按行边界裁掉头部,行边界处
5448
+ // ANSI 状态机一定是 idle 状态,重放结果与未裁等价。找不到行边界时
5449
+ // 退化到字节切,并避开 UTF-16 半截、ANSI 半截。
5450
+ var CLIENT_OUTPUT_MAX = 256 * 1024;
5451
+ var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
5452
+ function clampClientTerminalOutput(buf) {
5453
+ if (!buf || buf.length <= CLIENT_OUTPUT_TRIM_AT) return buf;
5454
+ var start = buf.length - CLIENT_OUTPUT_MAX;
5455
+ // UTF-16 low surrogate
5456
+ if (start > 0 && start < buf.length) {
5457
+ var c0 = buf.charCodeAt(start);
5458
+ if (c0 >= 0xdc00 && c0 <= 0xdfff) start++;
5459
+ }
5460
+ // 优先在 lookahead 内找下一个换行符切割
5461
+ var LOOKAHEAD = 4096;
5462
+ var upper = Math.min(start + LOOKAHEAD, buf.length);
5463
+ for (var i = start; i < upper; i++) {
5464
+ if (buf.charCodeAt(i) === 0x0a) return buf.slice(i + 1);
5465
+ }
5466
+ // 没换行 → 检查 start 是否落在未结束的 ESC 序列里
5467
+ var lookback = Math.max(0, start - 256);
5468
+ var escAt = -1;
5469
+ for (var j = start - 1; j >= lookback; j--) {
5470
+ var c = buf.charCodeAt(j);
5471
+ if (c === 0x1b) { escAt = j; break; }
5472
+ if (c === 0x07) break;
5473
+ if (c >= 0x40 && c <= 0x7e) break;
5474
+ }
5475
+ if (escAt !== -1) {
5476
+ var terminated = false;
5477
+ for (var k = escAt + 1; k < start; k++) {
5478
+ var ck = buf.charCodeAt(k);
5479
+ if (ck === 0x07) { terminated = true; break; }
5480
+ if (ck >= 0x40 && ck <= 0x7e) { terminated = true; break; }
5481
+ }
5482
+ if (!terminated) {
5483
+ var ahead = Math.min(start + 256, buf.length);
5484
+ for (var m = start; m < ahead; m++) {
5485
+ var cm = buf.charCodeAt(m);
5486
+ if (cm === 0x07 || (cm >= 0x40 && cm <= 0x7e)) {
5487
+ return buf.slice(m + 1);
5488
+ }
5489
+ }
5490
+ }
5491
+ }
5492
+ return buf.slice(start);
5493
+ }
5494
+
4929
5495
  function resetTerminal() {
4930
- if (!state.terminal || typeof state.terminal.write !== "function") return;
4931
- // @wterm/dom WTerm 类没有暴露 reset() 方法(grep 全包零匹配),
4932
- // 所以早期的 state.terminal.reset() 调用是 no-op——softResyncTerminal
4933
- // 实际只做了"再写一份 terminalOutput 追加",旧 grid 不会被清空,
4934
- // 这就是"点刷新按钮没用、只有窗口尺寸变化才修"的根因。
4935
- // 改用 ANSI RIS (Reset to Initial State, ESC c) 让 WASM 状态机自己
4936
- // 重置 grid / 光标 / 属性 / scrollback,所有 VT 实现都支持这个序列。
4937
- state.terminal.write("\x1bc");
5496
+ if (!state.terminal) return;
5497
+ // 优先走 wterm-entry.js 自定义 WTerm 子类暴露的 reset():它会调用
5498
+ // bridge.init(cols, rows) WASM 重新初始化整个状态机——包含
5499
+ // grid、光标、属性 *和* scrollback。这是跨会话切换时清空旧
5500
+ // scrollback 的唯一可靠方式,避免新会话向上滚还能看到旧会话内容。
5501
+ // 单纯写 ANSI RIS (\x1bc) WASM 实现里只清当前 grid,不动 scrollback。
5502
+ if (typeof state.terminal.reset === "function") {
5503
+ state.terminal.reset();
5504
+ resetWideParserState();
5505
+ return;
5506
+ }
5507
+ if (typeof state.terminal.write === "function") {
5508
+ state.terminal.write("\x1bc");
5509
+ }
4938
5510
  resetWideParserState();
4939
5511
  }
4940
5512
 
4941
5513
  // Soft resync terminal: reset WASM grid and replay full output buffer.
4942
5514
  // Clears any stale DOM rows left over from CSI cursor-jump sequences
4943
5515
  // (e.g. Claude permission menus redrawing in place while user holds arrow keys).
4944
- function softResyncTerminal() {
5516
+ // Pass { skipFit: true } when the caller knows the grid was just sized
5517
+ // correctly (e.g. wterm.onResize fired this resync — bouncing back into
5518
+ // ensureTerminalFit would just trigger another remeasure → resize → onResize
5519
+ // → softResyncTerminal recursion).
5520
+ //
5521
+ // 重放整 buffer 而非截短:alt screen 切换 / 滚动区 / 字符集等模式开关
5522
+ // 依赖从 buffer 开头开始消费。从中间切会丢失这些状态机指令,治标变
5523
+ // 造反。H1 已经把 buffer 限到 256KB,对 wterm WASM 来说这是 ms 级开销。
5524
+ var _resyncStatsWindowStart = 0;
5525
+ var _resyncStatsCount = 0;
5526
+ var _resyncLastWarnAt = 0;
5527
+ var RESYNC_BUDGET_WINDOW_MS = 5000;
5528
+ var RESYNC_BUDGET_MAX = 12;
5529
+ var RESYNC_WARN_COOLDOWN_MS = 30000;
5530
+ function softResyncTerminal(options) {
4945
5531
  if (!state.terminal || !state.terminalOutput) return false;
5532
+ var opts = options || {};
5533
+ var bufLen = state.terminalOutput.length;
5534
+ var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
4946
5535
  resetTerminal();
4947
5536
  wandTerminalWrite(state.terminal, state.terminalOutput);
4948
5537
  state.lastTerminalResyncAt = Date.now();
4949
5538
  maybeScrollTerminalToBottom("output");
4950
- // Remeasure against real container: the refresh button used to only
4951
- // reset+write, so a stale cols/rows (set at mount time with hidden
4952
- // container) would survive the refresh and keep wrapping output wrong.
4953
- // Suppress the auto-replay branch in ensureTerminalFit — we just
4954
- // replayed, no point doing it again on the next rAF tick.
4955
- state.suppressFitReplay = true;
4956
- ensureTerminalFit("refresh");
5539
+ if (!opts.skipFit) ensureTerminalFit("refresh");
5540
+ // 统计 5s 窗口内的 resync 次数,过密时打 warn 帮助诊断
5541
+ // ——比如 wterm 状态机被反复弄脏、上游持续推原地重绘的菜单。
5542
+ // 单次 warn 后冷却 30s,避免刷屏。
5543
+ var now = Date.now();
5544
+ if (now - _resyncStatsWindowStart > RESYNC_BUDGET_WINDOW_MS) {
5545
+ _resyncStatsWindowStart = now;
5546
+ _resyncStatsCount = 1;
5547
+ } else {
5548
+ _resyncStatsCount++;
5549
+ if (_resyncStatsCount > RESYNC_BUDGET_MAX && now - _resyncLastWarnAt > RESYNC_WARN_COOLDOWN_MS) {
5550
+ _resyncLastWarnAt = now;
5551
+ var endedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
5552
+ console.warn("[wand] softResyncTerminal high frequency",
5553
+ "count=" + _resyncStatsCount + "/" + Math.round((now - _resyncStatsWindowStart) / 100) / 10 + "s",
5554
+ "bufLen=" + bufLen,
5555
+ "lastReplayMs=" + Math.round(endedAt - startedAt));
5556
+ }
5557
+ }
4957
5558
  return true;
4958
5559
  }
4959
5560
 
4960
- // Soft refresh the whole current view without losing page state:
4961
- // - Replays terminal buffer to clear residue
4962
- // - Clears chat render cache and forces a full rebuild
4963
- // Used by the refresh button and by automatic triggers
4964
- // (e.g. permission escalation appearing/disappearing).
4965
- function softRefreshCurrentView() {
4966
- softResyncTerminal();
4967
- if (typeof resetChatRenderCache === "function") resetChatRenderCache();
4968
- if (typeof scheduleChatRender === "function") scheduleChatRender(true);
4969
- else if (typeof render === "function") render();
4970
- }
4971
-
4972
5561
  function scheduleSoftResyncTerminal(delayMs) {
4973
5562
  if (state.softResyncTimer) clearTimeout(state.softResyncTimer);
4974
5563
  state.softResyncTimer = setTimeout(function() {
@@ -4977,6 +5566,51 @@
4977
5566
  }, typeof delayMs === "number" ? delayMs : 150);
4978
5567
  }
4979
5568
 
5569
+ // Claude CLI 的 permission 菜单 / 选择列表,在用户按方向键时会
5570
+ // 发送光标定位 (CSI H/f)、光标移动 (CSI A-D)、擦除显示/行 (CSI
5571
+ // J/K) 等序列在原地重绘整块区域。wterm 在这种高频原地重绘下,
5572
+ // DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
5573
+ // 用户体感就是"明明在改菜单,结果跑到最上面去了"。
5574
+ //
5575
+ // 兜底策略是"重置 wterm 状态机 + 重放整 buffer"(softResyncTerminal)。
5576
+ // 这里的关键是触发时机:旧实现用 350ms debounce,但用户实际持续
5577
+ // 按方向键时,每次按键都会让 PTY 回流一次原地重绘 chunk,timer
5578
+ // 被反复 reset,永远等不到静默期,softResync 实际从不触发——
5579
+ // 这是这个保护机制的根本逻辑错误。
5580
+ //
5581
+ // 改成 leading + tail 的节流:第一次进入立即 resync(leading),
5582
+ // 节流窗口内的连续 chunk 只挂一个尾巴 timer 兜底,不重置。这样
5583
+ // 持续按键期间每 RESYNC_THROTTLE_MS 强制 resync 一次,用户停手
5584
+ // 时由尾巴 timer 收尾。不依赖按键停顿这种永远不发生的条件。
5585
+ var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
5586
+ var RESYNC_THROTTLE_MS = 400;
5587
+ var RESYNC_TAIL_MS = 350;
5588
+ var _resyncChunkLastAt = 0;
5589
+ var _resyncChunkTailTimer = null;
5590
+ function maybeScheduleResyncForChunk(chunk) {
5591
+ if (!chunk || typeof chunk !== "string") return;
5592
+ if (chunk.indexOf("\x1b[") === -1) return;
5593
+ if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
5594
+ var now = Date.now();
5595
+ var sinceLast = now - _resyncChunkLastAt;
5596
+ if (sinceLast >= RESYNC_THROTTLE_MS) {
5597
+ if (_resyncChunkTailTimer) {
5598
+ clearTimeout(_resyncChunkTailTimer);
5599
+ _resyncChunkTailTimer = null;
5600
+ }
5601
+ _resyncChunkLastAt = now;
5602
+ softResyncTerminal();
5603
+ return;
5604
+ }
5605
+ if (_resyncChunkTailTimer) return;
5606
+ var wait = Math.max(RESYNC_TAIL_MS, RESYNC_THROTTLE_MS - sinceLast);
5607
+ _resyncChunkTailTimer = setTimeout(function() {
5608
+ _resyncChunkTailTimer = null;
5609
+ _resyncChunkLastAt = Date.now();
5610
+ softResyncTerminal();
5611
+ }, wait);
5612
+ }
5613
+
4980
5614
  function syncTerminalBuffer(sessionId, output, options) {
4981
5615
  if (!state.terminal) return false;
4982
5616
  var normalizedOutput = normalizeTerminalOutput(output || "");
@@ -5020,6 +5654,7 @@
5020
5654
  var delta = normalizedOutput.slice(currentOutput.length);
5021
5655
  if (delta) {
5022
5656
  wandTerminalWrite(state.terminal, delta);
5657
+ maybeScheduleResyncForChunk(delta);
5023
5658
  wrote = true;
5024
5659
  }
5025
5660
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -5059,10 +5694,37 @@
5059
5694
  state.terminalInitRetries = 0;
5060
5695
  state.terminalInitializing = true;
5061
5696
 
5697
+ // wterm 构造与 init() 内部都通过 getBoundingClientRect 测字符宽高,
5698
+ // 要求容器及祖先链都不是 display:none。.terminal-container 默认
5699
+ // display:none,必须 .active 才变 flex。switchToSessionView 里
5700
+ // initTerminal() 在 applyCurrentView() 之前同步执行——那时容器还是
5701
+ // display:none,_measureCharSize 返回 null → ResizeObserver 不挂
5702
+ // 载、首屏 cols 永远停在硬编码的 120,必须用户刷新/弹键盘/调窗口
5703
+ // 才能恢复。这里在创建 wterm 之前先把 active 类挂上,让容器进入
5704
+ // flex 布局,确保 _measureCharSize 拿到真实字符尺寸。
5705
+ if (state.selectedId) {
5706
+ container.classList.remove("hidden");
5707
+ container.classList.add("active");
5708
+ }
5709
+
5710
+ // 防御式清理:teardownTerminal 已经会移除残留 termWrap,但若有
5711
+ // 调用路径绕过 teardown(比如 outputContainer 被外部 render 重建),
5712
+ // 这里再扫一次确保新会话不会和旧 termWrap 叠在同一位置。
5713
+ var staleWraps = container.querySelectorAll(".terminal-scroll-wrap");
5714
+ for (var i = 0; i < staleWraps.length; i++) {
5715
+ var stale = staleWraps[i];
5716
+ if (stale.parentNode === container) container.removeChild(stale);
5717
+ }
5718
+
5062
5719
  var termWrap = document.createElement("div");
5063
5720
  termWrap.className = "terminal-scroll-wrap";
5064
5721
  container.appendChild(termWrap);
5065
5722
 
5723
+ // cols/rows 给一个保守默认即可:wterm-entry.js 重写的 init()
5724
+ // 会在 super.init() 之前按 termWrap 真实尺寸做一次预校准,
5725
+ // 保证 super.init() 里 bridge.init / renderer.setup 一上来
5726
+ // 就按真实 cols 初始化,从源头消除"先按 120 写一遍 → 异步
5727
+ // remeasure 纠正"的时序窗口。
5066
5728
  var term = new WTermLib.WTerm(termWrap, {
5067
5729
  cols: 120,
5068
5730
  rows: 36,
@@ -5074,18 +5736,15 @@
5074
5736
  },
5075
5737
  onResize: function(cols, rows) {
5076
5738
  sendTerminalResize(cols, rows);
5077
- // wterm 自身 ResizeObserver 在容器尺寸变化时主动调 resize()
5078
- // bridge.resize() grid 按新 cols 重排,但 scrollback 仍是
5079
- // 按旧 cols 写入 WASM 的、新 grid 又被清空到干净状态。wand
5080
- // 这层缓存的 terminalOutput 才是完整原始字节流,必须按新
5081
- // cols 重放一次,grid + scrollback 才会和实际历史对齐。
5082
- // 同步立即重放——不要走 setTimeout(0):移动端 WebView
5083
- // 前后台切换或键盘动画期间,macrotask 经常被推迟到 wterm
5084
- // 的下一帧 render 之后,结果用户先看到一帧空 grid 才看到
5085
- // replay 完的内容,体感上就是"刷新都没用、动一下窗口才好"。
5739
+ // wterm.resize() just ran renderer.setup() (DOM rows wiped) and
5740
+ // bridge.resize() (WASM grid reflowed). terminalOutput is the
5741
+ // canonical raw byte stream replay it now so historical lines
5742
+ // and any in-flight CSI sequences re-render at the new width.
5743
+ // skipFit: wterm already did the sizing work; calling
5744
+ // ensureTerminalFit again here would just cycle back through
5745
+ // remeasure resize → onResize → softResyncTerminal.
5086
5746
  if (state.terminal && state.terminalOutput) {
5087
- state.suppressFitReplay = true;
5088
- softResyncTerminal();
5747
+ softResyncTerminal({ skipFit: true });
5089
5748
  }
5090
5749
  }
5091
5750
  });
@@ -5103,13 +5762,32 @@
5103
5762
  state.terminal = term;
5104
5763
  state.terminalInitializing = false;
5105
5764
  applyTerminalScale();
5765
+
5766
+ // wterm 构造时 cols/rows 是硬编码的 120/36,super.init() 内部
5767
+ // 的 ResizeObserver 要等下一个 layout 阶段异步 fire 才纠正。
5768
+ // 如果不在写入历史前就把 bridge reflow 到容器真实尺寸,
5769
+ // syncTerminalBuffer 会按 120 cols 把整段历史写进 WASM grid,
5770
+ // 用户首屏看到的就是错列宽折行——必须等 ResizeObserver 触发
5771
+ // softResync 才恢复,中间会有几帧明显错位("刚开终端布局错乱
5772
+ // 一下、resize 一下才正常")。这里先强制一次 layout flush,
5773
+ // 再同步 remeasure 把 bridge 校准到真实 cols/rows,把"写入"
5774
+ // 卡在正确尺寸之后,避免错位帧。
5775
+ if (termWrap.isConnected) {
5776
+ void termWrap.offsetHeight;
5777
+ if (typeof term.remeasure === "function") {
5778
+ try { term.remeasure(); } catch (e) { /* ignore: 非致命 */ }
5779
+ }
5780
+ }
5781
+
5106
5782
  state.terminalAutoFollow = true;
5107
5783
  clearTerminalScrollIdleTimer();
5108
5784
 
5109
5785
  var viewport = getTerminalViewport();
5110
5786
  if (viewport) {
5111
5787
  state.terminalViewportScrollHandler = function() {
5112
- if (isTerminalNearBottom()) {
5788
+ // 严格"真正到底"才恢复 autoFollow:避免 wheel 设 false 后被
5789
+ // 紧接着的 scroll 事件因"接近底部 12px"而反转回 true。
5790
+ if (isTerminalAtBottom()) {
5113
5791
  state.terminalAutoFollow = true;
5114
5792
  clearTerminalScrollIdleTimer();
5115
5793
  updateTerminalJumpToBottomButton();
@@ -5152,7 +5830,7 @@
5152
5830
  // Container may have been hidden / zero-width at construction
5153
5831
  // time (hard-coded 120x36). Remeasure against the real container
5154
5832
  // so wterm reflows the just-written history to the correct cols.
5155
- ensureTerminalFit("mount");
5833
+ ensureTerminalFit("mount", { forceReplay: true });
5156
5834
  }).catch(function(err) {
5157
5835
  state.terminalInitializing = false;
5158
5836
  console.error("[wand] wterm init failed:", err);
@@ -5266,6 +5944,11 @@
5266
5944
  if (terminalInteractive) {
5267
5945
  return "终端交互模式开启中,请直接在终端中输入";
5268
5946
  }
5947
+ if (session && isStructuredSession(session)) {
5948
+ return session.provider === "codex"
5949
+ ? "向 Codex 发送消息;chat 为结构化对话视图"
5950
+ : "向 Claude 发送消息;chat 为结构化对话视图";
5951
+ }
5269
5952
  if (session && session.provider === "codex") {
5270
5953
  if (session.status !== "running") {
5271
5954
  return "Codex 会话已结束,无法继续发送";
@@ -5287,7 +5970,7 @@
5287
5970
 
5288
5971
  function getToolModeHint(tool, mode) {
5289
5972
  if (tool === "codex") {
5290
- return "Codex 当前仅支持 PTY 透传,并固定以 full-access 启动。";
5973
+ return "Codex 支持 PTY 终端与结构化(JSONL)两种会话,结构化模式按 full-access 启动。";
5291
5974
  }
5292
5975
  if (mode === "full-access") {
5293
5976
  return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
@@ -5391,15 +6074,20 @@
5391
6074
  return "";
5392
6075
  }
5393
6076
 
5394
- function renderChatModelOptions(selected) {
5395
- var models = state.availableModels || [];
6077
+ function getModelsForCurrentProvider(session) {
6078
+ var provider = (session && session.provider) || state.sessionTool || "claude";
6079
+ if (provider === "codex") return state.availableCodexModels || [];
6080
+ return state.availableModels || [];
6081
+ }
6082
+
6083
+ function renderChatModelOptions(selected, session) {
6084
+ var models = getModelsForCurrentProvider(session);
5396
6085
  var html = '<option value="">默认(跟随设置)</option>';
5397
6086
  for (var i = 0; i < models.length; i++) {
5398
6087
  var m = models[i];
5399
6088
  var label = m.label || m.id;
5400
6089
  html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
5401
6090
  }
5402
- // If selected is unknown (custom value), prepend it as a sticky option
5403
6091
  if (selected && !models.some(function(m) { return m.id === selected; })) {
5404
6092
  html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
5405
6093
  }
@@ -5410,7 +6098,7 @@
5410
6098
  var select = document.getElementById("chat-model-select");
5411
6099
  if (!select) return;
5412
6100
  var effective = getEffectiveModel(session);
5413
- select.innerHTML = renderChatModelOptions(effective);
6101
+ select.innerHTML = renderChatModelOptions(effective, session);
5414
6102
  select.value = effective;
5415
6103
  }
5416
6104
 
@@ -5420,6 +6108,7 @@
5420
6108
  .then(function(data) {
5421
6109
  if (data && Array.isArray(data.models)) {
5422
6110
  state.availableModels = data.models;
6111
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5423
6112
  syncComposerModelSelect(getSelectedSession());
5424
6113
  updateSettingsDefaultModelSelect(data);
5425
6114
  }
@@ -5438,6 +6127,7 @@
5438
6127
  .then(function(data) {
5439
6128
  if (data && Array.isArray(data.models)) {
5440
6129
  state.availableModels = data.models;
6130
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5441
6131
  syncComposerModelSelect(getSelectedSession());
5442
6132
  updateSettingsDefaultModelSelect(data);
5443
6133
  if (typeof showToast === "function") {
@@ -5461,7 +6151,7 @@
5461
6151
  if (!select) return;
5462
6152
  var previous = select.value;
5463
6153
  var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
5464
- select.innerHTML = renderChatModelOptions(current);
6154
+ select.innerHTML = renderChatModelOptions(current, { provider: "claude" });
5465
6155
  select.value = current;
5466
6156
  var versionEl = document.getElementById("cfg-default-model-version");
5467
6157
  if (versionEl && data) {
@@ -5483,7 +6173,6 @@
5483
6173
  try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
5484
6174
  var session = getSelectedSession();
5485
6175
  if (!session) return;
5486
- if (session.provider && session.provider !== "claude") return;
5487
6176
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
5488
6177
  method: "POST",
5489
6178
  headers: { "Content-Type": "application/json" },
@@ -5500,7 +6189,8 @@
5500
6189
  updateSessionSnapshot(data);
5501
6190
  if (typeof showToast === "function") {
5502
6191
  var display = normalized || "默认";
5503
- showToast("已切换模型 " + display, "success");
6192
+ var hint = session.provider === "codex" ? "(下次对话生效)" : "";
6193
+ showToast("已切换模型 → " + display + hint, "success");
5504
6194
  }
5505
6195
  }
5506
6196
  })
@@ -5508,11 +6198,13 @@
5508
6198
  }
5509
6199
 
5510
6200
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
6201
+ var provider = state.sessionTool === "codex" ? "codex" : "claude";
5511
6202
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
5512
6203
  var payload = {
5513
6204
  cwd: cwdOverride || getEffectiveCwd(),
5514
6205
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
5515
- runner: state.structuredRunner || "claude-cli-print",
6206
+ provider: provider,
6207
+ runner: provider === "codex" ? "codex-cli-exec" : (state.structuredRunner || "claude-cli-print"),
5516
6208
  prompt: prompt || undefined,
5517
6209
  worktreeEnabled: worktreeEnabled === true,
5518
6210
  model: modelPref || undefined
@@ -5582,11 +6274,6 @@
5582
6274
  var tool = state.sessionTool || "claude";
5583
6275
  var sessionKind = state.sessionCreateKind || "structured";
5584
6276
 
5585
- if (tool === "codex" && sessionKind === "structured") {
5586
- sessionKind = "pty";
5587
- state.sessionCreateKind = "pty";
5588
- }
5589
-
5590
6277
  state.sessionTool = tool;
5591
6278
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
5592
6279
 
@@ -5603,7 +6290,7 @@
5603
6290
  if (kindCards.length) {
5604
6291
  kindCards.forEach(function(card) {
5605
6292
  var kind = card.getAttribute("data-session-kind");
5606
- var disabled = tool === "codex" && kind === "structured";
6293
+ var disabled = false;
5607
6294
  card.classList.toggle("active", kind === sessionKind);
5608
6295
  card.classList.toggle("disabled", disabled);
5609
6296
  });
@@ -5812,6 +6499,9 @@
5812
6499
  }
5813
6500
  }
5814
6501
  updateShellChrome();
6502
+ if (state.selectedId && state.gitStatusSessionId !== state.selectedId) {
6503
+ loadGitStatus(state.selectedId);
6504
+ }
5815
6505
 
5816
6506
  var reloadPromise = Promise.resolve();
5817
6507
  if (!opts.skipSelectedOutputReload && state.selectedId) {
@@ -5973,10 +6663,35 @@
5973
6663
  updateShellChrome();
5974
6664
 
5975
6665
  if (state.terminal && id === state.selectedId && data.output !== undefined) {
5976
- syncTerminalBuffer(id, data.output, { mode: "append" });
5977
- // Session-switch / history replay: force a real fit so wterm
5978
- // reflows the just-written output against the real container.
5979
- ensureTerminalFit("session-switch");
6666
+ // ws 在线时不要在这里写终端:HTTP 这边返回的是 PTY transcript
6667
+ // 完整磁盘文件(可达数十 MB),ws 订阅 init 拿到的是内存 ring
6668
+ // buffer 末尾窗口(约 200KB),二者长度+起点都不同。两路都
6669
+ // syncTerminalBuffer 时,append 模式的前缀检查必然失败,
6670
+ // 落到 else 分支的 reset+全量重写,与 ws init 的 reset+
6671
+ // 写入交叠,造成首屏「两份内容错位重叠」。
6672
+ // 设计原则:terminal 写入只走 ws init 与 chunk hot-path 两条
6673
+ // 权威路径——参见 case "init" 的 replace 写入与 onmessage
6674
+ // chunk 处理。这里只在 ws 离线兜底时才 append 写入。
6675
+ //
6676
+ // wsLikelyTakingOver: 即使 wsConnected=false(onopen 还没 fire),
6677
+ // 只要 ws.readyState 是 CONNECTING 或 OPEN,就视为 ws 即将
6678
+ // 接管。否则 selectSession → loadOutput resolve 比 ws onopen
6679
+ // 早时(常见于刷新页面后的首次连接)会误走 fallback,写入
6680
+ // terminal 后 ws init 又写一次,造成双路重叠。
6681
+ var wsLikelyTakingOver = !!state.ws && (
6682
+ state.ws.readyState === WebSocket.OPEN ||
6683
+ state.ws.readyState === WebSocket.CONNECTING
6684
+ );
6685
+ if (!wsLikelyTakingOver) {
6686
+ syncTerminalBuffer(id, data.output, { mode: "append" });
6687
+ // 离线兜底路径自己负责 fit + replay,否则尺寸不对。
6688
+ ensureTerminalFit("session-switch", { forceReplay: true });
6689
+ } else {
6690
+ // ws 在线/连接中:仅校准列宽,不重 replay(init 的
6691
+ // ensureTerminalFitWithRetry("init") 会负责按真实
6692
+ // 宽度的全量基线写入)。
6693
+ ensureTerminalFit("session-switch");
6694
+ }
5980
6695
  }
5981
6696
 
5982
6697
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
@@ -5991,6 +6706,9 @@
5991
6706
  if (!foundSession) {
5992
6707
  return;
5993
6708
  }
6709
+ if (state.selectedId !== id) {
6710
+ teardownTerminal();
6711
+ }
5994
6712
  state.selectedId = id;
5995
6713
  persistSelectedId();
5996
6714
  state.toolContentCache = {};
@@ -6021,6 +6739,11 @@
6021
6739
  }
6022
6740
  loadOutput(id).then(function() { focusInputBox(true); });
6023
6741
  subscribeToSession(id);
6742
+ // 切换会话时清掉旧 git 状态,再异步刷新
6743
+ state.gitStatus = null;
6744
+ state.gitStatusSessionId = null;
6745
+ updateTopbarGitBadge();
6746
+ loadGitStatus(id, { force: true });
6024
6747
  }
6025
6748
 
6026
6749
  function updatePinState() {
@@ -6120,7 +6843,7 @@
6120
6843
  lastFocusedElement = document.activeElement;
6121
6844
  state.sessionTool = getPreferredTool();
6122
6845
  state.preferredCommand = state.sessionTool;
6123
- state.sessionCreateKind = state.sessionTool === "codex" ? "pty" : "structured";
6846
+ state.sessionCreateKind = "structured";
6124
6847
  state.sessionCreateWorktree = false;
6125
6848
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
6126
6849
  syncSessionModalUI();
@@ -7327,6 +8050,29 @@
7327
8050
  el.classList.remove("hidden");
7328
8051
  }
7329
8052
 
8053
+ // 创建 PTY 会话时把当前终端的真实 cols/rows 注入 body,让后端 pty.spawn
8054
+ // 直接落在正确尺寸下。否则 PTY 先按 cols=120 启动,Claude/Codex 会基于
8055
+ // 120 列输出 \x1b[120G 这类绝对列定位序列;等前端 remeasure 触发 resize
8056
+ // 时这些早期内容已经被以 80 等真实列数渲染,整条历史就错位。
8057
+ function withTerminalDimensions(body) {
8058
+ if (!body || typeof body !== "object") return body;
8059
+ if (!state.terminal) return body;
8060
+ try {
8061
+ if (typeof state.terminal.remeasure === "function") {
8062
+ state.terminal.remeasure();
8063
+ }
8064
+ } catch (e) {}
8065
+ var cols = state.terminal.cols;
8066
+ var rows = state.terminal.rows;
8067
+ if (typeof cols === "number" && typeof rows === "number"
8068
+ && Number.isFinite(cols) && Number.isFinite(rows)
8069
+ && cols > 0 && rows > 0) {
8070
+ body.cols = cols;
8071
+ body.rows = rows;
8072
+ }
8073
+ return body;
8074
+ }
8075
+
7330
8076
  function quickStartSession() {
7331
8077
  var command = getPreferredTool();
7332
8078
  var defaultCwd = getEffectiveCwd();
@@ -7337,7 +8083,7 @@
7337
8083
  method: "POST",
7338
8084
  headers: { "Content-Type": "application/json" },
7339
8085
  credentials: "same-origin",
7340
- body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
8086
+ body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
7341
8087
  })
7342
8088
  .then(function(res) { return res.json(); })
7343
8089
  .then(function(data) {
@@ -7382,12 +8128,13 @@
7382
8128
  }
7383
8129
 
7384
8130
  function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
7385
- console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
8131
+ var provider = state.sessionTool === "codex" ? "codex" : "claude";
8132
+ console.log("[WAND] startStructuredSessionFromModal provider:", provider, "cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
7386
8133
  _sessionCreating = true;
7387
8134
  state.modeValue = mode;
7388
8135
  state.chatMode = mode;
7389
- state.sessionTool = "claude";
7390
- state.preferredCommand = "claude";
8136
+ state.sessionTool = provider;
8137
+ state.preferredCommand = provider;
7391
8138
  syncComposerModeSelect();
7392
8139
  syncComposerModelSelect(getSelectedSession());
7393
8140
  return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
@@ -7417,13 +8164,13 @@
7417
8164
  method: "POST",
7418
8165
  headers: { "Content-Type": "application/json" },
7419
8166
  credentials: "same-origin",
7420
- body: JSON.stringify({
8167
+ body: JSON.stringify(withTerminalDimensions({
7421
8168
  command: command,
7422
8169
  provider: command,
7423
8170
  cwd: cwd,
7424
8171
  mode: mode,
7425
8172
  worktreeEnabled: worktreeEnabled
7426
- })
8173
+ }))
7427
8174
  })
7428
8175
  .then(function(res) { return res.json(); })
7429
8176
  .then(function(data) {
@@ -7914,6 +8661,101 @@
7914
8661
  }
7915
8662
  }
7916
8663
 
8664
+ var promptOptimizeInFlight = false;
8665
+ function optimizePromptText() {
8666
+ if (promptOptimizeInFlight) return;
8667
+ var inputBox = document.getElementById("input-box");
8668
+ var btn = document.getElementById("prompt-optimize-btn");
8669
+ var composer = document.querySelector(".input-composer");
8670
+ if (!inputBox) return;
8671
+ var raw = (inputBox.value || "").trim();
8672
+ if (!raw) {
8673
+ if (typeof showToast === "function") showToast("请先输入要优化的内容。", "info");
8674
+ inputBox.focus();
8675
+ return;
8676
+ }
8677
+ promptOptimizeInFlight = true;
8678
+ if (btn) {
8679
+ btn.classList.add("is-loading");
8680
+ btn.disabled = true;
8681
+ btn.setAttribute("title", "正在优化…");
8682
+ }
8683
+ if (composer) composer.classList.add("is-optimizing");
8684
+ inputBox.setAttribute("aria-busy", "true");
8685
+ var prevReadOnly = inputBox.readOnly;
8686
+ inputBox.readOnly = true;
8687
+
8688
+ var payload = { text: raw };
8689
+ if (state && state.selectedId) payload.sessionId = state.selectedId;
8690
+
8691
+ fetch("/api/optimize-prompt", {
8692
+ method: "POST",
8693
+ credentials: "same-origin",
8694
+ headers: { "Content-Type": "application/json" },
8695
+ body: JSON.stringify(payload)
8696
+ })
8697
+ .then(function(res) {
8698
+ return res.json().then(function(data) { return { ok: res.ok, data: data }; });
8699
+ })
8700
+ .then(function(result) {
8701
+ if (!result.ok) throw new Error((result.data && result.data.error) || "提示词优化失败。");
8702
+ var optimized = (result.data && result.data.optimized) || "";
8703
+ if (!optimized) throw new Error("Claude 返回为空。");
8704
+ animateOptimizedReplace(inputBox, optimized);
8705
+ })
8706
+ .catch(function(error) {
8707
+ if (typeof showToast === "function") showToast((error && error.message) || "提示词优化失败。", "error");
8708
+ if (btn) {
8709
+ btn.classList.remove("is-loading");
8710
+ btn.classList.add("is-shake");
8711
+ setTimeout(function() { if (btn) btn.classList.remove("is-shake"); }, 400);
8712
+ }
8713
+ })
8714
+ .finally(function() {
8715
+ promptOptimizeInFlight = false;
8716
+ if (btn) {
8717
+ btn.classList.remove("is-loading");
8718
+ btn.disabled = false;
8719
+ btn.setAttribute("title", "提示词优化(AI)");
8720
+ }
8721
+ if (composer) composer.classList.remove("is-optimizing");
8722
+ inputBox.removeAttribute("aria-busy");
8723
+ inputBox.readOnly = prevReadOnly;
8724
+ });
8725
+ }
8726
+
8727
+ function animateOptimizedReplace(inputBox, finalText) {
8728
+ if (!inputBox) return;
8729
+ // Typewriter-style fill so user sees the replacement happen
8730
+ var chars = Array.from(finalText);
8731
+ var total = chars.length;
8732
+ if (total === 0) {
8733
+ inputBox.value = "";
8734
+ setDraftValue("", true);
8735
+ autoResizeInput(inputBox);
8736
+ return;
8737
+ }
8738
+ var totalDuration = Math.min(700, Math.max(220, total * 8));
8739
+ var stepCount = Math.min(total, 60);
8740
+ var charsPerStep = Math.ceil(total / stepCount);
8741
+ var stepDelay = totalDuration / stepCount;
8742
+ var i = 0;
8743
+ inputBox.value = "";
8744
+ autoResizeInput(inputBox);
8745
+ function tick() {
8746
+ i = Math.min(total, i + charsPerStep);
8747
+ inputBox.value = chars.slice(0, i).join("");
8748
+ autoResizeInput(inputBox);
8749
+ if (i < total) {
8750
+ setTimeout(tick, stepDelay);
8751
+ } else {
8752
+ setDraftValue(finalText, true);
8753
+ try { inputBox.setSelectionRange(finalText.length, finalText.length); } catch (e) { /* ignore */ }
8754
+ }
8755
+ }
8756
+ tick();
8757
+ }
8758
+
7917
8759
  function autoResizeInput(el) {
7918
8760
  if (!el) return;
7919
8761
  var minHeight = 36;
@@ -7948,6 +8790,9 @@
7948
8790
  function isSelectedSessionRunning() {
7949
8791
  if (!state.selectedId) return false;
7950
8792
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
8793
+ if (isStructuredSession(selectedSession)) {
8794
+ return !!(selectedSession.structuredState && selectedSession.structuredState.inFlight);
8795
+ }
7951
8796
  return !!selectedSession && selectedSession.status === "running";
7952
8797
  }
7953
8798
 
@@ -7957,6 +8802,9 @@
7957
8802
 
7958
8803
  function hasAnyBusySession() {
7959
8804
  return state.sessions.some(function(s) {
8805
+ if (isStructuredSession(s)) {
8806
+ return !!(s.structuredState && s.structuredState.inFlight) && !s.archived;
8807
+ }
7960
8808
  return s.status === "running" && !s.archived;
7961
8809
  });
7962
8810
  }
@@ -7986,12 +8834,12 @@
7986
8834
  method: "POST",
7987
8835
  headers: { "Content-Type": "application/json" },
7988
8836
  credentials: "same-origin",
7989
- body: JSON.stringify({
8837
+ body: JSON.stringify(withTerminalDimensions({
7990
8838
  command: item.tool,
7991
8839
  cwd: item.cwd,
7992
8840
  mode: item.mode,
7993
8841
  initialInput: item.text
7994
- })
8842
+ }))
7995
8843
  })
7996
8844
  .then(function(res) { return res.json(); })
7997
8845
  .then(function(data) {
@@ -8026,12 +8874,12 @@
8026
8874
  method: "POST",
8027
8875
  headers: { "Content-Type": "application/json" },
8028
8876
  credentials: "same-origin",
8029
- body: JSON.stringify({
8877
+ body: JSON.stringify(withTerminalDimensions({
8030
8878
  command: item.tool,
8031
8879
  cwd: item.cwd,
8032
8880
  mode: item.mode,
8033
8881
  initialInput: item.text
8034
- })
8882
+ }))
8035
8883
  })
8036
8884
  .then(function(res) { return res.json(); })
8037
8885
  .then(function(data) {
@@ -8207,13 +9055,13 @@
8207
9055
  method: "POST",
8208
9056
  headers: { "Content-Type": "application/json" },
8209
9057
  credentials: "same-origin",
8210
- body: JSON.stringify({
9058
+ body: JSON.stringify(withTerminalDimensions({
8211
9059
  command: preferredTool,
8212
9060
  provider: preferredTool,
8213
9061
  cwd: defaultCwd,
8214
9062
  mode: mode,
8215
9063
  initialInput: value
8216
- })
9064
+ }))
8217
9065
  })
8218
9066
  .then(function(res) { return res.json(); })
8219
9067
  .then(function(data) {
@@ -8277,13 +9125,13 @@
8277
9125
  method: "POST",
8278
9126
  headers: { "Content-Type": "application/json" },
8279
9127
  credentials: "same-origin",
8280
- body: JSON.stringify({
9128
+ body: JSON.stringify(withTerminalDimensions({
8281
9129
  command: preferredTool,
8282
9130
  provider: preferredTool,
8283
9131
  cwd: defaultCwd,
8284
9132
  mode: mode,
8285
9133
  initialInput: value || undefined
8286
- })
9134
+ }))
8287
9135
  })
8288
9136
  .then(function(res) { return res.json(); })
8289
9137
  .then(function(data) {
@@ -8352,7 +9200,7 @@
8352
9200
  // Container just flipped from hidden -> visible (or geometry changed
8353
9201
  // because chat/terminal panels swapped). Refit now so the terminal
8354
9202
  // picks up the real cols/rows instead of keeping the stale ones.
8355
- if (!structured) ensureTerminalFit("view-switch");
9203
+ if (!structured) ensureTerminalFit("view-switch", { forceReplay: true });
8356
9204
  }
8357
9205
 
8358
9206
 
@@ -8413,7 +9261,8 @@
8413
9261
 
8414
9262
  return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
8415
9263
  if (!readySession) {
8416
- showToast("会话未就绪,将稍后重试。", "info");
9264
+ // ensureSessionReadyForInput / resumeClaudeSessionById 已经在失败路径里
9265
+ // 自行 toast,这里不再重复提示,避免叠两条消息。
8417
9266
  return null;
8418
9267
  }
8419
9268
  var submitView = state.currentView;
@@ -8478,7 +9327,9 @@
8478
9327
  // the HTTP response arrives.
8479
9328
  var epochBeforePost = state.queueEpoch;
8480
9329
 
8481
- return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
9330
+ // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
9331
+ // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
9332
+ return fetch("/api/structured-sessions/" + session.id + "/messages", {
8482
9333
  method: "POST",
8483
9334
  headers: { "Content-Type": "application/json" },
8484
9335
  credentials: "same-origin",
@@ -8501,11 +9352,14 @@
8501
9352
  delete snapshot.queuedMessages;
8502
9353
  }
8503
9354
  updateSessionSnapshot(snapshot);
8504
- var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
8505
- state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
8506
- renderChat(true);
8507
- if (isInterrupting) {
8508
- showToast("已中断上一条回复,正在处理新消息…", "info");
9355
+ // 仅当 snapshot 仍属当前选中会话时才覆盖视图状态,否则只更新底层数据。
9356
+ if (snapshot.id === state.selectedId) {
9357
+ var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
9358
+ state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
9359
+ renderChat(true);
9360
+ if (isInterrupting) {
9361
+ showToast("已中断上一条回复,正在处理新消息…", "info");
9362
+ }
8509
9363
  }
8510
9364
  }
8511
9365
  })
@@ -8646,7 +9500,10 @@
8646
9500
  }
8647
9501
 
8648
9502
  function canAutoResumeSession(session) {
8649
- return !!(session && session.provider === "claude" && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
9503
+ // 只要是 Claude provider + 非运行中 + claudeSessionId
9504
+ // 就允许在用户发送时静默触发恢复。不再要求 messages 里同时
9505
+ // 有 user + assistant 文本(slim 列表/截断历史会让该判断失真)。
9506
+ return !!(session && session.provider === "claude" && session.status !== "running" && session.claudeSessionId);
8650
9507
  }
8651
9508
 
8652
9509
  function ensureSessionReadyForInput(session, errorEl) {
@@ -8663,7 +9520,7 @@
8663
9520
  return Promise.resolve(null);
8664
9521
  }
8665
9522
 
8666
- showToast("正在恢复历史会话…", "info");
9523
+ // 静默恢复:不再弹 "正在恢复历史会话…" 提示,让用户发送动作看起来无缝。
8667
9524
  return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
8668
9525
  if (!data) return null;
8669
9526
  updateSessionSnapshot(data);
@@ -8702,13 +9559,26 @@
8702
9559
  }, Promise.resolve());
8703
9560
  }
8704
9561
 
9562
+ // pendingMessages 用于 ws 离线时缓存输入,重连后批量回放。
9563
+ // 旧实现:按字符串入队,仅靠长度上限 100 控制;超出 shift 最早一条。
9564
+ // 问题:用户连续按方向键时几秒就把队列填满;shift 把最早按下的丢
9565
+ // 掉,剩下的反而是后期按的——重连后回放的"输入序列"和用户实际
9566
+ // 按下的顺序矛盾。给每条消息打时间戳,flush 时直接丢弃过期项,
9567
+ // 让"离线超过 N 秒后重连"恢复成一个干净状态而不是错位重放。
9568
+ var PENDING_INPUT_TTL_MS = 5000;
9569
+ var PENDING_INPUT_MAX = 100;
9570
+ function enqueuePendingInput(input) {
9571
+ if (!input) return;
9572
+ if (state.pendingMessages.length >= PENDING_INPUT_MAX) {
9573
+ state.pendingMessages.shift();
9574
+ }
9575
+ state.pendingMessages.push({ input: input, at: Date.now() });
9576
+ }
9577
+
8705
9578
  function queueOfflineTerminalChunks(chunks) {
8706
9579
  var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
8707
9580
  sequence.forEach(function(chunk) {
8708
- if (state.pendingMessages.length >= 100) {
8709
- state.pendingMessages.shift();
8710
- }
8711
- state.pendingMessages.push(chunk);
9581
+ enqueuePendingInput(chunk);
8712
9582
  });
8713
9583
  }
8714
9584
 
@@ -8726,16 +9596,21 @@
8726
9596
 
8727
9597
  function postInput(input, shortcutKey, viewOverride) {
8728
9598
  if (!state.selectedId) return Promise.resolve();
9599
+ // 锁定本次请求归属的 sessionId。fetch 发起后用户可能切到别的会话,
9600
+ // 后续 then 回调里直接用 state.selectedId 会误把 A 的响应应用到 B:
9601
+ // - URL 上拼错会话(虽然 fetch 已经求值过 URL,但 markSessionStopped
9602
+ // 等 in-flight 引用会读最新值 → 把 B 标为 stopped 但实际是 A 失败)
9603
+ // - response.snapshot 属于 A,被 setCurrentMessages 误覆盖到 B 视图
9604
+ // 用 requestSessionId 锁住请求方,渲染相关动作再单独判断 snapshot.id
9605
+ // === 当前 state.selectedId 才执行。
9606
+ var requestSessionId = state.selectedId;
8729
9607
  var effectiveView = viewOverride || state.currentView;
8730
9608
 
8731
9609
  // Pre-check: don't send if session is not running
8732
9610
  if (!isSelectedSessionRunning()) {
8733
9611
  // If WebSocket is disconnected, queue for flush on reconnect
8734
9612
  if (!state.wsConnected) {
8735
- if (state.pendingMessages.length >= 100) {
8736
- state.pendingMessages.shift();
8737
- }
8738
- state.pendingMessages.push(input);
9613
+ enqueuePendingInput(input);
8739
9614
  console.log("[wand] postInput: session not running, queued for reconnect", {
8740
9615
  sessionId: state.selectedId,
8741
9616
  inputLength: input.length
@@ -8751,10 +9626,7 @@
8751
9626
 
8752
9627
  // If WebSocket is disconnected, queue the message (no HTTP fetch while offline)
8753
9628
  if (!state.wsConnected) {
8754
- if (state.pendingMessages.length >= 100) {
8755
- state.pendingMessages.shift();
8756
- }
8757
- state.pendingMessages.push(input);
9629
+ enqueuePendingInput(input);
8758
9630
  console.log("[wand] postInput: WebSocket disconnected, queued message", {
8759
9631
  sessionId: state.selectedId,
8760
9632
  inputLength: input.length
@@ -8769,7 +9641,7 @@
8769
9641
  wsConnected: state.wsConnected
8770
9642
  });
8771
9643
 
8772
- return fetch("/api/sessions/" + state.selectedId + "/input", {
9644
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
8773
9645
  method: "POST",
8774
9646
  headers: { "Content-Type": "application/json" },
8775
9647
  credentials: "same-origin",
@@ -8784,11 +9656,11 @@
8784
9656
  status: res.status,
8785
9657
  errorCode: error.errorCode,
8786
9658
  message: error.message,
8787
- sessionId: state.selectedId
9659
+ sessionId: requestSessionId
8788
9660
  });
8789
9661
  // Mark session as stopped for unavailable errors
8790
9662
  if (isSessionUnavailableError(error)) {
8791
- markSessionStopped(state.selectedId, error.sessionStatus || "exited");
9663
+ markSessionStopped(requestSessionId, error.sessionStatus || "exited");
8792
9664
  }
8793
9665
  throw error;
8794
9666
  });
@@ -8797,11 +9669,18 @@
8797
9669
  })
8798
9670
  .then(function(snapshot) {
8799
9671
  if (snapshot && snapshot.id) {
9672
+ // 底层 sessions 数据按 id 索引,无论是否仍是当前选中都可以
9673
+ // 安全更新(不会污染其他会话)。
8800
9674
  updateSessionSnapshot(snapshot);
8801
- if (snapshot.messages && snapshot.messages.length > 0) {
8802
- state.currentMessages = snapshot.messages;
9675
+ // currentMessages / renderChat 是当前视图状态,必须仅当
9676
+ // snapshot 仍属当前选中会话时才执行;否则会把 A 的消息列表
9677
+ // 渲染到 B 的 chat 视图。
9678
+ if (snapshot.id === state.selectedId) {
9679
+ if (snapshot.messages && snapshot.messages.length > 0) {
9680
+ state.currentMessages = snapshot.messages;
9681
+ }
9682
+ renderChat(true);
8803
9683
  }
8804
- renderChat(true);
8805
9684
  }
8806
9685
  return snapshot;
8807
9686
  });
@@ -8823,6 +9702,18 @@
8823
9702
  return !!state.selectedId && state.currentView === "terminal";
8824
9703
  }
8825
9704
 
9705
+ // 判断一条带 sessionId 的 ws 消息是否应该被当前 wterm 实例消费。
9706
+ // 收敛多处散落的"selectedId 一致 + terminalSessionId 兼容"判断,避免
9707
+ // 后续重构时漏改某一处导致旧会话的输出污染当前终端。
9708
+ // terminalSessionId 为空(尚未首次 init/切换刚发生)视为可接受任何
9709
+ // sessionId —— 这是首条 chunk 触发自我初始化的场景。
9710
+ function isCurrentTerminalSession(sessionId) {
9711
+ if (!state.terminal || !sessionId) return false;
9712
+ if (sessionId !== state.selectedId) return false;
9713
+ if (state.terminalSessionId && state.terminalSessionId !== sessionId) return false;
9714
+ return true;
9715
+ }
9716
+
8826
9717
  function shouldCaptureTerminalEvent(event) {
8827
9718
  if (!state.terminalInteractive || !isTerminalInteractionAvailable()) return false;
8828
9719
  if (event.defaultPrevented || event.isComposing) return false;
@@ -8990,7 +9881,9 @@
8990
9881
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
8991
9882
  var structured = isStructuredSession(selectedSession);
8992
9883
  var isCodex = selectedSession && selectedSession.provider === "codex";
8993
- var isRunning = !!selectedSession && selectedSession.status === "running";
9884
+ var isRunning = structured
9885
+ ? !!(selectedSession && selectedSession.structuredState && selectedSession.structuredState.inFlight)
9886
+ : !!selectedSession && selectedSession.status === "running";
8994
9887
  var composer = document.getElementById("input-box");
8995
9888
  // Update both toggle buttons (topbar and terminal-header)
8996
9889
  var toggles = ["terminal-interactive-toggle-top"];
@@ -9015,18 +9908,28 @@
9015
9908
  : "Enter 发送 · Shift+Enter 换行";
9016
9909
  }
9017
9910
  }
9018
- var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
9911
+ var disableStructuredInput = false;
9912
+ // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
9913
+ // 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9914
+ var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
9019
9915
  if (composer) {
9020
9916
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
9021
- composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
9917
+ composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9022
9918
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
9919
+ // 终端交互模式下,按键由 document capture phase 直接透传到 PTY;
9920
+ // 把 textarea 设为 readonly 避免浏览器同时把字符落进输入框
9921
+ // (IME 组合输入、preventDefault 不彻底等边界场景下会出现"键
9922
+ // 发到了 PTY、输入框里也留下了字"的双状态)。disabled 会让
9923
+ // textarea 失去焦点能力影响一些场景,readOnly 更轻、保留焦点。
9924
+ composer.readOnly = !!state.terminalInteractive;
9925
+ composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
9023
9926
  }
9024
9927
  var sendBtn = document.getElementById("send-input-button");
9025
9928
  if (sendBtn) {
9026
- sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
9027
- sendBtn.setAttribute("title", isCodex
9028
- ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
9029
- : (structured ? "发送" : (!selectedSession || isRunning ? "发送" : "会话已结束")));
9929
+ sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9930
+ sendBtn.setAttribute("title", structured
9931
+ ? "发送"
9932
+ : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
9030
9933
  }
9031
9934
  var container = document.getElementById("output");
9032
9935
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -9057,6 +9960,7 @@
9057
9960
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: state.modifiers.shift });
9058
9961
  if (sequence) sendTerminalSequence(sequence, key);
9059
9962
  clearModifiers();
9963
+ scheduleShortcutResync();
9060
9964
  }
9061
9965
 
9062
9966
  function handleInlineKeyboardClick(event) {
@@ -9073,12 +9977,28 @@
9073
9977
  if (key === "ctrl_enter") {
9074
9978
  var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
9075
9979
  if (sequence) sendTerminalSequence(sequence, "ctrl_enter");
9980
+ scheduleShortcutResync();
9076
9981
  return;
9077
9982
  }
9078
9983
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: false });
9079
9984
  if (sequence) sendTerminalSequence(sequence, key);
9080
9985
  clearModifiers();
9081
9986
  updateKeyboardPopupUI();
9987
+ scheduleShortcutResync();
9988
+ }
9989
+
9990
+ // 用户点击下方快捷键栏(↑↓←→/Enter/Esc 等)后,PTY 通常会回流大量
9991
+ // 原地重绘序列(CSI A-D / J / K / H / f)。maybeScheduleResyncForChunk
9992
+ // 已经在收到这类 chunk 时做节流 resync,但 wterm 状态机偶尔会漏抓
9993
+ // (比如 Codex 的菜单切换),导致 DOM 行残留 / 错位 —— 表现就是用户
9994
+ // 反馈"按方向键之后画面错位,必须按一下右上角缩放才恢复"。
9995
+ // 这里在每次快捷键点击之后安排一次延迟的 softResyncTerminal 兜底,
9996
+ // 等价于自动按一次缩放按钮:reset 状态机 + 重放 buffer,把残留的
9997
+ // 错位洗掉。延迟 ~500ms 是为了让服务端先把这次按键的回执完整推过来,
9998
+ // 避免 resync 时只回放到 chunk 一半。
9999
+ function scheduleShortcutResync() {
10000
+ if (!state.terminal) return;
10001
+ scheduleSoftResyncTerminal(500);
9082
10002
  }
9083
10003
 
9084
10004
  function updateKeyboardPopupUI() {
@@ -9197,8 +10117,20 @@
9197
10117
 
9198
10118
  // Send queued messages in order, bypassing the session-running check
9199
10119
  // since our local state may be stale right after reconnect
9200
- var queue = state.pendingMessages.slice();
10120
+ var now = Date.now();
10121
+ var queue = [];
10122
+ var dropped = 0;
10123
+ state.pendingMessages.forEach(function(item) {
10124
+ // Backward-compatible: 老逻辑里 entries 可能是裸字符串。
10125
+ if (typeof item === "string") { queue.push(item); return; }
10126
+ if (!item || typeof item.input !== "string") return;
10127
+ if (now - (item.at || 0) > PENDING_INPUT_TTL_MS) { dropped++; return; }
10128
+ queue.push(item.input);
10129
+ });
9201
10130
  state.pendingMessages = [];
10131
+ if (dropped > 0) {
10132
+ console.log("[wand] flushPendingMessages: dropped " + dropped + " stale input(s)");
10133
+ }
9202
10134
 
9203
10135
  var sendPromise = Promise.resolve();
9204
10136
  queue.forEach(function(input) {
@@ -9212,7 +10144,10 @@
9212
10144
 
9213
10145
  function sendInputDirect(input) {
9214
10146
  if (!input || !state.selectedId) return Promise.resolve();
9215
- return fetch("/api/sessions/" + state.selectedId + "/input", {
10147
+ // postInput:flushPendingMessages 重连后批量回放离线消息时,
10148
+ // 用户可能已在切到别的会话,必须用本次请求的 sessionId 快照。
10149
+ var requestSessionId = state.selectedId;
10150
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
9216
10151
  method: "POST",
9217
10152
  headers: { "Content-Type": "application/json" },
9218
10153
  credentials: "same-origin",
@@ -9227,7 +10162,7 @@
9227
10162
  // on the user's next message, and stale queue items would cause duplicates
9228
10163
  if (isSessionUnavailableError(error)) {
9229
10164
  console.log("[wand] sendInputDirect: session unavailable, dropping", {
9230
- sessionId: state.selectedId,
10165
+ sessionId: requestSessionId,
9231
10166
  errorCode: error.errorCode
9232
10167
  });
9233
10168
  return null;
@@ -9240,10 +10175,13 @@
9240
10175
  .then(function(snapshot) {
9241
10176
  if (snapshot && snapshot.id) {
9242
10177
  updateSessionSnapshot(snapshot);
9243
- if (snapshot.messages && snapshot.messages.length > 0) {
9244
- state.currentMessages = snapshot.messages;
10178
+ // 仅当 snapshot 仍属当前选中会话时才覆盖视图,否则只更新底层数据。
10179
+ if (snapshot.id === state.selectedId) {
10180
+ if (snapshot.messages && snapshot.messages.length > 0) {
10181
+ state.currentMessages = snapshot.messages;
10182
+ }
10183
+ renderChat(true);
9245
10184
  }
9246
- renderChat(true);
9247
10185
  }
9248
10186
  return snapshot;
9249
10187
  });
@@ -9372,12 +10310,12 @@
9372
10310
  method: "POST",
9373
10311
  headers: { "Content-Type": "application/json" },
9374
10312
  credentials: "same-origin",
9375
- body: JSON.stringify({
10313
+ body: JSON.stringify(withTerminalDimensions({
9376
10314
  command: command,
9377
10315
  cwd: cwd || "",
9378
10316
  mode: state.chatMode || state.config.defaultMode || "default",
9379
10317
  model: modelPref || undefined
9380
- })
10318
+ }))
9381
10319
  })
9382
10320
  .then(function(res) { return res.json(); })
9383
10321
  .then(function(data) {
@@ -9402,9 +10340,9 @@
9402
10340
  method: "POST",
9403
10341
  headers: { "Content-Type": "application/json" },
9404
10342
  credentials: "same-origin",
9405
- body: JSON.stringify({
10343
+ body: JSON.stringify(withTerminalDimensions({
9406
10344
  mode: state.chatMode || state.config.defaultMode || "default"
9407
- })
10345
+ }))
9408
10346
  })
9409
10347
  .then(function(res) { return res.json(); })
9410
10348
  .then(function(data) {
@@ -9433,9 +10371,9 @@
9433
10371
  method: "POST",
9434
10372
  headers: { "Content-Type": "application/json" },
9435
10373
  credentials: "same-origin",
9436
- body: JSON.stringify({
10374
+ body: JSON.stringify(withTerminalDimensions({
9437
10375
  mode: state.chatMode || state.config.defaultMode || "default"
9438
- })
10376
+ }))
9439
10377
  })
9440
10378
  .then(function(res) { return res.json(); })
9441
10379
  .then(function(data) {
@@ -9462,6 +10400,10 @@
9462
10400
 
9463
10401
  function activateSession(data) {
9464
10402
  if (!data || !data.id) return Promise.resolve();
10403
+ state.selectedId = data.id;
10404
+ persistSelectedId();
10405
+ state.currentMessages = [];
10406
+ teardownTerminal();
9465
10407
  resetChatRenderCache();
9466
10408
  switchToSessionView(data.id);
9467
10409
  updateSessionSnapshot(data);
@@ -9509,13 +10451,13 @@
9509
10451
  method: "POST",
9510
10452
  headers: { "Content-Type": "application/json" },
9511
10453
  credentials: "same-origin",
9512
- body: JSON.stringify({
10454
+ body: JSON.stringify(withTerminalDimensions({
9513
10455
  command: preferredTool,
9514
10456
  cwd: defaultCwd,
9515
10457
  mode: mode,
9516
10458
  initialInput: value,
9517
10459
  model: modelPref || undefined
9518
- })
10460
+ }))
9519
10461
  })
9520
10462
  .then(function(res) { return res.json(); })
9521
10463
  .then(function(data) {
@@ -9547,13 +10489,13 @@
9547
10489
  method: "POST",
9548
10490
  headers: { "Content-Type": "application/json" },
9549
10491
  credentials: "same-origin",
9550
- body: JSON.stringify({
10492
+ body: JSON.stringify(withTerminalDimensions({
9551
10493
  command: preferredTool,
9552
10494
  cwd: defaultCwd,
9553
10495
  mode: mode,
9554
10496
  initialInput: value || undefined,
9555
10497
  model: modelPref || undefined
9556
- })
10498
+ }))
9557
10499
  })
9558
10500
  .then(function(res) { return res.json(); })
9559
10501
  .then(function(data) {
@@ -9609,10 +10551,10 @@
9609
10551
  method: "POST",
9610
10552
  headers: { "Content-Type": "application/json" },
9611
10553
  credentials: "same-origin",
9612
- body: JSON.stringify({
10554
+ body: JSON.stringify(withTerminalDimensions({
9613
10555
  mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
9614
10556
  cwd: cwd
9615
- })
10557
+ }))
9616
10558
  })
9617
10559
  .then(function(res) { return res.json(); })
9618
10560
  .then(function(data) {
@@ -9652,15 +10594,15 @@
9652
10594
  }
9653
10595
 
9654
10596
  function updateInputPanelViewportSpacing() {
10597
+ // 旧实现给 input-panel 加底部 padding = 键盘高度,意图是腾出键盘
10598
+ // 空间。但 input-panel 本身位置由 flex 决定,padding 增大只是把
10599
+ // panel 自身撑高、内部底部多出空白,textarea(panel 顶部)反而
10600
+ // 被往上推、离键盘更远。新方案改为让 body 高度跟随 visualViewport
10601
+ // 收缩(见 syncAppViewportHeight),input-panel 自然贴键盘上沿。
10602
+ // 这里清掉旧 keyboard-offset,避免新旧双重补偿。
9655
10603
  var inputPanel = document.querySelector('.input-panel');
9656
10604
  if (!inputPanel) return;
9657
- if (!('visualViewport' in window) || !isTouchDevice()) {
9658
- inputPanel.style.removeProperty('--keyboard-offset');
9659
- return;
9660
- }
9661
- var vv = window.visualViewport;
9662
- var offsetBottom = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
9663
- inputPanel.style.setProperty('--keyboard-offset', offsetBottom + 'px');
10605
+ inputPanel.style.removeProperty('--keyboard-offset');
9664
10606
  }
9665
10607
 
9666
10608
  function resetInputPanelViewportSpacing() {
@@ -9713,7 +10655,7 @@
9713
10655
  // The container height restores but terminal needs time to
9714
10656
  // fill the expanded space, and the scroll position needs resetting.
9715
10657
  if (isTouchDevice()) {
9716
- ensureTerminalFit();
10658
+ ensureTerminalFit("keyboard-blur", { forceReplay: true });
9717
10659
  maybeScrollTerminalToBottom("force");
9718
10660
  }
9719
10661
  }, 100);
@@ -10391,14 +11333,14 @@
10391
11333
  var terminalContainer = document.querySelector('.terminal-container');
10392
11334
 
10393
11335
  // Virtual Keyboard API (Chrome/Edge)
11336
+ // 不再给 input-panel 直接 setPaddingBottom——新方案通过
11337
+ // syncAppViewportHeight 让 body 跟随可见视口收缩,input-panel
11338
+ // 自然上移。这里只把事件留作未来钩子,避免和新方案双重补偿。
10394
11339
  if ('virtualKeyboard' in navigator) {
10395
11340
  var vk = navigator.virtualKeyboard;
10396
-
10397
11341
  vk.addEventListener('geometrychange', function() {
10398
11342
  if (!inputPanel) return;
10399
- var rect = vk.boundingRect;
10400
- var kbHeight = rect ? rect.height : 0;
10401
- inputPanel.style.paddingBottom = kbHeight > 0 ? kbHeight + 'px' : '';
11343
+ inputPanel.style.removeProperty('padding-bottom');
10402
11344
  });
10403
11345
  }
10404
11346
 
@@ -10421,6 +11363,26 @@
10421
11363
  }
10422
11364
  }
10423
11365
 
11366
+ // 把 body / .app-container 的高度从 100dvh 切换为可见视口高度,
11367
+ // 这样键盘弹起时整个 flex column 自动收缩,input-panel 跟着上移到
11368
+ // 键盘上沿。Android targetSdk 36 在 edge-to-edge 默认开启时,
11369
+ // adjustResize 不再自动 resize WebView 内容;同时仅给 input-panel
11370
+ // 加 padding-bottom 只是把 panel 内部底部撑空,并不会让 panel 自身
11371
+ // 上移。这里通过 CSS 变量驱动整层高度,是跨 WebView/Chrome/PWA 的
11372
+ // 统一兜底。仅在视口比窗口明显变小时(典型 = 软键盘弹起)覆盖,
11373
+ // 桌面与无键盘场景维持 100dvh 不抖。
11374
+ function syncAppViewportHeight() {
11375
+ var vv = window.visualViewport;
11376
+ if (!vv) return;
11377
+ var diff = window.innerHeight - vv.height - vv.offsetTop;
11378
+ var root = document.documentElement;
11379
+ if (diff > 50) {
11380
+ root.style.setProperty('--app-viewport-height', vv.height + 'px');
11381
+ } else {
11382
+ root.style.removeProperty('--app-viewport-height');
11383
+ }
11384
+ }
11385
+
10424
11386
  // Visual viewport handling for better mobile keyboard support
10425
11387
  function setupVisualViewportHandlers() {
10426
11388
  if (!('visualViewport' in window)) return;
@@ -10436,6 +11398,10 @@
10436
11398
  var isKeyboardOpen = offsetBottom > 50;
10437
11399
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
10438
11400
 
11401
+ // 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
11402
+ // 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
11403
+ syncAppViewportHeight();
11404
+
10439
11405
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
10440
11406
  syncInputBoxScroll(inputBox);
10441
11407
  }
@@ -10445,14 +11411,14 @@
10445
11411
  // Without an immediate refit, any chunk arriving while the keyboard
10446
11412
  // animates in renders against the old grid and tears the screen.
10447
11413
  if (!keyboardOpen && isKeyboardOpen) {
10448
- ensureTerminalFit("keyboard-open");
11414
+ ensureTerminalFit("keyboard-open", { forceReplay: true });
10449
11415
  }
10450
11416
 
10451
11417
  // Keyboard just closed — force terminal refit and scroll to bottom
10452
11418
  // after a delay so the keyboard dismiss animation and layout settle.
10453
11419
  if (keyboardOpen && !isKeyboardOpen) {
10454
11420
  setTimeout(function() {
10455
- ensureTerminalFit("keyboard-close");
11421
+ ensureTerminalFit("keyboard-close", { forceReplay: true });
10456
11422
  maybeScrollTerminalToBottom("force");
10457
11423
  }, 200);
10458
11424
  }
@@ -10586,11 +11552,11 @@
10586
11552
  // Page returning from background: container dimensions may have
10587
11553
  // drifted (PWA standalone, tab switch, iOS address-bar toggle).
10588
11554
  state.visibilityHandler = function() {
10589
- if (!document.hidden) ensureTerminalFit("visibility");
11555
+ if (!document.hidden) ensureTerminalFit("visibility", { forceReplay: true });
10590
11556
  };
10591
11557
  document.addEventListener("visibilitychange", state.visibilityHandler);
10592
11558
  // Mobile device rotation — large geometry change.
10593
- state.orientationHandler = function() { ensureTerminalFit("orientation"); };
11559
+ state.orientationHandler = function() { ensureTerminalFit("orientation", { forceReplay: true }); };
10594
11560
  window.addEventListener("orientationchange", state.orientationHandler);
10595
11561
  requestAnimationFrame(function() { scheduleTerminalResize(true); });
10596
11562
  }
@@ -10686,11 +11652,43 @@
10686
11652
  state.terminal.destroy();
10687
11653
  state.terminal = null;
10688
11654
  }
11655
+ // wterm.destroy() 只把 termWrap.innerHTML 置空,节点本身还挂在
11656
+ // #output 上。多次会话切换会让 N 个 .terminal-scroll-wrap 叠在
11657
+ // 同一 inset:0 位置;新 init 又 appendChild 一个新 termWrap,
11658
+ // 旧节点的 DOM 行虽被清空,但 scroll/层叠状态可能造成跨会话视觉
11659
+ // 污染。这里把残留节点彻底移除。
11660
+ if (output) {
11661
+ var staleWraps = output.querySelectorAll(".terminal-scroll-wrap");
11662
+ for (var i = 0; i < staleWraps.length; i++) {
11663
+ var wrap = staleWraps[i];
11664
+ if (wrap.parentNode === output) output.removeChild(wrap);
11665
+ }
11666
+ }
11667
+ // widePadAnsi 是模块级状态机,跨终端实例时若卡在 esc/csi/string 等
11668
+ // 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
11669
+ // ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
11670
+ resetWideParserState();
10689
11671
  state.terminalSessionId = null;
10690
11672
  state.terminalOutput = "";
10691
11673
  state.terminalAutoFollow = true;
10692
11674
  state.showTerminalJumpToBottom = false;
10693
11675
  updateTerminalJumpToBottomButton();
11676
+ // 清理本轮新增的、依赖当前 wterm 实例的模块级 timer 和频次统计。
11677
+ // 不清掉的话,旧会话上挂起的 tail timer 在新 wterm 实例上触发会
11678
+ // 用 state.terminalOutput 做一次无意义的 resync;resyncStats 计数
11679
+ // 跨会话累加也会让告警阈值在新会话立即触发误报。
11680
+ if (state.softResyncTimer) {
11681
+ clearTimeout(state.softResyncTimer);
11682
+ state.softResyncTimer = null;
11683
+ }
11684
+ if (_resyncChunkTailTimer) {
11685
+ clearTimeout(_resyncChunkTailTimer);
11686
+ _resyncChunkTailTimer = null;
11687
+ }
11688
+ _resyncChunkLastAt = 0;
11689
+ _resyncStatsWindowStart = 0;
11690
+ _resyncStatsCount = 0;
11691
+ _resyncLastWarnAt = 0;
10694
11692
  }
10695
11693
 
10696
11694
  function sendTerminalResize(cols, rows) {
@@ -10709,24 +11707,71 @@
10709
11707
  }
10710
11708
  }
10711
11709
 
10712
- // Unified entry point for re-fitting the xterm grid to its container.
10713
- // Why: wterm's `autoResize` ResizeObserver only fires on subsequent
10714
- // container size changes. If the terminal is mounted or written to
10715
- // while the container is hidden/zero-width, cols/rows stay wrong and
10716
- // new output renders with broken wrapping (content visually piles at
10717
- // the top). Call this after any layout change that might have altered
10718
- // container geometry (mount, session switch, view switch, refresh).
10719
- // Same as ensureTerminalFit, but if the container is currently 0×0
10720
- // (typical right after Android WebView.onResume the page hasn't
10721
- // re-laid-out yet), keep retrying through requestAnimationFrame /
10722
- // setTimeout up to ~5 frames. Each attempt forces a layout read
10723
- // (offsetHeight) so the browser has to flush styles.
10724
- // Without this, the very first ensureTerminalFit silently fails,
10725
- // cols/rows stay at the pre-suspend values, and freshly arriving
10726
- // PTY chunks wrap against the wrong width — that's exactly the
10727
- // "content piles at the top after resuming the app" bug.
10728
- function ensureTerminalFitWithRetry(reason) {
11710
+ // Unified entry point for re-fitting the wterm grid to its container.
11711
+ //
11712
+ // wterm's internal ResizeObserver only fires when newCols/newRows
11713
+ // actually differ from the current values. So a "soft refresh" path
11714
+ // (refresh button, ws-reconnect, view-switch container size unchanged)
11715
+ // never reaches wterm.resize() on its own; we have to drive replay
11716
+ // explicitly via { forceReplay: true }.
11717
+ //
11718
+ // When cols *do* change in the rAF body, our remeasure() calls
11719
+ // wterm.resize() which synchronously fires the onResize callback —
11720
+ // and that callback already runs softResyncTerminal({ skipFit: true }).
11721
+ // So the rAF body must NOT replay again in that case (would flicker /
11722
+ // double-scroll). The two outcomes are mutually exclusive: either
11723
+ // remeasure resized and onResize replayed, or cols stayed put and we
11724
+ // honor forceReplay.
11725
+ function ensureTerminalFit(reason, options) {
11726
+ if (!state.terminal) return false;
11727
+ var opts = options || {};
11728
+ var forceReplay = opts.forceReplay === true;
11729
+ var el = document.getElementById("output");
11730
+ if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
11731
+ // Container has no visible size yet (hidden, mid-transition,
11732
+ // pre-keyboard layout frame, Android WebView resume). Defer to
11733
+ // the retry loop; without it, a missed fit means PTY chunks keep
11734
+ // wrapping at the wrong width until the next external trigger
11735
+ // (rotation, keyboard toggle), and content piles at the top.
11736
+ ensureTerminalFitWithRetry(reason || "fit-retry", { forceReplay: forceReplay });
11737
+ return false;
11738
+ }
11739
+ var prevCols = state.terminal.cols;
11740
+ var prevRows = state.terminal.rows;
11741
+ requestAnimationFrame(function() {
11742
+ requestAnimationFrame(function() {
11743
+ if (!state.terminal) return;
11744
+ if (typeof state.terminal.remeasure === "function") {
11745
+ // remeasure → wterm.resize (if cols changed) → onResize →
11746
+ // softResyncTerminal({ skipFit: true }). Replay happens there.
11747
+ state.terminal.remeasure();
11748
+ }
11749
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
11750
+ var didResize = state.terminal.cols !== prevCols
11751
+ || state.terminal.rows !== prevRows;
11752
+ // Mutex: didResize already replayed via onResize; otherwise the
11753
+ // caller may still demand a replay (e.g. ws-reconnect, refresh
11754
+ // button — DOM may be stale even at the same cols).
11755
+ if (!didResize && forceReplay && state.terminalOutput) {
11756
+ softResyncTerminal({ skipFit: true });
11757
+ }
11758
+ if (state.terminalAutoFollow || isTerminalNearBottom()) {
11759
+ maybeScrollTerminalToBottom("resize");
11760
+ }
11761
+ });
11762
+ });
11763
+ return true;
11764
+ }
11765
+
11766
+ // Same as ensureTerminalFit but spins through requestAnimationFrame /
11767
+ // setTimeout up to ~8 frames waiting for a non-zero container size
11768
+ // (Android WebView.onResume, keyboard transitions, hidden→visible
11769
+ // panel flips). Forwards forceReplay so the caller's intent is
11770
+ // preserved when the container finally settles.
11771
+ function ensureTerminalFitWithRetry(reason, options) {
10729
11772
  if (!state.terminal) return;
11773
+ var opts = options || {};
11774
+ var forceReplay = opts.forceReplay !== false; // default true: retry path implies "may be stale"
10730
11775
  var attempts = 0;
10731
11776
  var maxAttempts = 8;
10732
11777
  function tryFit() {
@@ -10738,16 +11783,7 @@
10738
11783
  void el.offsetHeight;
10739
11784
  }
10740
11785
  if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
10741
- ensureTerminalFit(reason);
10742
- // After fit, force a buffer replay: even when cols didn't
10743
- // change, the WASM grid state may be inconsistent after a
10744
- // long suspend (DOM rows clipped, scroll position lost).
10745
- // softResyncTerminal is cheap because terminalOutput is
10746
- // already in memory.
10747
- if (state.terminalOutput) {
10748
- state.suppressFitReplay = true;
10749
- softResyncTerminal();
10750
- }
11786
+ ensureTerminalFit(reason, { forceReplay: forceReplay });
10751
11787
  return;
10752
11788
  }
10753
11789
  if (++attempts >= maxAttempts) return;
@@ -10763,51 +11799,6 @@
10763
11799
  tryFit();
10764
11800
  }
10765
11801
 
10766
- function ensureTerminalFit(reason) {
10767
- if (!state.terminal) return false;
10768
- var el = document.getElementById("output");
10769
- if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
10770
- // 容器暂时没有可见尺寸(hidden、动画过渡、键盘弹起前的 layout
10771
- // 中间帧、Android WebView resume 头几帧),不要静默放弃——
10772
- // 改成丢给 ensureTerminalFitWithRetry 兜底,等容器有了真实
10773
- // 尺寸再 fit + replay。否则一旦错过这一次,只能等下一次外部
10774
- // 触发(旋转屏幕、开关键盘等),中间的输出就会一直按错误
10775
- // 宽度堆在视图上方,看起来像"中间一大段都没有显示"。
10776
- ensureTerminalFitWithRetry(reason || "fit-retry");
10777
- return false;
10778
- }
10779
- var prevCols = state.terminal.cols;
10780
- var prevRows = state.terminal.rows;
10781
- requestAnimationFrame(function() {
10782
- requestAnimationFrame(function() {
10783
- if (!state.terminal) return;
10784
- if (typeof state.terminal.remeasure === "function") {
10785
- state.terminal.remeasure();
10786
- }
10787
- sendTerminalResize(state.terminal.cols, state.terminal.rows);
10788
- // Cache the container width that produced this cols/rows so the
10789
- // hot-path chunk writer can detect drift cheaply (avoids running
10790
- // a full remeasure on every WebSocket message).
10791
- state.lastFitContainerWidth = el.offsetWidth;
10792
- state.lastFitContainerHeight = el.offsetHeight;
10793
- // If cols actually changed, the previously written buffer was
10794
- // wrapped to the old width. Replay the full buffer so historical
10795
- // lines and any in-flight CSI cursor sequences re-render against
10796
- // the new grid — this is what fixes the "torn" screens users see
10797
- // after rotating, opening the keyboard, or resizing the panel.
10798
- var skipReplay = state.suppressFitReplay === true;
10799
- state.suppressFitReplay = false;
10800
- if (!skipReplay && (state.terminal.cols !== prevCols || state.terminal.rows !== prevRows)) {
10801
- if (state.terminalOutput) softResyncTerminal();
10802
- }
10803
- if (state.terminalAutoFollow || isTerminalNearBottom()) {
10804
- maybeScrollTerminalToBottom("resize");
10805
- }
10806
- });
10807
- });
10808
- return true;
10809
- }
10810
-
10811
11802
  function scheduleTerminalResize(immediate) {
10812
11803
  if (state.resizeTimer) {
10813
11804
  clearTimeout(state.resizeTimer);
@@ -10916,6 +11907,9 @@
10916
11907
  // starts the ladder from 500ms again.
10917
11908
  state.wsReconnectAttempts = 0;
10918
11909
  cancelWsReconnect();
11910
+ // Server's per-client output sequence counter restarts on every
11911
+ // new socket; clear ours so the first init isn't treated as a gap.
11912
+ state.lastSeqBySession = {};
10919
11913
  // Subscribe to current session if any
10920
11914
  subscribeToSession(state.selectedId);
10921
11915
  // Flush pending messages after reconnection
@@ -10932,6 +11926,43 @@
10932
11926
  ws.onmessage = function(event) {
10933
11927
  try {
10934
11928
  var msg = JSON.parse(event.data);
11929
+ if (msg && msg.type === "resync_required" && msg.sessionId) {
11930
+ // Server dropped some output events under backpressure and
11931
+ // is asking us for a fresh snapshot. Send a resync so the
11932
+ // server replies with a new init carrying the full output.
11933
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
11934
+ try {
11935
+ state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
11936
+ } catch (sendErr) { /* ignore */ }
11937
+ }
11938
+ if (!state.lastSeqBySession) state.lastSeqBySession = {};
11939
+ state.lastSeqBySession[msg.sessionId] = 0;
11940
+ return;
11941
+ }
11942
+ if (msg && (msg.type === "init" || msg.type === "output") && msg.sessionId && typeof msg.seq === "number") {
11943
+ if (!state.lastSeqBySession) state.lastSeqBySession = {};
11944
+ var prevSeq = state.lastSeqBySession[msg.sessionId] || 0;
11945
+ if (msg.type === "init") {
11946
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11947
+ } else if (msg.seq === prevSeq + 1) {
11948
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11949
+ } else if (msg.seq > prevSeq + 1 && prevSeq > 0) {
11950
+ // We missed at least one event — request a resync and
11951
+ // skip this stale event so we don't apply a partial gap.
11952
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
11953
+ try {
11954
+ state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
11955
+ } catch (sendErr) { /* ignore */ }
11956
+ }
11957
+ state.lastSeqBySession[msg.sessionId] = 0;
11958
+ return;
11959
+ } else {
11960
+ // seq <= prevSeq: duplicate or out-of-order from a stale
11961
+ // queue; drop quietly.
11962
+ if (msg.seq < prevSeq) return;
11963
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11964
+ }
11965
+ }
10935
11966
  handleWebSocketMessage(msg);
10936
11967
  } catch (e) {
10937
11968
  // Ignore parse errors
@@ -10962,6 +11993,30 @@
10962
11993
  case 'output':
10963
11994
  // Update session output (for terminal display and local message parsing)
10964
11995
  // NOTE: For structured sessions, output may be "" during streaming — check messages too
11996
+ // thinking → idle 边界自愈:桥接层在 output.chat 事件里把 isResponding
11997
+ // 透传过来。当某会话由 true 变 false(assistant 完成一轮响应)时,
11998
+ // 主动做一次 softResyncTerminal —— 等价于自动按一次右上角缩放按钮,
11999
+ // 把 Claude/Codex 流式渲染中残留的错位光标定位序列洗掉。
12000
+ // 用 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 触发多次重放。
12001
+ if (msg.data && msg.sessionId
12002
+ && Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
12003
+ if (!state._lastIsResponding) state._lastIsResponding = {};
12004
+ var _prevResp = !!state._lastIsResponding[msg.sessionId];
12005
+ var _nextResp = !!msg.data.isResponding;
12006
+ state._lastIsResponding[msg.sessionId] = _nextResp;
12007
+ if (_prevResp && !_nextResp
12008
+ && msg.sessionId === state.selectedId
12009
+ && state.terminal
12010
+ && state.terminalOutput) {
12011
+ if (state._idleResyncTimer) clearTimeout(state._idleResyncTimer);
12012
+ var _idleResyncSid = msg.sessionId;
12013
+ state._idleResyncTimer = setTimeout(function() {
12014
+ state._idleResyncTimer = null;
12015
+ if (state.selectedId !== _idleResyncSid) return;
12016
+ try { softResyncTerminal({ skipFit: true }); } catch (e) {}
12017
+ }, 120);
12018
+ }
12019
+ }
10965
12020
  if (msg.data && msg.sessionId) {
10966
12021
  var isIncremental = !!msg.data.incremental;
10967
12022
  var snapshot = { id: msg.sessionId };
@@ -11035,7 +12090,7 @@
11035
12090
  }
11036
12091
  // Real-time terminal output
11037
12092
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
11038
- if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
12093
+ if (msg.data.chunk && isCurrentTerminalSession(msg.sessionId)) {
11039
12094
  // Fast path: write chunk directly to avoid full-output comparison.
11040
12095
  state.lastChunkAt = Date.now();
11041
12096
  state.terminalLiveStreamSessions[msg.sessionId] = true;
@@ -11046,11 +12101,12 @@
11046
12101
  // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
11047
12102
  // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
11048
12103
  wandTerminalWrite(state.terminal, msg.data.chunk);
12104
+ maybeScheduleResyncForChunk(msg.data.chunk);
11049
12105
  state.terminalSessionId = msg.sessionId;
11050
12106
  if (msg.data.output) {
11051
- state.terminalOutput = normalizeTerminalOutput(msg.data.output);
12107
+ state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
11052
12108
  } else {
11053
- state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
12109
+ state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
11054
12110
  }
11055
12111
  maybeScrollTerminalToBottom("output");
11056
12112
  updateTerminalJumpToBottomButton();
@@ -11575,7 +12631,9 @@
11575
12631
  var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
11576
12632
  var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
11577
12633
  && selectedForDelay.sessionKind !== "structured";
11578
- var delay = isActiveStream ? 150 : 30;
12634
+ // 活跃流时拉到 CHAT_RENDER_LIVE_MS 减少高频重渲;空闲时用 IDLE 快速响应。
12635
+ // 旧实现里这两档在 setTimeout 调用时被覆盖成固定 30ms,分档逻辑形同虚设。
12636
+ var delay = isActiveStream ? CHAT_RENDER_LIVE_MS : CHAT_RENDER_IDLE_MS;
11579
12637
  chatRenderTimer = setTimeout(function() {
11580
12638
  chatRenderTimer = null;
11581
12639
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
@@ -11583,7 +12641,7 @@
11583
12641
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
11584
12642
  }
11585
12643
  renderChat();
11586
- }, 30);
12644
+ }, delay);
11587
12645
  }
11588
12646
 
11589
12647
  // Extract system info from PTY output that's not in structured messages
@@ -14421,6 +15479,21 @@
14421
15479
  var _progressSyncTimers = {};
14422
15480
  var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
14423
15481
 
15482
+ // Strip markdown formatting and clamp to a single short line so the
15483
+ // native Live Activity / lock-screen card stays readable. 100 chars
15484
+ // matches getLastAssistantSummary; OPPO truncates harder anyway.
15485
+ function _compactNotificationText(text) {
15486
+ if (!text) return "";
15487
+ var t = String(text)
15488
+ .replace(/^#+\s+/gm, "")
15489
+ .replace(/\*\*/g, "")
15490
+ .replace(/`/g, "")
15491
+ .trim();
15492
+ var firstLine = t.split("\n")[0].trim();
15493
+ if (firstLine.length > 100) firstLine = firstLine.slice(0, 100) + "…";
15494
+ return firstLine;
15495
+ }
15496
+
14424
15497
  function syncSessionProgressToNative(sessionId) {
14425
15498
  if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
14426
15499
  if (!sessionId) return;
@@ -14446,21 +15519,56 @@
14446
15519
  return;
14447
15520
  }
14448
15521
 
14449
- // Get latest todos from session messages
15522
+ // Get latest todos from session messages, plus the most recent user
15523
+ // prompt and assistant text in the same scan. sessionLabel is frozen
15524
+ // to the first prompt (session.summary), so without these fields the
15525
+ // OPPO Live Activity / lock-screen card stays stuck on round-1 text
15526
+ // forever. We carry the latest round across so native can refresh.
14450
15527
  var todos = null;
15528
+ var latestUserText = "";
15529
+ var latestAssistantText = "";
14451
15530
  var messages = session.messages || [];
14452
15531
  for (var i = messages.length - 1; i >= 0; i--) {
14453
15532
  var msg = messages[i];
14454
15533
  if (!msg.content || !Array.isArray(msg.content)) continue;
14455
- for (var j = msg.content.length - 1; j >= 0; j--) {
14456
- var block = msg.content[j];
14457
- if (block.type === "tool_use" && block.name === "TodoWrite"
14458
- && block.input && block.input.todos) {
14459
- todos = block.input.todos;
14460
- break;
15534
+
15535
+ if (!latestAssistantText && msg.role === "assistant") {
15536
+ for (var ai = msg.content.length - 1; ai >= 0; ai--) {
15537
+ var ablock = msg.content[ai];
15538
+ if (ablock && ablock.type === "text" && ablock.text && ablock.text.trim()) {
15539
+ latestAssistantText = _compactNotificationText(ablock.text);
15540
+ break;
15541
+ }
14461
15542
  }
14462
15543
  }
14463
- if (todos) break;
15544
+
15545
+ if (!latestUserText && msg.role === "user") {
15546
+ // Skip queued / synthetic placeholder turns — they don't represent
15547
+ // user-visible "I just asked this" prompts.
15548
+ var isPlaceholder = msg.content.some(function(b) { return b && b.__queued; });
15549
+ if (!isPlaceholder) {
15550
+ for (var ui = 0; ui < msg.content.length; ui++) {
15551
+ var ublock = msg.content[ui];
15552
+ if (ublock && ublock.type === "text" && ublock.text && ublock.text.trim()) {
15553
+ latestUserText = _compactNotificationText(ublock.text);
15554
+ break;
15555
+ }
15556
+ }
15557
+ }
15558
+ }
15559
+
15560
+ if (!todos) {
15561
+ for (var j = msg.content.length - 1; j >= 0; j--) {
15562
+ var block = msg.content[j];
15563
+ if (block && block.type === "tool_use" && block.name === "TodoWrite"
15564
+ && block.input && block.input.todos) {
15565
+ todos = block.input.todos;
15566
+ break;
15567
+ }
15568
+ }
15569
+ }
15570
+
15571
+ if (todos && latestUserText && latestAssistantText) break;
14464
15572
  }
14465
15573
 
14466
15574
  // Get current task
@@ -14473,6 +15581,8 @@
14473
15581
  sessionLabel: sessionLabel,
14474
15582
  status: sessionStatus,
14475
15583
  currentTask: currentTask,
15584
+ latestUserText: latestUserText,
15585
+ latestAssistantText: latestAssistantText,
14476
15586
  todos: todos || []
14477
15587
  };
14478
15588