@co0ontty/wand 1.20.4 → 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.
@@ -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 {
@@ -235,10 +240,90 @@
235
240
  // ── Structured session status bar (in-flight timer) ──
236
241
  var _statusBarTimerId = null;
237
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
+ }
238
320
 
239
321
  function renderStructuredStatusBar(chatMessages, session) {
240
- // Status bar now lives above the input-composer, inside .input-panel
241
- 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");
242
327
  var existing = document.querySelector(".structured-status-bar");
243
328
  var composer = document.querySelector(".input-composer");
244
329
  if (!session || !isStructuredSession(session)) {
@@ -260,15 +345,15 @@
260
345
  // Add glow to input composer
261
346
  if (composer) composer.classList.add("in-flight");
262
347
 
263
- if (!existing && inputPanel && composer) {
348
+ if (!existing && topRow) {
264
349
  var bar = document.createElement("div");
265
350
  bar.className = "structured-status-bar";
266
351
  bar.innerHTML =
267
352
  '<span class="status-bar-dot"></span>' +
268
353
  '<span class="status-bar-label">回复中</span>' +
269
354
  '<span class="status-bar-timer">0.0s</span>';
270
- // Insert right before the input-composer element
271
- 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);
272
357
  existing = bar;
273
358
  } else if (existing && existing.classList.contains("completed")) {
274
359
  // Was completed, now in-flight again — reset
@@ -1038,6 +1123,12 @@
1038
1123
  if (isLoggedIn && state.selectedId && state.gitStatusSessionId !== state.selectedId) {
1039
1124
  loadGitStatus(state.selectedId);
1040
1125
  }
1126
+
1127
+ // DOM 整体重渲后,重新挂上"运行中"指示器(顶部进度条/徽章计时/气泡呼吸条)
1128
+ if (isLoggedIn) {
1129
+ var __sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
1130
+ updateRunningIndicators(__sel);
1131
+ }
1041
1132
  }
1042
1133
 
1043
1134
  function renderShortcutKeys() {
@@ -1315,17 +1406,19 @@
1315
1406
  '</div>' +
1316
1407
  '</div>' +
1317
1408
  '<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
1318
- '<div id="todo-progress" class="todo-progress hidden">' +
1319
- '<div class="todo-progress-header" id="todo-progress-toggle">' +
1320
- '<div class="todo-progress-left">' +
1321
- '<span class="todo-progress-spinner"></span>' +
1322
- '<span class="todo-progress-counter" id="todo-progress-counter">0/0</span>' +
1323
- '<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>' +
1324
1421
  '</div>' +
1325
- '<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>' +
1326
- '</div>' +
1327
- '<div class="todo-progress-body hidden" id="todo-progress-body">' +
1328
- '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1329
1422
  '</div>' +
1330
1423
  '</div>' +
1331
1424
  '<div class="input-composer">' +
@@ -1350,7 +1443,7 @@
1350
1443
  renderModeOptions(preferredTool, composerMode) +
1351
1444
  '</select>' +
1352
1445
  '<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1353
- renderChatModelOptions(getEffectiveModel(selectedSession)) +
1446
+ renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1354
1447
  '</select>' +
1355
1448
  '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1356
1449
  '<span class="permission-actions hidden" id="permission-actions">' +
@@ -2672,10 +2765,16 @@
2672
2765
 
2673
2766
  function applyTerminalScale() {
2674
2767
  if (!state.terminal || !state.terminal.element) return;
2675
- var newSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
2676
- var newRowHeight = (state.terminalBaseFontSize * state.terminalScale * 1.5) + "px";
2677
- state.terminal.element.style.setProperty("--term-font-size", newSize);
2678
- 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");
2679
2778
  if (typeof state.terminal.remeasure === "function") {
2680
2779
  requestAnimationFrame(function() {
2681
2780
  if (state.terminal) state.terminal.remeasure();
@@ -3135,7 +3234,9 @@
3135
3234
  if (!session) return "";
3136
3235
  if (session.archived) return "已归档";
3137
3236
  if (session.permissionBlocked) return "等待授权";
3237
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "思考中";
3138
3238
  var statusMap = {
3239
+ "idle": "空闲",
3139
3240
  "stopped": "已停止",
3140
3241
  "running": "运行中",
3141
3242
  "exited": "已退出",
@@ -3148,6 +3249,7 @@
3148
3249
  if (!session) return "";
3149
3250
  if (session.archived) return "archived";
3150
3251
  if (session.permissionBlocked) return "permission-blocked";
3252
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "running";
3151
3253
  return session.status || "";
3152
3254
  }
3153
3255
 
@@ -3321,7 +3423,7 @@
3321
3423
  function renderProviderOptions(selectedTool) {
3322
3424
  var tools = [
3323
3425
  { id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
3324
- { id: "codex", label: "Codex", desc: "PTY 透传,全权限启动" }
3426
+ { id: "codex", label: "Codex", desc: "结构化 JSONL 或 PTY 会话" }
3325
3427
  ];
3326
3428
  return tools.map(function(tool) {
3327
3429
  var active = tool.id === selectedTool ? " active" : "";
@@ -3339,7 +3441,7 @@
3339
3441
  ];
3340
3442
  return kinds.map(function(kind) {
3341
3443
  var active = kind.id === selectedKind ? " active" : "";
3342
- var disabled = (state.sessionTool === "codex" && kind.id === "structured") ? " disabled" : "";
3444
+ var disabled = "";
3343
3445
  return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
3344
3446
  '<span class="mode-card-label">' + kind.label + '</span>' +
3345
3447
  '<span class="mode-card-desc">' + kind.desc + '</span>' +
@@ -3357,10 +3459,12 @@
3357
3459
  function getSessionKindHint(kind) {
3358
3460
  var tool = state.sessionTool || "claude";
3359
3461
  if (kind === "structured") {
3360
- return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3462
+ return tool === "codex"
3463
+ ? "Codex JSONL 结构化聊天界面,支持多轮对话和工具调用展示。"
3464
+ : "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3361
3465
  }
3362
3466
  if (tool === "codex") {
3363
- return "Codex 仅支持 PTY;terminal 是原始输出,chat 是解析后的阅读视图。";
3467
+ return "Codex PTY 终端会话;terminal 是原始输出,chat 是解析后的阅读视图。";
3364
3468
  }
3365
3469
  return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
3366
3470
  }
@@ -3748,10 +3852,9 @@
3748
3852
  if (provider) {
3749
3853
  state.sessionTool = provider;
3750
3854
  state.preferredCommand = provider;
3751
- if (provider === "codex") {
3752
- state.sessionCreateKind = "pty";
3753
- state.modeValue = "full-access";
3754
- }
3855
+ // Codex 现在同时支持 PTY 与结构化 runner,不再强制把 kind 切成 pty。
3856
+ // mode 由 syncSessionModalUI() 调用 getSafeModeForTool() 自动 clamp,
3857
+ // 不在这里硬写。
3755
3858
  syncSessionModalUI();
3756
3859
  }
3757
3860
  });
@@ -4929,6 +5032,17 @@
4929
5032
  return distance <= state.terminalScrollThreshold;
4930
5033
  }
4931
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
+
4932
5046
  function scrollTerminalToBottom(smooth) {
4933
5047
  if (!state.terminal) return;
4934
5048
  var viewport = getTerminalViewport();
@@ -4969,11 +5083,13 @@
4969
5083
  updateTerminalJumpToBottomButton();
4970
5084
  return;
4971
5085
  }
4972
- if (!state.terminalAutoFollow && !isTerminalNearBottom()) {
5086
+ // 只看 autoFollow 标志:用户主动 wheel/touch 后该标志被设为 false,
5087
+ // 即使当前位置仍在底部 12px 阈值内也不再强行滚回,避免把用户刚滚上去
5088
+ // 的几像素吞掉。autoFollow 由 scroll handler 在"真正到底"时恢复。
5089
+ if (!state.terminalAutoFollow) {
4973
5090
  updateTerminalJumpToBottomButton();
4974
5091
  return;
4975
5092
  }
4976
- state.terminalAutoFollow = true;
4977
5093
  scrollTerminalToBottom(false);
4978
5094
  updateTerminalJumpToBottomButton();
4979
5095
  }
@@ -5262,6 +5378,13 @@
5262
5378
  if (!terminal || data == null) return;
5263
5379
  if (!state.wideParserState) state.wideParserState = createWideParserState();
5264
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
+ }
5265
5388
  }
5266
5389
 
5267
5390
  function resetWideParserState() {
@@ -5290,6 +5413,85 @@
5290
5413
  }
5291
5414
  stripWideFillerForCopy();
5292
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
+
5293
5495
  function resetTerminal() {
5294
5496
  if (!state.terminal) return;
5295
5497
  // 优先走 wterm-entry.js 自定义 WTerm 子类暴露的 reset():它会调用
@@ -5315,14 +5517,44 @@
5315
5517
  // correctly (e.g. wterm.onResize fired this resync — bouncing back into
5316
5518
  // ensureTerminalFit would just trigger another remeasure → resize → onResize
5317
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;
5318
5530
  function softResyncTerminal(options) {
5319
5531
  if (!state.terminal || !state.terminalOutput) return false;
5320
5532
  var opts = options || {};
5533
+ var bufLen = state.terminalOutput.length;
5534
+ var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
5321
5535
  resetTerminal();
5322
5536
  wandTerminalWrite(state.terminal, state.terminalOutput);
5323
5537
  state.lastTerminalResyncAt = Date.now();
5324
5538
  maybeScrollTerminalToBottom("output");
5325
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
+ }
5326
5558
  return true;
5327
5559
  }
5328
5560
 
@@ -5340,20 +5572,43 @@
5340
5572
  // DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
5341
5573
  // 用户体感就是"明明在改菜单,结果跑到最上面去了"。
5342
5574
  //
5343
- // 已有的 pendingEscalation/permissionBlocked 状态变化触发的
5344
- // scheduleSoftResyncTerminal 在这种场景下不会触发(这两个布尔
5345
- // 在菜单交互过程中不变),health check 30s 兜底也太慢,且
5346
- // 连续按键时 chunkPause 永远不成立(lastChunkAt 一直在刷新)。
5575
+ // 兜底策略是"重置 wterm 状态机 + 重放整 buffer"(softResyncTerminal)。
5576
+ // 这里的关键是触发时机:旧实现用 350ms debounce,但用户实际持续
5577
+ // 按方向键时,每次按键都会让 PTY 回流一次原地重绘 chunk,timer
5578
+ // 被反复 reset,永远等不到静默期,softResync 实际从不触发——
5579
+ // 这是这个保护机制的根本逻辑错误。
5347
5580
  //
5348
- // 这里在写 chunk 时被动检测:含上述序列就 schedule 一次 350ms
5349
- // debounce softResync。连续按键时 timer 反复被重置,仅在
5350
- // 停顿后真正重放一次 buffer,开销可控。
5581
+ // 改成 leading + tail 的节流:第一次进入立即 resync(leading),
5582
+ // 节流窗口内的连续 chunk 只挂一个尾巴 timer 兜底,不重置。这样
5583
+ // 持续按键期间每 RESYNC_THROTTLE_MS 强制 resync 一次,用户停手
5584
+ // 时由尾巴 timer 收尾。不依赖按键停顿这种永远不发生的条件。
5351
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;
5352
5590
  function maybeScheduleResyncForChunk(chunk) {
5353
5591
  if (!chunk || typeof chunk !== "string") return;
5354
5592
  if (chunk.indexOf("\x1b[") === -1) return;
5355
5593
  if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
5356
- scheduleSoftResyncTerminal(350);
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);
5357
5612
  }
5358
5613
 
5359
5614
  function syncTerminalBuffer(sessionId, output, options) {
@@ -5465,6 +5720,11 @@
5465
5720
  termWrap.className = "terminal-scroll-wrap";
5466
5721
  container.appendChild(termWrap);
5467
5722
 
5723
+ // cols/rows 给一个保守默认即可:wterm-entry.js 重写的 init()
5724
+ // 会在 super.init() 之前按 termWrap 真实尺寸做一次预校准,
5725
+ // 保证 super.init() 里 bridge.init / renderer.setup 一上来
5726
+ // 就按真实 cols 初始化,从源头消除"先按 120 写一遍 → 异步
5727
+ // remeasure 纠正"的时序窗口。
5468
5728
  var term = new WTermLib.WTerm(termWrap, {
5469
5729
  cols: 120,
5470
5730
  rows: 36,
@@ -5502,13 +5762,32 @@
5502
5762
  state.terminal = term;
5503
5763
  state.terminalInitializing = false;
5504
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
+
5505
5782
  state.terminalAutoFollow = true;
5506
5783
  clearTerminalScrollIdleTimer();
5507
5784
 
5508
5785
  var viewport = getTerminalViewport();
5509
5786
  if (viewport) {
5510
5787
  state.terminalViewportScrollHandler = function() {
5511
- if (isTerminalNearBottom()) {
5788
+ // 严格"真正到底"才恢复 autoFollow:避免 wheel 设 false 后被
5789
+ // 紧接着的 scroll 事件因"接近底部 12px"而反转回 true。
5790
+ if (isTerminalAtBottom()) {
5512
5791
  state.terminalAutoFollow = true;
5513
5792
  clearTerminalScrollIdleTimer();
5514
5793
  updateTerminalJumpToBottomButton();
@@ -5665,6 +5944,11 @@
5665
5944
  if (terminalInteractive) {
5666
5945
  return "终端交互模式开启中,请直接在终端中输入";
5667
5946
  }
5947
+ if (session && isStructuredSession(session)) {
5948
+ return session.provider === "codex"
5949
+ ? "向 Codex 发送消息;chat 为结构化对话视图"
5950
+ : "向 Claude 发送消息;chat 为结构化对话视图";
5951
+ }
5668
5952
  if (session && session.provider === "codex") {
5669
5953
  if (session.status !== "running") {
5670
5954
  return "Codex 会话已结束,无法继续发送";
@@ -5686,7 +5970,7 @@
5686
5970
 
5687
5971
  function getToolModeHint(tool, mode) {
5688
5972
  if (tool === "codex") {
5689
- return "Codex 当前仅支持 PTY 透传,并固定以 full-access 启动。";
5973
+ return "Codex 支持 PTY 终端与结构化(JSONL)两种会话,结构化模式按 full-access 启动。";
5690
5974
  }
5691
5975
  if (mode === "full-access") {
5692
5976
  return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
@@ -5790,15 +6074,20 @@
5790
6074
  return "";
5791
6075
  }
5792
6076
 
5793
- function renderChatModelOptions(selected) {
5794
- 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);
5795
6085
  var html = '<option value="">默认(跟随设置)</option>';
5796
6086
  for (var i = 0; i < models.length; i++) {
5797
6087
  var m = models[i];
5798
6088
  var label = m.label || m.id;
5799
6089
  html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
5800
6090
  }
5801
- // If selected is unknown (custom value), prepend it as a sticky option
5802
6091
  if (selected && !models.some(function(m) { return m.id === selected; })) {
5803
6092
  html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
5804
6093
  }
@@ -5809,7 +6098,7 @@
5809
6098
  var select = document.getElementById("chat-model-select");
5810
6099
  if (!select) return;
5811
6100
  var effective = getEffectiveModel(session);
5812
- select.innerHTML = renderChatModelOptions(effective);
6101
+ select.innerHTML = renderChatModelOptions(effective, session);
5813
6102
  select.value = effective;
5814
6103
  }
5815
6104
 
@@ -5819,6 +6108,7 @@
5819
6108
  .then(function(data) {
5820
6109
  if (data && Array.isArray(data.models)) {
5821
6110
  state.availableModels = data.models;
6111
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5822
6112
  syncComposerModelSelect(getSelectedSession());
5823
6113
  updateSettingsDefaultModelSelect(data);
5824
6114
  }
@@ -5837,6 +6127,7 @@
5837
6127
  .then(function(data) {
5838
6128
  if (data && Array.isArray(data.models)) {
5839
6129
  state.availableModels = data.models;
6130
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5840
6131
  syncComposerModelSelect(getSelectedSession());
5841
6132
  updateSettingsDefaultModelSelect(data);
5842
6133
  if (typeof showToast === "function") {
@@ -5860,7 +6151,7 @@
5860
6151
  if (!select) return;
5861
6152
  var previous = select.value;
5862
6153
  var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
5863
- select.innerHTML = renderChatModelOptions(current);
6154
+ select.innerHTML = renderChatModelOptions(current, { provider: "claude" });
5864
6155
  select.value = current;
5865
6156
  var versionEl = document.getElementById("cfg-default-model-version");
5866
6157
  if (versionEl && data) {
@@ -5882,7 +6173,6 @@
5882
6173
  try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
5883
6174
  var session = getSelectedSession();
5884
6175
  if (!session) return;
5885
- if (session.provider && session.provider !== "claude") return;
5886
6176
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
5887
6177
  method: "POST",
5888
6178
  headers: { "Content-Type": "application/json" },
@@ -5899,7 +6189,8 @@
5899
6189
  updateSessionSnapshot(data);
5900
6190
  if (typeof showToast === "function") {
5901
6191
  var display = normalized || "默认";
5902
- showToast("已切换模型 " + display, "success");
6192
+ var hint = session.provider === "codex" ? "(下次对话生效)" : "";
6193
+ showToast("已切换模型 → " + display + hint, "success");
5903
6194
  }
5904
6195
  }
5905
6196
  })
@@ -5907,11 +6198,13 @@
5907
6198
  }
5908
6199
 
5909
6200
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
6201
+ var provider = state.sessionTool === "codex" ? "codex" : "claude";
5910
6202
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
5911
6203
  var payload = {
5912
6204
  cwd: cwdOverride || getEffectiveCwd(),
5913
6205
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
5914
- runner: state.structuredRunner || "claude-cli-print",
6206
+ provider: provider,
6207
+ runner: provider === "codex" ? "codex-cli-exec" : (state.structuredRunner || "claude-cli-print"),
5915
6208
  prompt: prompt || undefined,
5916
6209
  worktreeEnabled: worktreeEnabled === true,
5917
6210
  model: modelPref || undefined
@@ -5981,11 +6274,6 @@
5981
6274
  var tool = state.sessionTool || "claude";
5982
6275
  var sessionKind = state.sessionCreateKind || "structured";
5983
6276
 
5984
- if (tool === "codex" && sessionKind === "structured") {
5985
- sessionKind = "pty";
5986
- state.sessionCreateKind = "pty";
5987
- }
5988
-
5989
6277
  state.sessionTool = tool;
5990
6278
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
5991
6279
 
@@ -6002,7 +6290,7 @@
6002
6290
  if (kindCards.length) {
6003
6291
  kindCards.forEach(function(card) {
6004
6292
  var kind = card.getAttribute("data-session-kind");
6005
- var disabled = tool === "codex" && kind === "structured";
6293
+ var disabled = false;
6006
6294
  card.classList.toggle("active", kind === sessionKind);
6007
6295
  card.classList.toggle("disabled", disabled);
6008
6296
  });
@@ -6384,12 +6672,22 @@
6384
6672
  // 设计原则:terminal 写入只走 ws init 与 chunk hot-path 两条
6385
6673
  // 权威路径——参见 case "init" 的 replace 写入与 onmessage
6386
6674
  // chunk 处理。这里只在 ws 离线兜底时才 append 写入。
6387
- if (!state.wsConnected) {
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) {
6388
6686
  syncTerminalBuffer(id, data.output, { mode: "append" });
6389
6687
  // 离线兜底路径自己负责 fit + replay,否则尺寸不对。
6390
6688
  ensureTerminalFit("session-switch", { forceReplay: true });
6391
6689
  } else {
6392
- // ws 在线场景:仅校准列宽,不重 replay(init 的
6690
+ // ws 在线/连接中:仅校准列宽,不重 replay(init 的
6393
6691
  // ensureTerminalFitWithRetry("init") 会负责按真实
6394
6692
  // 宽度的全量基线写入)。
6395
6693
  ensureTerminalFit("session-switch");
@@ -6545,7 +6843,7 @@
6545
6843
  lastFocusedElement = document.activeElement;
6546
6844
  state.sessionTool = getPreferredTool();
6547
6845
  state.preferredCommand = state.sessionTool;
6548
- state.sessionCreateKind = state.sessionTool === "codex" ? "pty" : "structured";
6846
+ state.sessionCreateKind = "structured";
6549
6847
  state.sessionCreateWorktree = false;
6550
6848
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
6551
6849
  syncSessionModalUI();
@@ -7752,6 +8050,29 @@
7752
8050
  el.classList.remove("hidden");
7753
8051
  }
7754
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
+
7755
8076
  function quickStartSession() {
7756
8077
  var command = getPreferredTool();
7757
8078
  var defaultCwd = getEffectiveCwd();
@@ -7762,7 +8083,7 @@
7762
8083
  method: "POST",
7763
8084
  headers: { "Content-Type": "application/json" },
7764
8085
  credentials: "same-origin",
7765
- body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
8086
+ body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
7766
8087
  })
7767
8088
  .then(function(res) { return res.json(); })
7768
8089
  .then(function(data) {
@@ -7807,12 +8128,13 @@
7807
8128
  }
7808
8129
 
7809
8130
  function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
7810
- 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);
7811
8133
  _sessionCreating = true;
7812
8134
  state.modeValue = mode;
7813
8135
  state.chatMode = mode;
7814
- state.sessionTool = "claude";
7815
- state.preferredCommand = "claude";
8136
+ state.sessionTool = provider;
8137
+ state.preferredCommand = provider;
7816
8138
  syncComposerModeSelect();
7817
8139
  syncComposerModelSelect(getSelectedSession());
7818
8140
  return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
@@ -7842,13 +8164,13 @@
7842
8164
  method: "POST",
7843
8165
  headers: { "Content-Type": "application/json" },
7844
8166
  credentials: "same-origin",
7845
- body: JSON.stringify({
8167
+ body: JSON.stringify(withTerminalDimensions({
7846
8168
  command: command,
7847
8169
  provider: command,
7848
8170
  cwd: cwd,
7849
8171
  mode: mode,
7850
8172
  worktreeEnabled: worktreeEnabled
7851
- })
8173
+ }))
7852
8174
  })
7853
8175
  .then(function(res) { return res.json(); })
7854
8176
  .then(function(data) {
@@ -8339,47 +8661,6 @@
8339
8661
  }
8340
8662
  }
8341
8663
 
8342
- function createOptimizeShimmer(inputBox, text) {
8343
- var composer = inputBox.closest(".input-composer");
8344
- if (!composer) return null;
8345
- // 用 mirror 元素同步 textarea 的文字 + 字体 + 排版参数,叠在
8346
- // textarea 之上。mirror 自身 color: transparent + background-clip:
8347
- // text,让动画渐变只在文字字符形状内显示——空白处完全透明,所以
8348
- // 视觉上只有"几个字"被光扫过,像魔法橡皮擦掠过文字本身,而不是
8349
- // 整个输入框背景在闪。
8350
- var rect = inputBox.getBoundingClientRect();
8351
- var composerRect = composer.getBoundingClientRect();
8352
- var style = window.getComputedStyle(inputBox);
8353
- var mirror = document.createElement("div");
8354
- mirror.className = "prompt-optimize-shimmer-overlay";
8355
- mirror.textContent = text;
8356
- // 与 textarea 同坐标同尺寸(composer 是 position:relative,所以
8357
- // 用相对 composer 的 offset;不直接用 inputBox.offsetTop 是因为
8358
- // textarea 可能有变换/包裹元素,getBoundingClientRect 更稳)。
8359
- mirror.style.top = (rect.top - composerRect.top) + "px";
8360
- mirror.style.left = (rect.left - composerRect.left) + "px";
8361
- mirror.style.width = rect.width + "px";
8362
- mirror.style.height = rect.height + "px";
8363
- // 复制 textarea 的字符排版,确保 mirror 上的字符与 textarea 渲染
8364
- // 的字符像素级对齐(错位会让"光从文字扫过"看起来糊掉)。
8365
- mirror.style.fontFamily = style.fontFamily;
8366
- mirror.style.fontSize = style.fontSize;
8367
- mirror.style.fontWeight = style.fontWeight;
8368
- mirror.style.fontStyle = style.fontStyle;
8369
- mirror.style.lineHeight = style.lineHeight;
8370
- mirror.style.letterSpacing = style.letterSpacing;
8371
- mirror.style.wordSpacing = style.wordSpacing;
8372
- mirror.style.textAlign = style.textAlign;
8373
- mirror.style.textIndent = style.textIndent;
8374
- mirror.style.paddingTop = style.paddingTop;
8375
- mirror.style.paddingRight = style.paddingRight;
8376
- mirror.style.paddingBottom = style.paddingBottom;
8377
- mirror.style.paddingLeft = style.paddingLeft;
8378
- mirror.style.boxSizing = style.boxSizing;
8379
- composer.appendChild(mirror);
8380
- return mirror;
8381
- }
8382
-
8383
8664
  var promptOptimizeInFlight = false;
8384
8665
  function optimizePromptText() {
8385
8666
  if (promptOptimizeInFlight) return;
@@ -8400,7 +8681,6 @@
8400
8681
  btn.setAttribute("title", "正在优化…");
8401
8682
  }
8402
8683
  if (composer) composer.classList.add("is-optimizing");
8403
- var shimmerOverlay = createOptimizeShimmer(inputBox, raw);
8404
8684
  inputBox.setAttribute("aria-busy", "true");
8405
8685
  var prevReadOnly = inputBox.readOnly;
8406
8686
  inputBox.readOnly = true;
@@ -8439,9 +8719,6 @@
8439
8719
  btn.setAttribute("title", "提示词优化(AI)");
8440
8720
  }
8441
8721
  if (composer) composer.classList.remove("is-optimizing");
8442
- if (shimmerOverlay && shimmerOverlay.parentNode) {
8443
- shimmerOverlay.parentNode.removeChild(shimmerOverlay);
8444
- }
8445
8722
  inputBox.removeAttribute("aria-busy");
8446
8723
  inputBox.readOnly = prevReadOnly;
8447
8724
  });
@@ -8474,8 +8751,6 @@
8474
8751
  } else {
8475
8752
  setDraftValue(finalText, true);
8476
8753
  try { inputBox.setSelectionRange(finalText.length, finalText.length); } catch (e) { /* ignore */ }
8477
- inputBox.classList.add("optimize-flash");
8478
- setTimeout(function() { inputBox.classList.remove("optimize-flash"); }, 900);
8479
8754
  }
8480
8755
  }
8481
8756
  tick();
@@ -8515,6 +8790,9 @@
8515
8790
  function isSelectedSessionRunning() {
8516
8791
  if (!state.selectedId) return false;
8517
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
+ }
8518
8796
  return !!selectedSession && selectedSession.status === "running";
8519
8797
  }
8520
8798
 
@@ -8524,6 +8802,9 @@
8524
8802
 
8525
8803
  function hasAnyBusySession() {
8526
8804
  return state.sessions.some(function(s) {
8805
+ if (isStructuredSession(s)) {
8806
+ return !!(s.structuredState && s.structuredState.inFlight) && !s.archived;
8807
+ }
8527
8808
  return s.status === "running" && !s.archived;
8528
8809
  });
8529
8810
  }
@@ -8553,12 +8834,12 @@
8553
8834
  method: "POST",
8554
8835
  headers: { "Content-Type": "application/json" },
8555
8836
  credentials: "same-origin",
8556
- body: JSON.stringify({
8837
+ body: JSON.stringify(withTerminalDimensions({
8557
8838
  command: item.tool,
8558
8839
  cwd: item.cwd,
8559
8840
  mode: item.mode,
8560
8841
  initialInput: item.text
8561
- })
8842
+ }))
8562
8843
  })
8563
8844
  .then(function(res) { return res.json(); })
8564
8845
  .then(function(data) {
@@ -8593,12 +8874,12 @@
8593
8874
  method: "POST",
8594
8875
  headers: { "Content-Type": "application/json" },
8595
8876
  credentials: "same-origin",
8596
- body: JSON.stringify({
8877
+ body: JSON.stringify(withTerminalDimensions({
8597
8878
  command: item.tool,
8598
8879
  cwd: item.cwd,
8599
8880
  mode: item.mode,
8600
8881
  initialInput: item.text
8601
- })
8882
+ }))
8602
8883
  })
8603
8884
  .then(function(res) { return res.json(); })
8604
8885
  .then(function(data) {
@@ -8774,13 +9055,13 @@
8774
9055
  method: "POST",
8775
9056
  headers: { "Content-Type": "application/json" },
8776
9057
  credentials: "same-origin",
8777
- body: JSON.stringify({
9058
+ body: JSON.stringify(withTerminalDimensions({
8778
9059
  command: preferredTool,
8779
9060
  provider: preferredTool,
8780
9061
  cwd: defaultCwd,
8781
9062
  mode: mode,
8782
9063
  initialInput: value
8783
- })
9064
+ }))
8784
9065
  })
8785
9066
  .then(function(res) { return res.json(); })
8786
9067
  .then(function(data) {
@@ -8844,13 +9125,13 @@
8844
9125
  method: "POST",
8845
9126
  headers: { "Content-Type": "application/json" },
8846
9127
  credentials: "same-origin",
8847
- body: JSON.stringify({
9128
+ body: JSON.stringify(withTerminalDimensions({
8848
9129
  command: preferredTool,
8849
9130
  provider: preferredTool,
8850
9131
  cwd: defaultCwd,
8851
9132
  mode: mode,
8852
9133
  initialInput: value || undefined
8853
- })
9134
+ }))
8854
9135
  })
8855
9136
  .then(function(res) { return res.json(); })
8856
9137
  .then(function(data) {
@@ -9046,7 +9327,9 @@
9046
9327
  // the HTTP response arrives.
9047
9328
  var epochBeforePost = state.queueEpoch;
9048
9329
 
9049
- 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", {
9050
9333
  method: "POST",
9051
9334
  headers: { "Content-Type": "application/json" },
9052
9335
  credentials: "same-origin",
@@ -9069,11 +9352,14 @@
9069
9352
  delete snapshot.queuedMessages;
9070
9353
  }
9071
9354
  updateSessionSnapshot(snapshot);
9072
- var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
9073
- state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
9074
- renderChat(true);
9075
- if (isInterrupting) {
9076
- 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
+ }
9077
9363
  }
9078
9364
  }
9079
9365
  })
@@ -9273,13 +9559,26 @@
9273
9559
  }, Promise.resolve());
9274
9560
  }
9275
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
+
9276
9578
  function queueOfflineTerminalChunks(chunks) {
9277
9579
  var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
9278
9580
  sequence.forEach(function(chunk) {
9279
- if (state.pendingMessages.length >= 100) {
9280
- state.pendingMessages.shift();
9281
- }
9282
- state.pendingMessages.push(chunk);
9581
+ enqueuePendingInput(chunk);
9283
9582
  });
9284
9583
  }
9285
9584
 
@@ -9297,16 +9596,21 @@
9297
9596
 
9298
9597
  function postInput(input, shortcutKey, viewOverride) {
9299
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;
9300
9607
  var effectiveView = viewOverride || state.currentView;
9301
9608
 
9302
9609
  // Pre-check: don't send if session is not running
9303
9610
  if (!isSelectedSessionRunning()) {
9304
9611
  // If WebSocket is disconnected, queue for flush on reconnect
9305
9612
  if (!state.wsConnected) {
9306
- if (state.pendingMessages.length >= 100) {
9307
- state.pendingMessages.shift();
9308
- }
9309
- state.pendingMessages.push(input);
9613
+ enqueuePendingInput(input);
9310
9614
  console.log("[wand] postInput: session not running, queued for reconnect", {
9311
9615
  sessionId: state.selectedId,
9312
9616
  inputLength: input.length
@@ -9322,10 +9626,7 @@
9322
9626
 
9323
9627
  // If WebSocket is disconnected, queue the message (no HTTP fetch while offline)
9324
9628
  if (!state.wsConnected) {
9325
- if (state.pendingMessages.length >= 100) {
9326
- state.pendingMessages.shift();
9327
- }
9328
- state.pendingMessages.push(input);
9629
+ enqueuePendingInput(input);
9329
9630
  console.log("[wand] postInput: WebSocket disconnected, queued message", {
9330
9631
  sessionId: state.selectedId,
9331
9632
  inputLength: input.length
@@ -9340,7 +9641,7 @@
9340
9641
  wsConnected: state.wsConnected
9341
9642
  });
9342
9643
 
9343
- return fetch("/api/sessions/" + state.selectedId + "/input", {
9644
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
9344
9645
  method: "POST",
9345
9646
  headers: { "Content-Type": "application/json" },
9346
9647
  credentials: "same-origin",
@@ -9355,11 +9656,11 @@
9355
9656
  status: res.status,
9356
9657
  errorCode: error.errorCode,
9357
9658
  message: error.message,
9358
- sessionId: state.selectedId
9659
+ sessionId: requestSessionId
9359
9660
  });
9360
9661
  // Mark session as stopped for unavailable errors
9361
9662
  if (isSessionUnavailableError(error)) {
9362
- markSessionStopped(state.selectedId, error.sessionStatus || "exited");
9663
+ markSessionStopped(requestSessionId, error.sessionStatus || "exited");
9363
9664
  }
9364
9665
  throw error;
9365
9666
  });
@@ -9368,11 +9669,18 @@
9368
9669
  })
9369
9670
  .then(function(snapshot) {
9370
9671
  if (snapshot && snapshot.id) {
9672
+ // 底层 sessions 数据按 id 索引,无论是否仍是当前选中都可以
9673
+ // 安全更新(不会污染其他会话)。
9371
9674
  updateSessionSnapshot(snapshot);
9372
- if (snapshot.messages && snapshot.messages.length > 0) {
9373
- 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);
9374
9683
  }
9375
- renderChat(true);
9376
9684
  }
9377
9685
  return snapshot;
9378
9686
  });
@@ -9394,6 +9702,18 @@
9394
9702
  return !!state.selectedId && state.currentView === "terminal";
9395
9703
  }
9396
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
+
9397
9717
  function shouldCaptureTerminalEvent(event) {
9398
9718
  if (!state.terminalInteractive || !isTerminalInteractionAvailable()) return false;
9399
9719
  if (event.defaultPrevented || event.isComposing) return false;
@@ -9561,7 +9881,9 @@
9561
9881
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
9562
9882
  var structured = isStructuredSession(selectedSession);
9563
9883
  var isCodex = selectedSession && selectedSession.provider === "codex";
9564
- var isRunning = !!selectedSession && selectedSession.status === "running";
9884
+ var isRunning = structured
9885
+ ? !!(selectedSession && selectedSession.structuredState && selectedSession.structuredState.inFlight)
9886
+ : !!selectedSession && selectedSession.status === "running";
9565
9887
  var composer = document.getElementById("input-box");
9566
9888
  // Update both toggle buttons (topbar and terminal-header)
9567
9889
  var toggles = ["terminal-interactive-toggle-top"];
@@ -9586,7 +9908,7 @@
9586
9908
  : "Enter 发送 · Shift+Enter 换行";
9587
9909
  }
9588
9910
  }
9589
- var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
9911
+ var disableStructuredInput = false;
9590
9912
  // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
9591
9913
  // 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9592
9914
  var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
@@ -9594,13 +9916,20 @@
9594
9916
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
9595
9917
  composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9596
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);
9597
9926
  }
9598
9927
  var sendBtn = document.getElementById("send-input-button");
9599
9928
  if (sendBtn) {
9600
9929
  sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9601
- sendBtn.setAttribute("title", isCodex
9602
- ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
9603
- : (structured ? "发送" : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
9930
+ sendBtn.setAttribute("title", structured
9931
+ ? "发送"
9932
+ : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
9604
9933
  }
9605
9934
  var container = document.getElementById("output");
9606
9935
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -9631,6 +9960,7 @@
9631
9960
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: state.modifiers.shift });
9632
9961
  if (sequence) sendTerminalSequence(sequence, key);
9633
9962
  clearModifiers();
9963
+ scheduleShortcutResync();
9634
9964
  }
9635
9965
 
9636
9966
  function handleInlineKeyboardClick(event) {
@@ -9647,12 +9977,28 @@
9647
9977
  if (key === "ctrl_enter") {
9648
9978
  var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
9649
9979
  if (sequence) sendTerminalSequence(sequence, "ctrl_enter");
9980
+ scheduleShortcutResync();
9650
9981
  return;
9651
9982
  }
9652
9983
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: false });
9653
9984
  if (sequence) sendTerminalSequence(sequence, key);
9654
9985
  clearModifiers();
9655
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);
9656
10002
  }
9657
10003
 
9658
10004
  function updateKeyboardPopupUI() {
@@ -9771,8 +10117,20 @@
9771
10117
 
9772
10118
  // Send queued messages in order, bypassing the session-running check
9773
10119
  // since our local state may be stale right after reconnect
9774
- 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
+ });
9775
10130
  state.pendingMessages = [];
10131
+ if (dropped > 0) {
10132
+ console.log("[wand] flushPendingMessages: dropped " + dropped + " stale input(s)");
10133
+ }
9776
10134
 
9777
10135
  var sendPromise = Promise.resolve();
9778
10136
  queue.forEach(function(input) {
@@ -9786,7 +10144,10 @@
9786
10144
 
9787
10145
  function sendInputDirect(input) {
9788
10146
  if (!input || !state.selectedId) return Promise.resolve();
9789
- return fetch("/api/sessions/" + state.selectedId + "/input", {
10147
+ // postInput:flushPendingMessages 重连后批量回放离线消息时,
10148
+ // 用户可能已在切到别的会话,必须用本次请求的 sessionId 快照。
10149
+ var requestSessionId = state.selectedId;
10150
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
9790
10151
  method: "POST",
9791
10152
  headers: { "Content-Type": "application/json" },
9792
10153
  credentials: "same-origin",
@@ -9801,7 +10162,7 @@
9801
10162
  // on the user's next message, and stale queue items would cause duplicates
9802
10163
  if (isSessionUnavailableError(error)) {
9803
10164
  console.log("[wand] sendInputDirect: session unavailable, dropping", {
9804
- sessionId: state.selectedId,
10165
+ sessionId: requestSessionId,
9805
10166
  errorCode: error.errorCode
9806
10167
  });
9807
10168
  return null;
@@ -9814,10 +10175,13 @@
9814
10175
  .then(function(snapshot) {
9815
10176
  if (snapshot && snapshot.id) {
9816
10177
  updateSessionSnapshot(snapshot);
9817
- if (snapshot.messages && snapshot.messages.length > 0) {
9818
- 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);
9819
10184
  }
9820
- renderChat(true);
9821
10185
  }
9822
10186
  return snapshot;
9823
10187
  });
@@ -9946,12 +10310,12 @@
9946
10310
  method: "POST",
9947
10311
  headers: { "Content-Type": "application/json" },
9948
10312
  credentials: "same-origin",
9949
- body: JSON.stringify({
10313
+ body: JSON.stringify(withTerminalDimensions({
9950
10314
  command: command,
9951
10315
  cwd: cwd || "",
9952
10316
  mode: state.chatMode || state.config.defaultMode || "default",
9953
10317
  model: modelPref || undefined
9954
- })
10318
+ }))
9955
10319
  })
9956
10320
  .then(function(res) { return res.json(); })
9957
10321
  .then(function(data) {
@@ -9976,9 +10340,9 @@
9976
10340
  method: "POST",
9977
10341
  headers: { "Content-Type": "application/json" },
9978
10342
  credentials: "same-origin",
9979
- body: JSON.stringify({
10343
+ body: JSON.stringify(withTerminalDimensions({
9980
10344
  mode: state.chatMode || state.config.defaultMode || "default"
9981
- })
10345
+ }))
9982
10346
  })
9983
10347
  .then(function(res) { return res.json(); })
9984
10348
  .then(function(data) {
@@ -10007,9 +10371,9 @@
10007
10371
  method: "POST",
10008
10372
  headers: { "Content-Type": "application/json" },
10009
10373
  credentials: "same-origin",
10010
- body: JSON.stringify({
10374
+ body: JSON.stringify(withTerminalDimensions({
10011
10375
  mode: state.chatMode || state.config.defaultMode || "default"
10012
- })
10376
+ }))
10013
10377
  })
10014
10378
  .then(function(res) { return res.json(); })
10015
10379
  .then(function(data) {
@@ -10087,13 +10451,13 @@
10087
10451
  method: "POST",
10088
10452
  headers: { "Content-Type": "application/json" },
10089
10453
  credentials: "same-origin",
10090
- body: JSON.stringify({
10454
+ body: JSON.stringify(withTerminalDimensions({
10091
10455
  command: preferredTool,
10092
10456
  cwd: defaultCwd,
10093
10457
  mode: mode,
10094
10458
  initialInput: value,
10095
10459
  model: modelPref || undefined
10096
- })
10460
+ }))
10097
10461
  })
10098
10462
  .then(function(res) { return res.json(); })
10099
10463
  .then(function(data) {
@@ -10125,13 +10489,13 @@
10125
10489
  method: "POST",
10126
10490
  headers: { "Content-Type": "application/json" },
10127
10491
  credentials: "same-origin",
10128
- body: JSON.stringify({
10492
+ body: JSON.stringify(withTerminalDimensions({
10129
10493
  command: preferredTool,
10130
10494
  cwd: defaultCwd,
10131
10495
  mode: mode,
10132
10496
  initialInput: value || undefined,
10133
10497
  model: modelPref || undefined
10134
- })
10498
+ }))
10135
10499
  })
10136
10500
  .then(function(res) { return res.json(); })
10137
10501
  .then(function(data) {
@@ -10187,10 +10551,10 @@
10187
10551
  method: "POST",
10188
10552
  headers: { "Content-Type": "application/json" },
10189
10553
  credentials: "same-origin",
10190
- body: JSON.stringify({
10554
+ body: JSON.stringify(withTerminalDimensions({
10191
10555
  mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
10192
10556
  cwd: cwd
10193
- })
10557
+ }))
10194
10558
  })
10195
10559
  .then(function(res) { return res.json(); })
10196
10560
  .then(function(data) {
@@ -11300,11 +11664,31 @@
11300
11664
  if (wrap.parentNode === output) output.removeChild(wrap);
11301
11665
  }
11302
11666
  }
11667
+ // widePadAnsi 是模块级状态机,跨终端实例时若卡在 esc/csi/string 等
11668
+ // 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
11669
+ // ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
11670
+ resetWideParserState();
11303
11671
  state.terminalSessionId = null;
11304
11672
  state.terminalOutput = "";
11305
11673
  state.terminalAutoFollow = true;
11306
11674
  state.showTerminalJumpToBottom = false;
11307
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;
11308
11692
  }
11309
11693
 
11310
11694
  function sendTerminalResize(cols, rows) {
@@ -11523,6 +11907,9 @@
11523
11907
  // starts the ladder from 500ms again.
11524
11908
  state.wsReconnectAttempts = 0;
11525
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 = {};
11526
11913
  // Subscribe to current session if any
11527
11914
  subscribeToSession(state.selectedId);
11528
11915
  // Flush pending messages after reconnection
@@ -11539,6 +11926,43 @@
11539
11926
  ws.onmessage = function(event) {
11540
11927
  try {
11541
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
+ }
11542
11966
  handleWebSocketMessage(msg);
11543
11967
  } catch (e) {
11544
11968
  // Ignore parse errors
@@ -11569,6 +11993,30 @@
11569
11993
  case 'output':
11570
11994
  // Update session output (for terminal display and local message parsing)
11571
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
+ }
11572
12020
  if (msg.data && msg.sessionId) {
11573
12021
  var isIncremental = !!msg.data.incremental;
11574
12022
  var snapshot = { id: msg.sessionId };
@@ -11642,7 +12090,7 @@
11642
12090
  }
11643
12091
  // Real-time terminal output
11644
12092
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
11645
- if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
12093
+ if (msg.data.chunk && isCurrentTerminalSession(msg.sessionId)) {
11646
12094
  // Fast path: write chunk directly to avoid full-output comparison.
11647
12095
  state.lastChunkAt = Date.now();
11648
12096
  state.terminalLiveStreamSessions[msg.sessionId] = true;
@@ -11656,9 +12104,9 @@
11656
12104
  maybeScheduleResyncForChunk(msg.data.chunk);
11657
12105
  state.terminalSessionId = msg.sessionId;
11658
12106
  if (msg.data.output) {
11659
- state.terminalOutput = normalizeTerminalOutput(msg.data.output);
12107
+ state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
11660
12108
  } else {
11661
- state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
12109
+ state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
11662
12110
  }
11663
12111
  maybeScrollTerminalToBottom("output");
11664
12112
  updateTerminalJumpToBottomButton();
@@ -12183,7 +12631,9 @@
12183
12631
  var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
12184
12632
  var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
12185
12633
  && selectedForDelay.sessionKind !== "structured";
12186
- 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;
12187
12637
  chatRenderTimer = setTimeout(function() {
12188
12638
  chatRenderTimer = null;
12189
12639
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
@@ -12191,7 +12641,7 @@
12191
12641
  state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
12192
12642
  }
12193
12643
  renderChat();
12194
- }, 30);
12644
+ }, delay);
12195
12645
  }
12196
12646
 
12197
12647
  // Extract system info from PTY output that's not in structured messages
@@ -15029,6 +15479,21 @@
15029
15479
  var _progressSyncTimers = {};
15030
15480
  var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
15031
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
+
15032
15497
  function syncSessionProgressToNative(sessionId) {
15033
15498
  if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
15034
15499
  if (!sessionId) return;
@@ -15054,21 +15519,56 @@
15054
15519
  return;
15055
15520
  }
15056
15521
 
15057
- // 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.
15058
15527
  var todos = null;
15528
+ var latestUserText = "";
15529
+ var latestAssistantText = "";
15059
15530
  var messages = session.messages || [];
15060
15531
  for (var i = messages.length - 1; i >= 0; i--) {
15061
15532
  var msg = messages[i];
15062
15533
  if (!msg.content || !Array.isArray(msg.content)) continue;
15063
- for (var j = msg.content.length - 1; j >= 0; j--) {
15064
- var block = msg.content[j];
15065
- if (block.type === "tool_use" && block.name === "TodoWrite"
15066
- && block.input && block.input.todos) {
15067
- todos = block.input.todos;
15068
- 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
+ }
15069
15542
  }
15070
15543
  }
15071
- 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;
15072
15572
  }
15073
15573
 
15074
15574
  // Get current task
@@ -15081,6 +15581,8 @@
15081
15581
  sessionLabel: sessionLabel,
15082
15582
  status: sessionStatus,
15083
15583
  currentTask: currentTask,
15584
+ latestUserText: latestUserText,
15585
+ latestAssistantText: latestAssistantText,
15084
15586
  todos: todos || []
15085
15587
  };
15086
15588