@co0ontty/wand 1.20.4 → 1.21.5

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">' +
@@ -2033,6 +2126,14 @@
2033
2126
  '</div>' +
2034
2127
  '</div>' +
2035
2128
  '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
2129
+ '<div class="field">' +
2130
+ '<label class="field-label" for="cfg-structured-runner">结构化会话 Runner</label>' +
2131
+ '<select id="cfg-structured-runner" class="field-input">' +
2132
+ '<option value="cli">CLI(spawn claude -p,默认)</option>' +
2133
+ '<option value="sdk">SDK(@anthropic-ai/claude-agent-sdk)</option>' +
2134
+ '</select>' +
2135
+ '<p class="field-hint" style="margin-top:4px;">SDK 模式使用官方 Agent SDK 替代 CLI subprocess,接口更整洁,功能等价。重启后生效。</p>' +
2136
+ '</div>' +
2036
2137
  '<div class="field">' +
2037
2138
  '<label class="field-label" for="cfg-default-model">默认模型</label>' +
2038
2139
  '<div class="settings-row-with-action">' +
@@ -2672,10 +2773,16 @@
2672
2773
 
2673
2774
  function applyTerminalScale() {
2674
2775
  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);
2776
+ // 字号和行高都向上取整到整数像素:PC DPR 下浏览器对亚像素
2777
+ // 字号/行高的舍入策略不一致(fontSize 16.25 16 17,行高
2778
+ // 19.5 → 19 或 20),相邻行/列的吸附方向不同就会让 wterm 网格
2779
+ // 错位。强制整数 px 让 cell 高度、字符高度都稳定一致,等价于
2780
+ // 之前桌面端必须按右上角缩放才能恢复的"整像素重排"路径。
2781
+ var rawFontSize = state.terminalBaseFontSize * state.terminalScale;
2782
+ var fontPx = Math.max(1, Math.round(rawFontSize));
2783
+ var rowPx = Math.max(1, Math.round(rawFontSize * 1.5));
2784
+ state.terminal.element.style.setProperty("--term-font-size", fontPx + "px");
2785
+ state.terminal.element.style.setProperty("--term-row-height", rowPx + "px");
2679
2786
  if (typeof state.terminal.remeasure === "function") {
2680
2787
  requestAnimationFrame(function() {
2681
2788
  if (state.terminal) state.terminal.remeasure();
@@ -2994,11 +3101,14 @@
2994
3101
  // Code blocks with syntax highlighting
2995
3102
  escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
2996
3103
  var highlighted = highlightCodePreview(code.trim(), lang);
2997
- return '<pre><code class="language-' + lang + '">' + highlighted + '</code></pre>';
3104
+ var protectedHighlighted = highlighted.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
3105
+ return '<pre><code class="language-' + lang + '">' + protectedHighlighted + '</code></pre>';
2998
3106
  });
2999
3107
 
3000
3108
  // Inline code
3001
- escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
3109
+ escaped = escaped.replace(/`([^`]+)`/g, function(_, code) {
3110
+ return '<code>' + code.replace(/_/g, '&#95;').replace(/\*/g, '&#42;') + '</code>';
3111
+ });
3002
3112
 
3003
3113
  // Headers
3004
3114
  escaped = escaped.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
@@ -3012,9 +3122,9 @@
3012
3122
  escaped = escaped.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
3013
3123
  escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
3014
3124
  escaped = escaped.replace(/\*(.+?)\*/g, '<em>$1</em>');
3015
- escaped = escaped.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
3016
- escaped = escaped.replace(/__(.+?)__/g, '<strong>$1</strong>');
3017
- escaped = escaped.replace(/_(.+?)_/g, '<em>$1</em>');
3125
+ escaped = escaped.replace(/(^|[^\w])___(\S(?:[^\n]*?\S)?)___(?!\w)/g, '$1<strong><em>$2</em></strong>');
3126
+ escaped = escaped.replace(/(^|[^\w])__(\S(?:[^\n]*?\S)?)__(?!\w)/g, '$1<strong>$2</strong>');
3127
+ escaped = escaped.replace(/(^|[^\w])_(\S(?:[^\n_]*?\S)?)_(?!\w)/g, '$1<em>$2</em>');
3018
3128
 
3019
3129
  // Strikethrough
3020
3130
  escaped = escaped.replace(/~~(.+?)~~/g, '<del>$1</del>');
@@ -3135,7 +3245,9 @@
3135
3245
  if (!session) return "";
3136
3246
  if (session.archived) return "已归档";
3137
3247
  if (session.permissionBlocked) return "等待授权";
3248
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "思考中";
3138
3249
  var statusMap = {
3250
+ "idle": "空闲",
3139
3251
  "stopped": "已停止",
3140
3252
  "running": "运行中",
3141
3253
  "exited": "已退出",
@@ -3148,6 +3260,7 @@
3148
3260
  if (!session) return "";
3149
3261
  if (session.archived) return "archived";
3150
3262
  if (session.permissionBlocked) return "permission-blocked";
3263
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "running";
3151
3264
  return session.status || "";
3152
3265
  }
3153
3266
 
@@ -3321,7 +3434,7 @@
3321
3434
  function renderProviderOptions(selectedTool) {
3322
3435
  var tools = [
3323
3436
  { id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
3324
- { id: "codex", label: "Codex", desc: "PTY 透传,全权限启动" }
3437
+ { id: "codex", label: "Codex", desc: "结构化 JSONL 或 PTY 会话" }
3325
3438
  ];
3326
3439
  return tools.map(function(tool) {
3327
3440
  var active = tool.id === selectedTool ? " active" : "";
@@ -3339,7 +3452,7 @@
3339
3452
  ];
3340
3453
  return kinds.map(function(kind) {
3341
3454
  var active = kind.id === selectedKind ? " active" : "";
3342
- var disabled = (state.sessionTool === "codex" && kind.id === "structured") ? " disabled" : "";
3455
+ var disabled = "";
3343
3456
  return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
3344
3457
  '<span class="mode-card-label">' + kind.label + '</span>' +
3345
3458
  '<span class="mode-card-desc">' + kind.desc + '</span>' +
@@ -3357,10 +3470,12 @@
3357
3470
  function getSessionKindHint(kind) {
3358
3471
  var tool = state.sessionTool || "claude";
3359
3472
  if (kind === "structured") {
3360
- return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3473
+ return tool === "codex"
3474
+ ? "Codex JSONL 结构化聊天界面,支持多轮对话和工具调用展示。"
3475
+ : "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
3361
3476
  }
3362
3477
  if (tool === "codex") {
3363
- return "Codex 仅支持 PTY;terminal 是原始输出,chat 是解析后的阅读视图。";
3478
+ return "Codex PTY 终端会话;terminal 是原始输出,chat 是解析后的阅读视图。";
3364
3479
  }
3365
3480
  return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
3366
3481
  }
@@ -3748,10 +3863,9 @@
3748
3863
  if (provider) {
3749
3864
  state.sessionTool = provider;
3750
3865
  state.preferredCommand = provider;
3751
- if (provider === "codex") {
3752
- state.sessionCreateKind = "pty";
3753
- state.modeValue = "full-access";
3754
- }
3866
+ // Codex 现在同时支持 PTY 与结构化 runner,不再强制把 kind 切成 pty。
3867
+ // mode 由 syncSessionModalUI() 调用 getSafeModeForTool() 自动 clamp,
3868
+ // 不在这里硬写。
3755
3869
  syncSessionModalUI();
3756
3870
  }
3757
3871
  });
@@ -4929,6 +5043,17 @@
4929
5043
  return distance <= state.terminalScrollThreshold;
4930
5044
  }
4931
5045
 
5046
+ // 严格"真正到底"判定(仅亚像素 jitter 容忍):用于把 autoFollow 从 false
5047
+ // 翻回 true。不能用 isTerminalNearBottom 的 12px 阈值,否则用户在底部小幅
5048
+ // 向上滚时,wheel handler 把 autoFollow 设 false 后紧接着触发的 scroll
5049
+ // 事件会因为"还没滚出阈值"而把 autoFollow 反转回 true,丢失用户意图。
5050
+ function isTerminalAtBottom() {
5051
+ var viewport = getTerminalViewport();
5052
+ if (!viewport) return true;
5053
+ var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
5054
+ return distance <= 2;
5055
+ }
5056
+
4932
5057
  function scrollTerminalToBottom(smooth) {
4933
5058
  if (!state.terminal) return;
4934
5059
  var viewport = getTerminalViewport();
@@ -4969,11 +5094,13 @@
4969
5094
  updateTerminalJumpToBottomButton();
4970
5095
  return;
4971
5096
  }
4972
- if (!state.terminalAutoFollow && !isTerminalNearBottom()) {
5097
+ // 只看 autoFollow 标志:用户主动 wheel/touch 后该标志被设为 false,
5098
+ // 即使当前位置仍在底部 12px 阈值内也不再强行滚回,避免把用户刚滚上去
5099
+ // 的几像素吞掉。autoFollow 由 scroll handler 在"真正到底"时恢复。
5100
+ if (!state.terminalAutoFollow) {
4973
5101
  updateTerminalJumpToBottomButton();
4974
5102
  return;
4975
5103
  }
4976
- state.terminalAutoFollow = true;
4977
5104
  scrollTerminalToBottom(false);
4978
5105
  updateTerminalJumpToBottomButton();
4979
5106
  }
@@ -5262,6 +5389,13 @@
5262
5389
  if (!terminal || data == null) return;
5263
5390
  if (!state.wideParserState) state.wideParserState = createWideParserState();
5264
5391
  terminal.write(widePadAnsi(data, state.wideParserState));
5392
+ // wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
5393
+ // scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
5394
+ // true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
5395
+ // 的 autoFollow 状态,让 autoFollow 成为唯一真相。
5396
+ if ("_shouldScrollToBottom" in terminal) {
5397
+ terminal._shouldScrollToBottom = state.terminalAutoFollow !== false;
5398
+ }
5265
5399
  }
5266
5400
 
5267
5401
  function resetWideParserState() {
@@ -5290,6 +5424,62 @@
5290
5424
  }
5291
5425
  stripWideFillerForCopy();
5292
5426
 
5427
+ // PTY 链路节流不变式:
5428
+ // 服务端 OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
5429
+ // RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
5430
+ // 违反这两条会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
5431
+ var CHAT_RENDER_LIVE_MS = 150;
5432
+ var CHAT_RENDER_IDLE_MS = 30;
5433
+
5434
+ // state.terminalOutput 仅作 softResyncTerminal 的重放源(wterm 有自己的
5435
+ // scrollback),所以必须限长,否则长跑会话每次 resync 都喂几 MB 给 wterm。
5436
+ // 裁切优先在行边界(ANSI 状态机此时一定 idle,重放等价),找不到再按字节切
5437
+ // 并避开 UTF-16 半截 / ANSI 半截。
5438
+ var CLIENT_OUTPUT_MAX = 256 * 1024;
5439
+ var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
5440
+ function clampClientTerminalOutput(buf) {
5441
+ if (!buf || buf.length <= CLIENT_OUTPUT_TRIM_AT) return buf;
5442
+ var start = buf.length - CLIENT_OUTPUT_MAX;
5443
+ // UTF-16 low surrogate
5444
+ if (start > 0 && start < buf.length) {
5445
+ var c0 = buf.charCodeAt(start);
5446
+ if (c0 >= 0xdc00 && c0 <= 0xdfff) start++;
5447
+ }
5448
+ // 优先在 lookahead 内找下一个换行符切割
5449
+ var LOOKAHEAD = 4096;
5450
+ var upper = Math.min(start + LOOKAHEAD, buf.length);
5451
+ for (var i = start; i < upper; i++) {
5452
+ if (buf.charCodeAt(i) === 0x0a) return buf.slice(i + 1);
5453
+ }
5454
+ // 没换行 → 检查 start 是否落在未结束的 ESC 序列里
5455
+ var lookback = Math.max(0, start - 256);
5456
+ var escAt = -1;
5457
+ for (var j = start - 1; j >= lookback; j--) {
5458
+ var c = buf.charCodeAt(j);
5459
+ if (c === 0x1b) { escAt = j; break; }
5460
+ if (c === 0x07) break;
5461
+ if (c >= 0x40 && c <= 0x7e) break;
5462
+ }
5463
+ if (escAt !== -1) {
5464
+ var terminated = false;
5465
+ for (var k = escAt + 1; k < start; k++) {
5466
+ var ck = buf.charCodeAt(k);
5467
+ if (ck === 0x07) { terminated = true; break; }
5468
+ if (ck >= 0x40 && ck <= 0x7e) { terminated = true; break; }
5469
+ }
5470
+ if (!terminated) {
5471
+ var ahead = Math.min(start + 256, buf.length);
5472
+ for (var m = start; m < ahead; m++) {
5473
+ var cm = buf.charCodeAt(m);
5474
+ if (cm === 0x07 || (cm >= 0x40 && cm <= 0x7e)) {
5475
+ return buf.slice(m + 1);
5476
+ }
5477
+ }
5478
+ }
5479
+ }
5480
+ return buf.slice(start);
5481
+ }
5482
+
5293
5483
  function resetTerminal() {
5294
5484
  if (!state.terminal) return;
5295
5485
  // 优先走 wterm-entry.js 自定义 WTerm 子类暴露的 reset():它会调用
@@ -5308,21 +5498,47 @@
5308
5498
  resetWideParserState();
5309
5499
  }
5310
5500
 
5311
- // Soft resync terminal: reset WASM grid and replay full output buffer.
5312
- // Clears any stale DOM rows left over from CSI cursor-jump sequences
5313
- // (e.g. Claude permission menus redrawing in place while user holds arrow keys).
5314
- // Pass { skipFit: true } when the caller knows the grid was just sized
5315
- // correctly (e.g. wterm.onResize fired this resync — bouncing back into
5316
- // ensureTerminalFit would just trigger another remeasure resize onResize
5317
- // softResyncTerminal recursion).
5501
+ // Reset wterm WASM grid and replay the full output buffer to clear stale
5502
+ // DOM rows left by CSI cursor-jump sequences (Claude permission menus etc.).
5503
+ // Replays the *whole* buffer because alt-screen / scroll-region / charset
5504
+ // mode switches must be consumed from the start; cutting the middle drops
5505
+ // those state-machine instructions and corrupts the grid.
5506
+ // Pass { skipFit: true } when the caller already sized the grid (e.g.
5507
+ // wterm.onResize fired this resync) — otherwise ensureTerminalFit recurses.
5508
+ var _resyncStatsWindowStart = 0;
5509
+ var _resyncStatsCount = 0;
5510
+ var _resyncLastWarnAt = 0;
5511
+ var RESYNC_BUDGET_WINDOW_MS = 5000;
5512
+ var RESYNC_BUDGET_MAX = 12;
5513
+ var RESYNC_WARN_COOLDOWN_MS = 30000;
5318
5514
  function softResyncTerminal(options) {
5319
5515
  if (!state.terminal || !state.terminalOutput) return false;
5320
5516
  var opts = options || {};
5517
+ var bufLen = state.terminalOutput.length;
5518
+ var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
5321
5519
  resetTerminal();
5322
5520
  wandTerminalWrite(state.terminal, state.terminalOutput);
5323
5521
  state.lastTerminalResyncAt = Date.now();
5324
5522
  maybeScrollTerminalToBottom("output");
5325
5523
  if (!opts.skipFit) ensureTerminalFit("refresh");
5524
+ // 统计 5s 窗口内的 resync 次数,过密时打 warn 帮助诊断
5525
+ // ——比如 wterm 状态机被反复弄脏、上游持续推原地重绘的菜单。
5526
+ // 单次 warn 后冷却 30s,避免刷屏。
5527
+ var now = Date.now();
5528
+ if (now - _resyncStatsWindowStart > RESYNC_BUDGET_WINDOW_MS) {
5529
+ _resyncStatsWindowStart = now;
5530
+ _resyncStatsCount = 1;
5531
+ } else {
5532
+ _resyncStatsCount++;
5533
+ if (_resyncStatsCount > RESYNC_BUDGET_MAX && now - _resyncLastWarnAt > RESYNC_WARN_COOLDOWN_MS) {
5534
+ _resyncLastWarnAt = now;
5535
+ var endedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
5536
+ console.warn("[wand] softResyncTerminal high frequency",
5537
+ "count=" + _resyncStatsCount + "/" + Math.round((now - _resyncStatsWindowStart) / 100) / 10 + "s",
5538
+ "bufLen=" + bufLen,
5539
+ "lastReplayMs=" + Math.round(endedAt - startedAt));
5540
+ }
5541
+ }
5326
5542
  return true;
5327
5543
  }
5328
5544
 
@@ -5334,26 +5550,40 @@
5334
5550
  }, typeof delayMs === "number" ? delayMs : 150);
5335
5551
  }
5336
5552
 
5337
- // Claude CLI 的 permission 菜单 / 选择列表,在用户按方向键时会
5338
- // 发送光标定位 (CSI H/f)、光标移动 (CSI A-D)、擦除显示/行 (CSI
5339
- // J/K) 等序列在原地重绘整块区域。wterm 在这种高频原地重绘下,
5340
- // DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
5341
- // 用户体感就是"明明在改菜单,结果跑到最上面去了"。
5342
- //
5343
- // 已有的 pendingEscalation/permissionBlocked 状态变化触发的
5344
- // scheduleSoftResyncTerminal 在这种场景下不会触发(这两个布尔
5345
- // 在菜单交互过程中不变),health check 的 30s 兜底也太慢,且
5346
- // 连续按键时 chunkPause 永远不成立(lastChunkAt 一直在刷新)。
5553
+ // Claude CLI 的 permission 菜单 / 选择列表在方向键下会发原地重绘序列
5554
+ // (CSI A-D / J / K / H / f)。wterm 在这种高频原地重绘下 DOM 行容易残留
5555
+ // 或错位,必须用 softResyncTerminal 兜底。
5347
5556
  //
5348
- // 这里在写 chunk 时被动检测:含上述序列就 schedule 一次 350ms
5349
- // debounce softResync。连续按键时 timer 反复被重置,仅在
5350
- // 停顿后真正重放一次 buffer,开销可控。
5557
+ // 触发用 leading + tail 节流而非 debounce:用户持续按键时每次 chunk 都会
5558
+ // reset debounce timer,永远等不到静默期。leading 立即 resync、窗口内
5559
+ // 用尾巴 timer 收尾,不依赖按键停顿。
5351
5560
  var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
5561
+ var RESYNC_THROTTLE_MS = 400;
5562
+ var RESYNC_TAIL_MS = 350;
5563
+ var _resyncChunkLastAt = 0;
5564
+ var _resyncChunkTailTimer = null;
5352
5565
  function maybeScheduleResyncForChunk(chunk) {
5353
5566
  if (!chunk || typeof chunk !== "string") return;
5354
5567
  if (chunk.indexOf("\x1b[") === -1) return;
5355
5568
  if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
5356
- scheduleSoftResyncTerminal(350);
5569
+ var now = Date.now();
5570
+ var sinceLast = now - _resyncChunkLastAt;
5571
+ if (sinceLast >= RESYNC_THROTTLE_MS) {
5572
+ if (_resyncChunkTailTimer) {
5573
+ clearTimeout(_resyncChunkTailTimer);
5574
+ _resyncChunkTailTimer = null;
5575
+ }
5576
+ _resyncChunkLastAt = now;
5577
+ softResyncTerminal();
5578
+ return;
5579
+ }
5580
+ if (_resyncChunkTailTimer) return;
5581
+ var wait = Math.max(RESYNC_TAIL_MS, RESYNC_THROTTLE_MS - sinceLast);
5582
+ _resyncChunkTailTimer = setTimeout(function() {
5583
+ _resyncChunkTailTimer = null;
5584
+ _resyncChunkLastAt = Date.now();
5585
+ softResyncTerminal();
5586
+ }, wait);
5357
5587
  }
5358
5588
 
5359
5589
  function syncTerminalBuffer(sessionId, output, options) {
@@ -5465,6 +5695,11 @@
5465
5695
  termWrap.className = "terminal-scroll-wrap";
5466
5696
  container.appendChild(termWrap);
5467
5697
 
5698
+ // cols/rows 给一个保守默认即可:wterm-entry.js 重写的 init()
5699
+ // 会在 super.init() 之前按 termWrap 真实尺寸做一次预校准,
5700
+ // 保证 super.init() 里 bridge.init / renderer.setup 一上来
5701
+ // 就按真实 cols 初始化,从源头消除"先按 120 写一遍 → 异步
5702
+ // remeasure 纠正"的时序窗口。
5468
5703
  var term = new WTermLib.WTerm(termWrap, {
5469
5704
  cols: 120,
5470
5705
  rows: 36,
@@ -5502,13 +5737,32 @@
5502
5737
  state.terminal = term;
5503
5738
  state.terminalInitializing = false;
5504
5739
  applyTerminalScale();
5740
+
5741
+ // wterm 构造时 cols/rows 是硬编码的 120/36,super.init() 内部
5742
+ // 的 ResizeObserver 要等下一个 layout 阶段异步 fire 才纠正。
5743
+ // 如果不在写入历史前就把 bridge reflow 到容器真实尺寸,
5744
+ // syncTerminalBuffer 会按 120 cols 把整段历史写进 WASM grid,
5745
+ // 用户首屏看到的就是错列宽折行——必须等 ResizeObserver 触发
5746
+ // softResync 才恢复,中间会有几帧明显错位("刚开终端布局错乱
5747
+ // 一下、resize 一下才正常")。这里先强制一次 layout flush,
5748
+ // 再同步 remeasure 把 bridge 校准到真实 cols/rows,把"写入"
5749
+ // 卡在正确尺寸之后,避免错位帧。
5750
+ if (termWrap.isConnected) {
5751
+ void termWrap.offsetHeight;
5752
+ if (typeof term.remeasure === "function") {
5753
+ try { term.remeasure(); } catch (e) { /* ignore: 非致命 */ }
5754
+ }
5755
+ }
5756
+
5505
5757
  state.terminalAutoFollow = true;
5506
5758
  clearTerminalScrollIdleTimer();
5507
5759
 
5508
5760
  var viewport = getTerminalViewport();
5509
5761
  if (viewport) {
5510
5762
  state.terminalViewportScrollHandler = function() {
5511
- if (isTerminalNearBottom()) {
5763
+ // 严格"真正到底"才恢复 autoFollow:避免 wheel 设 false 后被
5764
+ // 紧接着的 scroll 事件因"接近底部 12px"而反转回 true。
5765
+ if (isTerminalAtBottom()) {
5512
5766
  state.terminalAutoFollow = true;
5513
5767
  clearTerminalScrollIdleTimer();
5514
5768
  updateTerminalJumpToBottomButton();
@@ -5665,6 +5919,11 @@
5665
5919
  if (terminalInteractive) {
5666
5920
  return "终端交互模式开启中,请直接在终端中输入";
5667
5921
  }
5922
+ if (session && isStructuredSession(session)) {
5923
+ return session.provider === "codex"
5924
+ ? "向 Codex 发送消息;chat 为结构化对话视图"
5925
+ : "向 Claude 发送消息;chat 为结构化对话视图";
5926
+ }
5668
5927
  if (session && session.provider === "codex") {
5669
5928
  if (session.status !== "running") {
5670
5929
  return "Codex 会话已结束,无法继续发送";
@@ -5686,7 +5945,7 @@
5686
5945
 
5687
5946
  function getToolModeHint(tool, mode) {
5688
5947
  if (tool === "codex") {
5689
- return "Codex 当前仅支持 PTY 透传,并固定以 full-access 启动。";
5948
+ return "Codex 支持 PTY 终端与结构化(JSONL)两种会话,结构化模式按 full-access 启动。";
5690
5949
  }
5691
5950
  if (mode === "full-access") {
5692
5951
  return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
@@ -5790,15 +6049,20 @@
5790
6049
  return "";
5791
6050
  }
5792
6051
 
5793
- function renderChatModelOptions(selected) {
5794
- var models = state.availableModels || [];
6052
+ function getModelsForCurrentProvider(session) {
6053
+ var provider = (session && session.provider) || state.sessionTool || "claude";
6054
+ if (provider === "codex") return state.availableCodexModels || [];
6055
+ return state.availableModels || [];
6056
+ }
6057
+
6058
+ function renderChatModelOptions(selected, session) {
6059
+ var models = getModelsForCurrentProvider(session);
5795
6060
  var html = '<option value="">默认(跟随设置)</option>';
5796
6061
  for (var i = 0; i < models.length; i++) {
5797
6062
  var m = models[i];
5798
6063
  var label = m.label || m.id;
5799
6064
  html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
5800
6065
  }
5801
- // If selected is unknown (custom value), prepend it as a sticky option
5802
6066
  if (selected && !models.some(function(m) { return m.id === selected; })) {
5803
6067
  html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
5804
6068
  }
@@ -5809,7 +6073,7 @@
5809
6073
  var select = document.getElementById("chat-model-select");
5810
6074
  if (!select) return;
5811
6075
  var effective = getEffectiveModel(session);
5812
- select.innerHTML = renderChatModelOptions(effective);
6076
+ select.innerHTML = renderChatModelOptions(effective, session);
5813
6077
  select.value = effective;
5814
6078
  }
5815
6079
 
@@ -5819,6 +6083,7 @@
5819
6083
  .then(function(data) {
5820
6084
  if (data && Array.isArray(data.models)) {
5821
6085
  state.availableModels = data.models;
6086
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5822
6087
  syncComposerModelSelect(getSelectedSession());
5823
6088
  updateSettingsDefaultModelSelect(data);
5824
6089
  }
@@ -5837,6 +6102,7 @@
5837
6102
  .then(function(data) {
5838
6103
  if (data && Array.isArray(data.models)) {
5839
6104
  state.availableModels = data.models;
6105
+ state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
5840
6106
  syncComposerModelSelect(getSelectedSession());
5841
6107
  updateSettingsDefaultModelSelect(data);
5842
6108
  if (typeof showToast === "function") {
@@ -5860,7 +6126,7 @@
5860
6126
  if (!select) return;
5861
6127
  var previous = select.value;
5862
6128
  var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
5863
- select.innerHTML = renderChatModelOptions(current);
6129
+ select.innerHTML = renderChatModelOptions(current, { provider: "claude" });
5864
6130
  select.value = current;
5865
6131
  var versionEl = document.getElementById("cfg-default-model-version");
5866
6132
  if (versionEl && data) {
@@ -5882,7 +6148,6 @@
5882
6148
  try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
5883
6149
  var session = getSelectedSession();
5884
6150
  if (!session) return;
5885
- if (session.provider && session.provider !== "claude") return;
5886
6151
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
5887
6152
  method: "POST",
5888
6153
  headers: { "Content-Type": "application/json" },
@@ -5899,7 +6164,8 @@
5899
6164
  updateSessionSnapshot(data);
5900
6165
  if (typeof showToast === "function") {
5901
6166
  var display = normalized || "默认";
5902
- showToast("已切换模型 " + display, "success");
6167
+ var hint = session.provider === "codex" ? "(下次对话生效)" : "";
6168
+ showToast("已切换模型 → " + display + hint, "success");
5903
6169
  }
5904
6170
  }
5905
6171
  })
@@ -5907,11 +6173,13 @@
5907
6173
  }
5908
6174
 
5909
6175
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
6176
+ var provider = state.sessionTool === "codex" ? "codex" : "claude";
5910
6177
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
5911
6178
  var payload = {
5912
6179
  cwd: cwdOverride || getEffectiveCwd(),
5913
6180
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
5914
- runner: state.structuredRunner || "claude-cli-print",
6181
+ provider: provider,
6182
+ runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
5915
6183
  prompt: prompt || undefined,
5916
6184
  worktreeEnabled: worktreeEnabled === true,
5917
6185
  model: modelPref || undefined
@@ -5981,11 +6249,6 @@
5981
6249
  var tool = state.sessionTool || "claude";
5982
6250
  var sessionKind = state.sessionCreateKind || "structured";
5983
6251
 
5984
- if (tool === "codex" && sessionKind === "structured") {
5985
- sessionKind = "pty";
5986
- state.sessionCreateKind = "pty";
5987
- }
5988
-
5989
6252
  state.sessionTool = tool;
5990
6253
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
5991
6254
 
@@ -6002,7 +6265,7 @@
6002
6265
  if (kindCards.length) {
6003
6266
  kindCards.forEach(function(card) {
6004
6267
  var kind = card.getAttribute("data-session-kind");
6005
- var disabled = tool === "codex" && kind === "structured";
6268
+ var disabled = false;
6006
6269
  card.classList.toggle("active", kind === sessionKind);
6007
6270
  card.classList.toggle("disabled", disabled);
6008
6271
  });
@@ -6083,19 +6346,28 @@
6083
6346
  return block && block.__processing;
6084
6347
  });
6085
6348
  })());
6086
- var preserveLocalStructuredProgress = (localSession.sessionKind === "structured")
6087
- && !!localStructuredState
6088
- && localStructuredState.inFlight === true
6089
- && (!serverStructuredState || serverStructuredState.inFlight !== true)
6090
- && localHasPendingAssistant
6091
- && !!localStructuredState.activeRequestId
6092
- && (!serverStructuredState || !serverStructuredState.activeRequestId || serverStructuredState.activeRequestId === localStructuredState.activeRequestId);
6093
6349
  var localMessages = Array.isArray(localSession.messages)
6094
6350
  ? (structuredSession ? stripRenderOnlyStructuredMessages(localSession.messages) : localSession.messages)
6095
6351
  : [];
6096
6352
  var serverMessages = Array.isArray(serverSession.messages)
6097
6353
  ? (structuredSession ? stripRenderOnlyStructuredMessages(serverSession.messages) : serverSession.messages)
6098
6354
  : [];
6355
+ // 服务端已经返回了完整的 assistant 回复(非 __processing 占位)时,
6356
+ // 不应再保留本地的 inFlight=true 状态,否则用户会看到"思考中"转圈永远不停。
6357
+ var serverHasCompletedAssistant = serverMessages.length > 0 && (function() {
6358
+ var last = serverMessages[serverMessages.length - 1];
6359
+ return last && last.role === "assistant" && Array.isArray(last.content)
6360
+ && !last.content.some(function(b) { return b && b.__processing; });
6361
+ })();
6362
+ var preserveLocalStructuredProgress = (localSession.sessionKind === "structured")
6363
+ && !!localStructuredState
6364
+ && localStructuredState.inFlight === true
6365
+ && (!serverStructuredState || serverStructuredState.inFlight !== true)
6366
+ && localHasPendingAssistant
6367
+ && !!localStructuredState.activeRequestId
6368
+ && !!serverStructuredState && !!serverStructuredState.activeRequestId
6369
+ && serverStructuredState.activeRequestId === localStructuredState.activeRequestId
6370
+ && !serverHasCompletedAssistant;
6099
6371
  var preserveLocalMessages = localMessages.length > serverMessages.length
6100
6372
  || (localMessages.length > 0 && serverMessages.length > 0
6101
6373
  && JSON.stringify(localMessages[localMessages.length - 1]) !== JSON.stringify(serverMessages[serverMessages.length - 1])
@@ -6384,12 +6656,22 @@
6384
6656
  // 设计原则:terminal 写入只走 ws init 与 chunk hot-path 两条
6385
6657
  // 权威路径——参见 case "init" 的 replace 写入与 onmessage
6386
6658
  // chunk 处理。这里只在 ws 离线兜底时才 append 写入。
6387
- if (!state.wsConnected) {
6659
+ //
6660
+ // wsLikelyTakingOver: 即使 wsConnected=false(onopen 还没 fire),
6661
+ // 只要 ws.readyState 是 CONNECTING 或 OPEN,就视为 ws 即将
6662
+ // 接管。否则 selectSession → loadOutput resolve 比 ws onopen
6663
+ // 早时(常见于刷新页面后的首次连接)会误走 fallback,写入
6664
+ // terminal 后 ws init 又写一次,造成双路重叠。
6665
+ var wsLikelyTakingOver = !!state.ws && (
6666
+ state.ws.readyState === WebSocket.OPEN ||
6667
+ state.ws.readyState === WebSocket.CONNECTING
6668
+ );
6669
+ if (!wsLikelyTakingOver) {
6388
6670
  syncTerminalBuffer(id, data.output, { mode: "append" });
6389
6671
  // 离线兜底路径自己负责 fit + replay,否则尺寸不对。
6390
6672
  ensureTerminalFit("session-switch", { forceReplay: true });
6391
6673
  } else {
6392
- // ws 在线场景:仅校准列宽,不重 replay(init 的
6674
+ // ws 在线/连接中:仅校准列宽,不重 replay(init 的
6393
6675
  // ensureTerminalFitWithRetry("init") 会负责按真实
6394
6676
  // 宽度的全量基线写入)。
6395
6677
  ensureTerminalFit("session-switch");
@@ -6545,7 +6827,7 @@
6545
6827
  lastFocusedElement = document.activeElement;
6546
6828
  state.sessionTool = getPreferredTool();
6547
6829
  state.preferredCommand = state.sessionTool;
6548
- state.sessionCreateKind = state.sessionTool === "codex" ? "pty" : "structured";
6830
+ state.sessionCreateKind = "structured";
6549
6831
  state.sessionCreateWorktree = false;
6550
6832
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
6551
6833
  syncSessionModalUI();
@@ -7181,6 +7463,9 @@
7181
7463
  var langEl = document.getElementById("cfg-language");
7182
7464
  if (langEl) langEl.value = cfg.language || "";
7183
7465
 
7466
+ var srEl = document.getElementById("cfg-structured-runner");
7467
+ if (srEl) srEl.value = cfg.structuredRunner || "cli";
7468
+
7184
7469
  // Default model
7185
7470
  state.configDefaultModel = cfg.defaultModel || "";
7186
7471
  updateSettingsDefaultModelSelect();
@@ -7239,6 +7524,7 @@
7239
7524
  shell: (document.getElementById("cfg-shell") || {}).value,
7240
7525
  language: (document.getElementById("cfg-language") || {}).value || "",
7241
7526
  defaultModel: (document.getElementById("cfg-default-model") || {}).value || "",
7527
+ structuredRunner: (document.getElementById("cfg-structured-runner") || {}).value || "cli",
7242
7528
  };
7243
7529
 
7244
7530
  var previousDefaultModel = (state.config && state.config.defaultModel) || "";
@@ -7752,6 +8038,29 @@
7752
8038
  el.classList.remove("hidden");
7753
8039
  }
7754
8040
 
8041
+ // 创建 PTY 会话时把当前终端的真实 cols/rows 注入 body,让后端 pty.spawn
8042
+ // 直接落在正确尺寸下。否则 PTY 先按 cols=120 启动,Claude/Codex 会基于
8043
+ // 120 列输出 \x1b[120G 这类绝对列定位序列;等前端 remeasure 触发 resize
8044
+ // 时这些早期内容已经被以 80 等真实列数渲染,整条历史就错位。
8045
+ function withTerminalDimensions(body) {
8046
+ if (!body || typeof body !== "object") return body;
8047
+ if (!state.terminal) return body;
8048
+ try {
8049
+ if (typeof state.terminal.remeasure === "function") {
8050
+ state.terminal.remeasure();
8051
+ }
8052
+ } catch (e) {}
8053
+ var cols = state.terminal.cols;
8054
+ var rows = state.terminal.rows;
8055
+ if (typeof cols === "number" && typeof rows === "number"
8056
+ && Number.isFinite(cols) && Number.isFinite(rows)
8057
+ && cols > 0 && rows > 0) {
8058
+ body.cols = cols;
8059
+ body.rows = rows;
8060
+ }
8061
+ return body;
8062
+ }
8063
+
7755
8064
  function quickStartSession() {
7756
8065
  var command = getPreferredTool();
7757
8066
  var defaultCwd = getEffectiveCwd();
@@ -7762,7 +8071,7 @@
7762
8071
  method: "POST",
7763
8072
  headers: { "Content-Type": "application/json" },
7764
8073
  credentials: "same-origin",
7765
- body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
8074
+ body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
7766
8075
  })
7767
8076
  .then(function(res) { return res.json(); })
7768
8077
  .then(function(data) {
@@ -7807,12 +8116,13 @@
7807
8116
  }
7808
8117
 
7809
8118
  function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
7810
- console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
8119
+ var provider = state.sessionTool === "codex" ? "codex" : "claude";
8120
+ console.log("[WAND] startStructuredSessionFromModal provider:", provider, "cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
7811
8121
  _sessionCreating = true;
7812
8122
  state.modeValue = mode;
7813
8123
  state.chatMode = mode;
7814
- state.sessionTool = "claude";
7815
- state.preferredCommand = "claude";
8124
+ state.sessionTool = provider;
8125
+ state.preferredCommand = provider;
7816
8126
  syncComposerModeSelect();
7817
8127
  syncComposerModelSelect(getSelectedSession());
7818
8128
  return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
@@ -7842,13 +8152,13 @@
7842
8152
  method: "POST",
7843
8153
  headers: { "Content-Type": "application/json" },
7844
8154
  credentials: "same-origin",
7845
- body: JSON.stringify({
8155
+ body: JSON.stringify(withTerminalDimensions({
7846
8156
  command: command,
7847
8157
  provider: command,
7848
8158
  cwd: cwd,
7849
8159
  mode: mode,
7850
8160
  worktreeEnabled: worktreeEnabled
7851
- })
8161
+ }))
7852
8162
  })
7853
8163
  .then(function(res) { return res.json(); })
7854
8164
  .then(function(data) {
@@ -8339,47 +8649,6 @@
8339
8649
  }
8340
8650
  }
8341
8651
 
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
8652
  var promptOptimizeInFlight = false;
8384
8653
  function optimizePromptText() {
8385
8654
  if (promptOptimizeInFlight) return;
@@ -8400,7 +8669,6 @@
8400
8669
  btn.setAttribute("title", "正在优化…");
8401
8670
  }
8402
8671
  if (composer) composer.classList.add("is-optimizing");
8403
- var shimmerOverlay = createOptimizeShimmer(inputBox, raw);
8404
8672
  inputBox.setAttribute("aria-busy", "true");
8405
8673
  var prevReadOnly = inputBox.readOnly;
8406
8674
  inputBox.readOnly = true;
@@ -8439,9 +8707,6 @@
8439
8707
  btn.setAttribute("title", "提示词优化(AI)");
8440
8708
  }
8441
8709
  if (composer) composer.classList.remove("is-optimizing");
8442
- if (shimmerOverlay && shimmerOverlay.parentNode) {
8443
- shimmerOverlay.parentNode.removeChild(shimmerOverlay);
8444
- }
8445
8710
  inputBox.removeAttribute("aria-busy");
8446
8711
  inputBox.readOnly = prevReadOnly;
8447
8712
  });
@@ -8474,8 +8739,6 @@
8474
8739
  } else {
8475
8740
  setDraftValue(finalText, true);
8476
8741
  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
8742
  }
8480
8743
  }
8481
8744
  tick();
@@ -8515,6 +8778,9 @@
8515
8778
  function isSelectedSessionRunning() {
8516
8779
  if (!state.selectedId) return false;
8517
8780
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
8781
+ if (isStructuredSession(selectedSession)) {
8782
+ return !!(selectedSession.structuredState && selectedSession.structuredState.inFlight);
8783
+ }
8518
8784
  return !!selectedSession && selectedSession.status === "running";
8519
8785
  }
8520
8786
 
@@ -8524,6 +8790,9 @@
8524
8790
 
8525
8791
  function hasAnyBusySession() {
8526
8792
  return state.sessions.some(function(s) {
8793
+ if (isStructuredSession(s)) {
8794
+ return !!(s.structuredState && s.structuredState.inFlight) && !s.archived;
8795
+ }
8527
8796
  return s.status === "running" && !s.archived;
8528
8797
  });
8529
8798
  }
@@ -8553,12 +8822,12 @@
8553
8822
  method: "POST",
8554
8823
  headers: { "Content-Type": "application/json" },
8555
8824
  credentials: "same-origin",
8556
- body: JSON.stringify({
8825
+ body: JSON.stringify(withTerminalDimensions({
8557
8826
  command: item.tool,
8558
8827
  cwd: item.cwd,
8559
8828
  mode: item.mode,
8560
8829
  initialInput: item.text
8561
- })
8830
+ }))
8562
8831
  })
8563
8832
  .then(function(res) { return res.json(); })
8564
8833
  .then(function(data) {
@@ -8593,12 +8862,12 @@
8593
8862
  method: "POST",
8594
8863
  headers: { "Content-Type": "application/json" },
8595
8864
  credentials: "same-origin",
8596
- body: JSON.stringify({
8865
+ body: JSON.stringify(withTerminalDimensions({
8597
8866
  command: item.tool,
8598
8867
  cwd: item.cwd,
8599
8868
  mode: item.mode,
8600
8869
  initialInput: item.text
8601
- })
8870
+ }))
8602
8871
  })
8603
8872
  .then(function(res) { return res.json(); })
8604
8873
  .then(function(data) {
@@ -8774,13 +9043,13 @@
8774
9043
  method: "POST",
8775
9044
  headers: { "Content-Type": "application/json" },
8776
9045
  credentials: "same-origin",
8777
- body: JSON.stringify({
9046
+ body: JSON.stringify(withTerminalDimensions({
8778
9047
  command: preferredTool,
8779
9048
  provider: preferredTool,
8780
9049
  cwd: defaultCwd,
8781
9050
  mode: mode,
8782
9051
  initialInput: value
8783
- })
9052
+ }))
8784
9053
  })
8785
9054
  .then(function(res) { return res.json(); })
8786
9055
  .then(function(data) {
@@ -8844,13 +9113,13 @@
8844
9113
  method: "POST",
8845
9114
  headers: { "Content-Type": "application/json" },
8846
9115
  credentials: "same-origin",
8847
- body: JSON.stringify({
9116
+ body: JSON.stringify(withTerminalDimensions({
8848
9117
  command: preferredTool,
8849
9118
  provider: preferredTool,
8850
9119
  cwd: defaultCwd,
8851
9120
  mode: mode,
8852
9121
  initialInput: value || undefined
8853
- })
9122
+ }))
8854
9123
  })
8855
9124
  .then(function(res) { return res.json(); })
8856
9125
  .then(function(data) {
@@ -9007,6 +9276,9 @@
9007
9276
  return Promise.resolve();
9008
9277
  }
9009
9278
 
9279
+ // 防止同一会话并发提交(快速双击 / 重复触发)
9280
+ var _structuredSubmittingSessions = {};
9281
+
9010
9282
  function postStructuredInput(input, inputBox, session) {
9011
9283
  console.log("[WAND] postStructuredInput selectedId:", state.selectedId, "input:", input && input.substring(0, 50), "session:", session && { id: session.id, sessionKind: session.sessionKind, runner: session.runner, status: session.status, inFlight: session.structuredState && session.structuredState.inFlight });
9012
9284
  if (!state.selectedId || !input) return Promise.resolve();
@@ -9014,6 +9286,11 @@
9014
9286
  showToast("会话不存在,请重新选择或新建会话。", "error");
9015
9287
  return Promise.resolve();
9016
9288
  }
9289
+ // 同一会话的上一次提交尚未落地,直接忽略防止重复发送
9290
+ if (_structuredSubmittingSessions[session.id]) {
9291
+ console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
9292
+ return Promise.resolve();
9293
+ }
9017
9294
 
9018
9295
  var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
9019
9296
  // Immediately render user message with thinking indicator
@@ -9046,21 +9323,36 @@
9046
9323
  // the HTTP response arrives.
9047
9324
  var epochBeforePost = state.queueEpoch;
9048
9325
 
9049
- return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
9326
+ // 给每次发送生成唯一 idempotency key。Android WebView 进程被冻结再恢复
9327
+ // 的边界场景下,底层网络栈偶尔会把上次未收到响应的 POST 重发一次(前端
9328
+ // JS 拦不住),导致同一条消息被 backend 处理两遍。带上 key 让 backend
9329
+ // 在窗口内识别重发并丢弃。
9330
+ var idempotencyKey = (typeof crypto !== "undefined" && crypto.randomUUID)
9331
+ ? crypto.randomUUID()
9332
+ : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
9333
+
9334
+ // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
9335
+ // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
9336
+ _structuredSubmittingSessions[session.id] = true;
9337
+ return fetch("/api/structured-sessions/" + session.id + "/messages", {
9050
9338
  method: "POST",
9051
9339
  headers: { "Content-Type": "application/json" },
9052
9340
  credentials: "same-origin",
9053
- body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
9341
+ body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined, idempotencyKey: idempotencyKey })
9054
9342
  })
9055
9343
  .then(function(res) {
9056
9344
  if (!res.ok) {
9057
9345
  return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
9058
- throw new Error((payload && payload.error) || "无法发送结构化消息。");
9346
+ var err = new Error((payload && payload.error) || "无法发送结构化消息。");
9347
+ err.errorCode = payload && payload.errorCode;
9348
+ err.httpStatus = res.status;
9349
+ throw err;
9059
9350
  });
9060
9351
  }
9061
9352
  return res.json();
9062
9353
  })
9063
9354
  .then(function(snapshot) {
9355
+ _structuredSubmittingSessions[session.id] = false;
9064
9356
  if (snapshot && snapshot.error) {
9065
9357
  throw new Error(snapshot.error);
9066
9358
  }
@@ -9069,19 +9361,45 @@
9069
9361
  delete snapshot.queuedMessages;
9070
9362
  }
9071
9363
  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");
9364
+ // 仅当 snapshot 仍属当前选中会话时才覆盖视图状态,否则只更新底层数据。
9365
+ if (snapshot.id === state.selectedId) {
9366
+ var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
9367
+ state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
9368
+ renderChat(true);
9369
+ if (isInterrupting) {
9370
+ showToast("已中断上一条回复,正在处理新消息…", "info");
9371
+ }
9077
9372
  }
9078
9373
  }
9079
9374
  })
9080
9375
  .catch(function(error) {
9376
+ _structuredSubmittingSessions[session.id] = false;
9377
+
9378
+ // duplicate_idempotency_key:服务端识别出 WebView 底层重发的副本,
9379
+ // 直接拦截不处理。这里**不**回滚乐观更新——第一次的请求实际上已经
9380
+ // 被服务端接收并处理(或正在处理),ws 推送会带回真实状态;如果在
9381
+ // 这里把 user turn rollback 掉,第一次的 user 消息会从 UI 上消失。
9382
+ if (error && error.errorCode === "duplicate_idempotency_key") {
9383
+ showToast(error.message || "检测到重复发送,已拦截。", "warning");
9384
+ updateInputHint("Enter 发送 · Shift+Enter 换行");
9385
+ return;
9386
+ }
9387
+
9388
+ // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
9389
+ var rollbackMsgs = userMsgs.slice(0, -1);
9081
9390
  updateSessionSnapshot({
9082
9391
  id: session.id,
9392
+ status: session.status,
9393
+ messages: rollbackMsgs,
9083
9394
  structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
9084
9395
  });
9396
+ if (session.id === state.selectedId) {
9397
+ state.currentMessages = buildMessagesForRender(
9398
+ Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
9399
+ rollbackMsgs
9400
+ );
9401
+ renderChat(true);
9402
+ }
9085
9403
  var message = (error && error.message) || "";
9086
9404
  var isTransientAbort =
9087
9405
  message === "Failed to fetch" ||
@@ -9273,13 +9591,22 @@
9273
9591
  }, Promise.resolve());
9274
9592
  }
9275
9593
 
9594
+ // pendingMessages 缓存 ws 离线时的输入,重连后回放。每条带时间戳,
9595
+ // flush 时丢弃过期项——离线 >TTL 后回放老按键序列只会让 PTY 错位。
9596
+ var PENDING_INPUT_TTL_MS = 5000;
9597
+ var PENDING_INPUT_MAX = 100;
9598
+ function enqueuePendingInput(input) {
9599
+ if (!input) return;
9600
+ if (state.pendingMessages.length >= PENDING_INPUT_MAX) {
9601
+ state.pendingMessages.shift();
9602
+ }
9603
+ state.pendingMessages.push({ input: input, at: Date.now() });
9604
+ }
9605
+
9276
9606
  function queueOfflineTerminalChunks(chunks) {
9277
9607
  var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
9278
9608
  sequence.forEach(function(chunk) {
9279
- if (state.pendingMessages.length >= 100) {
9280
- state.pendingMessages.shift();
9281
- }
9282
- state.pendingMessages.push(chunk);
9609
+ enqueuePendingInput(chunk);
9283
9610
  });
9284
9611
  }
9285
9612
 
@@ -9297,16 +9624,21 @@
9297
9624
 
9298
9625
  function postInput(input, shortcutKey, viewOverride) {
9299
9626
  if (!state.selectedId) return Promise.resolve();
9627
+ // 锁定本次请求归属的 sessionId。fetch 发起后用户可能切到别的会话,
9628
+ // 后续 then 回调里直接用 state.selectedId 会误把 A 的响应应用到 B:
9629
+ // - URL 上拼错会话(虽然 fetch 已经求值过 URL,但 markSessionStopped
9630
+ // 等 in-flight 引用会读最新值 → 把 B 标为 stopped 但实际是 A 失败)
9631
+ // - response.snapshot 属于 A,被 setCurrentMessages 误覆盖到 B 视图
9632
+ // 用 requestSessionId 锁住请求方,渲染相关动作再单独判断 snapshot.id
9633
+ // === 当前 state.selectedId 才执行。
9634
+ var requestSessionId = state.selectedId;
9300
9635
  var effectiveView = viewOverride || state.currentView;
9301
9636
 
9302
9637
  // Pre-check: don't send if session is not running
9303
9638
  if (!isSelectedSessionRunning()) {
9304
9639
  // If WebSocket is disconnected, queue for flush on reconnect
9305
9640
  if (!state.wsConnected) {
9306
- if (state.pendingMessages.length >= 100) {
9307
- state.pendingMessages.shift();
9308
- }
9309
- state.pendingMessages.push(input);
9641
+ enqueuePendingInput(input);
9310
9642
  console.log("[wand] postInput: session not running, queued for reconnect", {
9311
9643
  sessionId: state.selectedId,
9312
9644
  inputLength: input.length
@@ -9322,10 +9654,7 @@
9322
9654
 
9323
9655
  // If WebSocket is disconnected, queue the message (no HTTP fetch while offline)
9324
9656
  if (!state.wsConnected) {
9325
- if (state.pendingMessages.length >= 100) {
9326
- state.pendingMessages.shift();
9327
- }
9328
- state.pendingMessages.push(input);
9657
+ enqueuePendingInput(input);
9329
9658
  console.log("[wand] postInput: WebSocket disconnected, queued message", {
9330
9659
  sessionId: state.selectedId,
9331
9660
  inputLength: input.length
@@ -9333,14 +9662,7 @@
9333
9662
  return Promise.resolve();
9334
9663
  }
9335
9664
 
9336
- console.log("[wand] postInput: sending", {
9337
- sessionId: state.selectedId,
9338
- inputLength: input.length,
9339
- view: effectiveView,
9340
- wsConnected: state.wsConnected
9341
- });
9342
-
9343
- return fetch("/api/sessions/" + state.selectedId + "/input", {
9665
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
9344
9666
  method: "POST",
9345
9667
  headers: { "Content-Type": "application/json" },
9346
9668
  credentials: "same-origin",
@@ -9355,11 +9677,11 @@
9355
9677
  status: res.status,
9356
9678
  errorCode: error.errorCode,
9357
9679
  message: error.message,
9358
- sessionId: state.selectedId
9680
+ sessionId: requestSessionId
9359
9681
  });
9360
9682
  // Mark session as stopped for unavailable errors
9361
9683
  if (isSessionUnavailableError(error)) {
9362
- markSessionStopped(state.selectedId, error.sessionStatus || "exited");
9684
+ markSessionStopped(requestSessionId, error.sessionStatus || "exited");
9363
9685
  }
9364
9686
  throw error;
9365
9687
  });
@@ -9368,11 +9690,18 @@
9368
9690
  })
9369
9691
  .then(function(snapshot) {
9370
9692
  if (snapshot && snapshot.id) {
9693
+ // 底层 sessions 数据按 id 索引,无论是否仍是当前选中都可以
9694
+ // 安全更新(不会污染其他会话)。
9371
9695
  updateSessionSnapshot(snapshot);
9372
- if (snapshot.messages && snapshot.messages.length > 0) {
9373
- state.currentMessages = snapshot.messages;
9696
+ // currentMessages / renderChat 是当前视图状态,必须仅当
9697
+ // snapshot 仍属当前选中会话时才执行;否则会把 A 的消息列表
9698
+ // 渲染到 B 的 chat 视图。
9699
+ if (snapshot.id === state.selectedId) {
9700
+ if (snapshot.messages && snapshot.messages.length > 0) {
9701
+ state.currentMessages = snapshot.messages;
9702
+ }
9703
+ renderChat(true);
9374
9704
  }
9375
- renderChat(true);
9376
9705
  }
9377
9706
  return snapshot;
9378
9707
  });
@@ -9394,6 +9723,18 @@
9394
9723
  return !!state.selectedId && state.currentView === "terminal";
9395
9724
  }
9396
9725
 
9726
+ // 判断一条带 sessionId 的 ws 消息是否应该被当前 wterm 实例消费。
9727
+ // 收敛多处散落的"selectedId 一致 + terminalSessionId 兼容"判断,避免
9728
+ // 后续重构时漏改某一处导致旧会话的输出污染当前终端。
9729
+ // terminalSessionId 为空(尚未首次 init/切换刚发生)视为可接受任何
9730
+ // sessionId —— 这是首条 chunk 触发自我初始化的场景。
9731
+ function isCurrentTerminalSession(sessionId) {
9732
+ if (!state.terminal || !sessionId) return false;
9733
+ if (sessionId !== state.selectedId) return false;
9734
+ if (state.terminalSessionId && state.terminalSessionId !== sessionId) return false;
9735
+ return true;
9736
+ }
9737
+
9397
9738
  function shouldCaptureTerminalEvent(event) {
9398
9739
  if (!state.terminalInteractive || !isTerminalInteractionAvailable()) return false;
9399
9740
  if (event.defaultPrevented || event.isComposing) return false;
@@ -9561,7 +9902,9 @@
9561
9902
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
9562
9903
  var structured = isStructuredSession(selectedSession);
9563
9904
  var isCodex = selectedSession && selectedSession.provider === "codex";
9564
- var isRunning = !!selectedSession && selectedSession.status === "running";
9905
+ var isRunning = structured
9906
+ ? !!(selectedSession && selectedSession.structuredState && selectedSession.structuredState.inFlight)
9907
+ : !!selectedSession && selectedSession.status === "running";
9565
9908
  var composer = document.getElementById("input-box");
9566
9909
  // Update both toggle buttons (topbar and terminal-header)
9567
9910
  var toggles = ["terminal-interactive-toggle-top"];
@@ -9586,21 +9929,25 @@
9586
9929
  : "Enter 发送 · Shift+Enter 换行";
9587
9930
  }
9588
9931
  }
9589
- var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
9590
- // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
9591
- // 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9932
+ // 历史会话只要可自动恢复(Claude provider + claudeSessionId),输入框/发送按钮
9933
+ // 就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9592
9934
  var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
9593
9935
  if (composer) {
9594
9936
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
9595
- composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9937
+ composer.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
9596
9938
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
9939
+ // 终端交互模式下按键由 document capture phase 透传到 PTY;用
9940
+ // readOnly 而非 disabled 防止 IME 组合输入等边界场景下字符同时
9941
+ // 落到 textarea,又保留 focus 能力。
9942
+ composer.readOnly = !!state.terminalInteractive;
9943
+ composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
9597
9944
  }
9598
9945
  var sendBtn = document.getElementById("send-input-button");
9599
9946
  if (sendBtn) {
9600
- sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
9601
- sendBtn.setAttribute("title", isCodex
9602
- ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
9603
- : (structured ? "发送" : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
9947
+ sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
9948
+ sendBtn.setAttribute("title", structured
9949
+ ? "发送"
9950
+ : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
9604
9951
  }
9605
9952
  var container = document.getElementById("output");
9606
9953
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -9631,6 +9978,7 @@
9631
9978
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: state.modifiers.shift });
9632
9979
  if (sequence) sendTerminalSequence(sequence, key);
9633
9980
  clearModifiers();
9981
+ scheduleShortcutResync();
9634
9982
  }
9635
9983
 
9636
9984
  function handleInlineKeyboardClick(event) {
@@ -9647,12 +9995,22 @@
9647
9995
  if (key === "ctrl_enter") {
9648
9996
  var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
9649
9997
  if (sequence) sendTerminalSequence(sequence, "ctrl_enter");
9998
+ scheduleShortcutResync();
9650
9999
  return;
9651
10000
  }
9652
10001
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: false });
9653
10002
  if (sequence) sendTerminalSequence(sequence, key);
9654
10003
  clearModifiers();
9655
10004
  updateKeyboardPopupUI();
10005
+ scheduleShortcutResync();
10006
+ }
10007
+
10008
+ // 快捷键点击后做一次延迟 resync 兜底:maybeScheduleResyncForChunk 偶尔会漏
10009
+ // 抓 Codex 菜单切换之类的原地重绘,导致 DOM 行残留。500ms 是为了等服务端把
10010
+ // 本次按键的回执完整推过来,避免 resync 只回放到 chunk 一半。
10011
+ function scheduleShortcutResync() {
10012
+ if (!state.terminal) return;
10013
+ scheduleSoftResyncTerminal(500);
9656
10014
  }
9657
10015
 
9658
10016
  function updateKeyboardPopupUI() {
@@ -9771,8 +10129,20 @@
9771
10129
 
9772
10130
  // Send queued messages in order, bypassing the session-running check
9773
10131
  // since our local state may be stale right after reconnect
9774
- var queue = state.pendingMessages.slice();
10132
+ var now = Date.now();
10133
+ var queue = [];
10134
+ var dropped = 0;
10135
+ state.pendingMessages.forEach(function(item) {
10136
+ // Backward-compatible: 老逻辑里 entries 可能是裸字符串。
10137
+ if (typeof item === "string") { queue.push(item); return; }
10138
+ if (!item || typeof item.input !== "string") return;
10139
+ if (now - (item.at || 0) > PENDING_INPUT_TTL_MS) { dropped++; return; }
10140
+ queue.push(item.input);
10141
+ });
9775
10142
  state.pendingMessages = [];
10143
+ if (dropped > 0) {
10144
+ console.log("[wand] flushPendingMessages: dropped " + dropped + " stale input(s)");
10145
+ }
9776
10146
 
9777
10147
  var sendPromise = Promise.resolve();
9778
10148
  queue.forEach(function(input) {
@@ -9786,7 +10156,10 @@
9786
10156
 
9787
10157
  function sendInputDirect(input) {
9788
10158
  if (!input || !state.selectedId) return Promise.resolve();
9789
- return fetch("/api/sessions/" + state.selectedId + "/input", {
10159
+ // postInput:flushPendingMessages 重连后批量回放离线消息时,
10160
+ // 用户可能已在切到别的会话,必须用本次请求的 sessionId 快照。
10161
+ var requestSessionId = state.selectedId;
10162
+ return fetch("/api/sessions/" + requestSessionId + "/input", {
9790
10163
  method: "POST",
9791
10164
  headers: { "Content-Type": "application/json" },
9792
10165
  credentials: "same-origin",
@@ -9801,7 +10174,7 @@
9801
10174
  // on the user's next message, and stale queue items would cause duplicates
9802
10175
  if (isSessionUnavailableError(error)) {
9803
10176
  console.log("[wand] sendInputDirect: session unavailable, dropping", {
9804
- sessionId: state.selectedId,
10177
+ sessionId: requestSessionId,
9805
10178
  errorCode: error.errorCode
9806
10179
  });
9807
10180
  return null;
@@ -9814,10 +10187,13 @@
9814
10187
  .then(function(snapshot) {
9815
10188
  if (snapshot && snapshot.id) {
9816
10189
  updateSessionSnapshot(snapshot);
9817
- if (snapshot.messages && snapshot.messages.length > 0) {
9818
- state.currentMessages = snapshot.messages;
10190
+ // 仅当 snapshot 仍属当前选中会话时才覆盖视图,否则只更新底层数据。
10191
+ if (snapshot.id === state.selectedId) {
10192
+ if (snapshot.messages && snapshot.messages.length > 0) {
10193
+ state.currentMessages = snapshot.messages;
10194
+ }
10195
+ renderChat(true);
9819
10196
  }
9820
- renderChat(true);
9821
10197
  }
9822
10198
  return snapshot;
9823
10199
  });
@@ -9946,12 +10322,12 @@
9946
10322
  method: "POST",
9947
10323
  headers: { "Content-Type": "application/json" },
9948
10324
  credentials: "same-origin",
9949
- body: JSON.stringify({
10325
+ body: JSON.stringify(withTerminalDimensions({
9950
10326
  command: command,
9951
10327
  cwd: cwd || "",
9952
10328
  mode: state.chatMode || state.config.defaultMode || "default",
9953
10329
  model: modelPref || undefined
9954
- })
10330
+ }))
9955
10331
  })
9956
10332
  .then(function(res) { return res.json(); })
9957
10333
  .then(function(data) {
@@ -9976,9 +10352,9 @@
9976
10352
  method: "POST",
9977
10353
  headers: { "Content-Type": "application/json" },
9978
10354
  credentials: "same-origin",
9979
- body: JSON.stringify({
10355
+ body: JSON.stringify(withTerminalDimensions({
9980
10356
  mode: state.chatMode || state.config.defaultMode || "default"
9981
- })
10357
+ }))
9982
10358
  })
9983
10359
  .then(function(res) { return res.json(); })
9984
10360
  .then(function(data) {
@@ -10007,9 +10383,9 @@
10007
10383
  method: "POST",
10008
10384
  headers: { "Content-Type": "application/json" },
10009
10385
  credentials: "same-origin",
10010
- body: JSON.stringify({
10386
+ body: JSON.stringify(withTerminalDimensions({
10011
10387
  mode: state.chatMode || state.config.defaultMode || "default"
10012
- })
10388
+ }))
10013
10389
  })
10014
10390
  .then(function(res) { return res.json(); })
10015
10391
  .then(function(data) {
@@ -10087,13 +10463,13 @@
10087
10463
  method: "POST",
10088
10464
  headers: { "Content-Type": "application/json" },
10089
10465
  credentials: "same-origin",
10090
- body: JSON.stringify({
10466
+ body: JSON.stringify(withTerminalDimensions({
10091
10467
  command: preferredTool,
10092
10468
  cwd: defaultCwd,
10093
10469
  mode: mode,
10094
10470
  initialInput: value,
10095
10471
  model: modelPref || undefined
10096
- })
10472
+ }))
10097
10473
  })
10098
10474
  .then(function(res) { return res.json(); })
10099
10475
  .then(function(data) {
@@ -10125,13 +10501,13 @@
10125
10501
  method: "POST",
10126
10502
  headers: { "Content-Type": "application/json" },
10127
10503
  credentials: "same-origin",
10128
- body: JSON.stringify({
10504
+ body: JSON.stringify(withTerminalDimensions({
10129
10505
  command: preferredTool,
10130
10506
  cwd: defaultCwd,
10131
10507
  mode: mode,
10132
10508
  initialInput: value || undefined,
10133
10509
  model: modelPref || undefined
10134
- })
10510
+ }))
10135
10511
  })
10136
10512
  .then(function(res) { return res.json(); })
10137
10513
  .then(function(data) {
@@ -10187,10 +10563,10 @@
10187
10563
  method: "POST",
10188
10564
  headers: { "Content-Type": "application/json" },
10189
10565
  credentials: "same-origin",
10190
- body: JSON.stringify({
10566
+ body: JSON.stringify(withTerminalDimensions({
10191
10567
  mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
10192
10568
  cwd: cwd
10193
- })
10569
+ }))
10194
10570
  })
10195
10571
  .then(function(res) { return res.json(); })
10196
10572
  .then(function(data) {
@@ -10230,12 +10606,8 @@
10230
10606
  }
10231
10607
 
10232
10608
  function updateInputPanelViewportSpacing() {
10233
- // 旧实现给 input-panel 加底部 padding = 键盘高度,意图是腾出键盘
10234
- // 空间。但 input-panel 本身位置由 flex 决定,padding 增大只是把
10235
- // panel 自身撑高、内部底部多出空白,textarea(panel 顶部)反而
10236
- // 被往上推、离键盘更远。新方案改为让 body 高度跟随 visualViewport
10237
- // 收缩(见 syncAppViewportHeight),input-panel 自然贴键盘上沿。
10238
- // 这里清掉旧 keyboard-offset,避免新旧双重补偿。
10609
+ // 键盘空间通过 syncAppViewportHeight body 跟随 visualViewport 收缩处理;
10610
+ // 这里清掉历史遗留的 --keyboard-offset 避免双重补偿。
10239
10611
  var inputPanel = document.querySelector('.input-panel');
10240
10612
  if (!inputPanel) return;
10241
10613
  inputPanel.style.removeProperty('--keyboard-offset');
@@ -11300,11 +11672,31 @@
11300
11672
  if (wrap.parentNode === output) output.removeChild(wrap);
11301
11673
  }
11302
11674
  }
11675
+ // widePadAnsi 是模块级状态机,跨终端实例时若卡在 esc/csi/string 等
11676
+ // 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
11677
+ // ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
11678
+ resetWideParserState();
11303
11679
  state.terminalSessionId = null;
11304
11680
  state.terminalOutput = "";
11305
11681
  state.terminalAutoFollow = true;
11306
11682
  state.showTerminalJumpToBottom = false;
11307
11683
  updateTerminalJumpToBottomButton();
11684
+ // 清理本轮新增的、依赖当前 wterm 实例的模块级 timer 和频次统计。
11685
+ // 不清掉的话,旧会话上挂起的 tail timer 在新 wterm 实例上触发会
11686
+ // 用 state.terminalOutput 做一次无意义的 resync;resyncStats 计数
11687
+ // 跨会话累加也会让告警阈值在新会话立即触发误报。
11688
+ if (state.softResyncTimer) {
11689
+ clearTimeout(state.softResyncTimer);
11690
+ state.softResyncTimer = null;
11691
+ }
11692
+ if (_resyncChunkTailTimer) {
11693
+ clearTimeout(_resyncChunkTailTimer);
11694
+ _resyncChunkTailTimer = null;
11695
+ }
11696
+ _resyncChunkLastAt = 0;
11697
+ _resyncStatsWindowStart = 0;
11698
+ _resyncStatsCount = 0;
11699
+ _resyncLastWarnAt = 0;
11308
11700
  }
11309
11701
 
11310
11702
  function sendTerminalResize(cols, rows) {
@@ -11523,6 +11915,9 @@
11523
11915
  // starts the ladder from 500ms again.
11524
11916
  state.wsReconnectAttempts = 0;
11525
11917
  cancelWsReconnect();
11918
+ // Server's per-client output sequence counter restarts on every
11919
+ // new socket; clear ours so the first init isn't treated as a gap.
11920
+ state.lastSeqBySession = {};
11526
11921
  // Subscribe to current session if any
11527
11922
  subscribeToSession(state.selectedId);
11528
11923
  // Flush pending messages after reconnection
@@ -11539,6 +11934,43 @@
11539
11934
  ws.onmessage = function(event) {
11540
11935
  try {
11541
11936
  var msg = JSON.parse(event.data);
11937
+ if (msg && msg.type === "resync_required" && msg.sessionId) {
11938
+ // Server dropped some output events under backpressure and
11939
+ // is asking us for a fresh snapshot. Send a resync so the
11940
+ // server replies with a new init carrying the full output.
11941
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
11942
+ try {
11943
+ state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
11944
+ } catch (sendErr) { /* ignore */ }
11945
+ }
11946
+ if (!state.lastSeqBySession) state.lastSeqBySession = {};
11947
+ state.lastSeqBySession[msg.sessionId] = 0;
11948
+ return;
11949
+ }
11950
+ if (msg && (msg.type === "init" || msg.type === "output") && msg.sessionId && typeof msg.seq === "number") {
11951
+ if (!state.lastSeqBySession) state.lastSeqBySession = {};
11952
+ var prevSeq = state.lastSeqBySession[msg.sessionId] || 0;
11953
+ if (msg.type === "init") {
11954
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11955
+ } else if (msg.seq === prevSeq + 1) {
11956
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11957
+ } else if (msg.seq > prevSeq + 1 && prevSeq > 0) {
11958
+ // We missed at least one event — request a resync and
11959
+ // skip this stale event so we don't apply a partial gap.
11960
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
11961
+ try {
11962
+ state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
11963
+ } catch (sendErr) { /* ignore */ }
11964
+ }
11965
+ state.lastSeqBySession[msg.sessionId] = 0;
11966
+ return;
11967
+ } else {
11968
+ // seq <= prevSeq: duplicate or out-of-order from a stale
11969
+ // queue; drop quietly.
11970
+ if (msg.seq < prevSeq) return;
11971
+ state.lastSeqBySession[msg.sessionId] = msg.seq;
11972
+ }
11973
+ }
11542
11974
  handleWebSocketMessage(msg);
11543
11975
  } catch (e) {
11544
11976
  // Ignore parse errors
@@ -11567,8 +11999,29 @@
11567
11999
  function handleWebSocketMessage(msg) {
11568
12000
  switch (msg.type) {
11569
12001
  case 'output':
11570
- // Update session output (for terminal display and local message parsing)
11571
- // NOTE: For structured sessions, output may be "" during streaming — check messages too
12002
+ // For structured sessions, output may be "" during streaming check messages too.
12003
+ // thinking idle 边界自愈:bridge isResponding 透传过来,true→false
12004
+ // 主动 softResyncTerminal,洗掉流式渲染残留的错位光标定位序列。
12005
+ // 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 多次重放。
12006
+ if (msg.data && msg.sessionId
12007
+ && Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
12008
+ if (!state._lastIsResponding) state._lastIsResponding = {};
12009
+ var _prevResp = !!state._lastIsResponding[msg.sessionId];
12010
+ var _nextResp = !!msg.data.isResponding;
12011
+ state._lastIsResponding[msg.sessionId] = _nextResp;
12012
+ if (_prevResp && !_nextResp
12013
+ && msg.sessionId === state.selectedId
12014
+ && state.terminal
12015
+ && state.terminalOutput) {
12016
+ if (state._idleResyncTimer) clearTimeout(state._idleResyncTimer);
12017
+ var _idleResyncSid = msg.sessionId;
12018
+ state._idleResyncTimer = setTimeout(function() {
12019
+ state._idleResyncTimer = null;
12020
+ if (state.selectedId !== _idleResyncSid) return;
12021
+ try { softResyncTerminal({ skipFit: true }); } catch (e) {}
12022
+ }, 120);
12023
+ }
12024
+ }
11572
12025
  if (msg.data && msg.sessionId) {
11573
12026
  var isIncremental = !!msg.data.incremental;
11574
12027
  var snapshot = { id: msg.sessionId };
@@ -11642,7 +12095,7 @@
11642
12095
  }
11643
12096
  // Real-time terminal output
11644
12097
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
11645
- if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
12098
+ if (msg.data.chunk && isCurrentTerminalSession(msg.sessionId)) {
11646
12099
  // Fast path: write chunk directly to avoid full-output comparison.
11647
12100
  state.lastChunkAt = Date.now();
11648
12101
  state.terminalLiveStreamSessions[msg.sessionId] = true;
@@ -11656,9 +12109,9 @@
11656
12109
  maybeScheduleResyncForChunk(msg.data.chunk);
11657
12110
  state.terminalSessionId = msg.sessionId;
11658
12111
  if (msg.data.output) {
11659
- state.terminalOutput = normalizeTerminalOutput(msg.data.output);
12112
+ state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
11660
12113
  } else {
11661
- state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
12114
+ state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
11662
12115
  }
11663
12116
  maybeScrollTerminalToBottom("output");
11664
12117
  updateTerminalJumpToBottomButton();
@@ -11744,6 +12197,8 @@
11744
12197
 
11745
12198
  if (isStructuredEnded && msg.sessionId === state.selectedId) {
11746
12199
  flushStructuredInputQueue();
12200
+ // 结构化会话结束时也清 localStorage,防止下次加载恢复僵尸队列
12201
+ clearStructuredQueuePersistence(msg.sessionId);
11747
12202
  } else if (!isStructuredEnded) {
11748
12203
  state.structuredInputQueue = [];
11749
12204
  clearStructuredQueuePersistence(state.selectedId);
@@ -11787,18 +12242,12 @@
11787
12242
  renderChat(true);
11788
12243
  updateTaskDisplay();
11789
12244
  updateApprovalStats();
11790
- // ws 重新订阅时拿到的是服务端 ring buffer 的最新窗口(最多
11791
- // 120KB);客户端缓存的 terminalOutput 可能早于服务端窗口
11792
- // 的起点。append 模式有 prefix 检查,prefix 不匹配就 reset+
11793
- // 全量重写、全等就直接 return false——前者会把 alt-screen
11794
- // 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
11795
- // 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
11796
- // 这是订阅时唯一可信的全量基线。
12245
+ // 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
12246
+ // 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
12247
+ // 可信的全量基线,避免 append prefix 检查走错分支。
11797
12248
  updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
11798
- // 紧接着等容器有真实尺寸再 fit + softResync:wterm 启动
11799
- // 硬编码 cols=120,replace 写入也可能落在错的列宽上,
11800
- // ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
11801
- // 一次,确保最终一定按真实宽度重排。
12249
+ // wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
12250
+ // 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
11802
12251
  ensureTerminalFitWithRetry("init");
11803
12252
  }
11804
12253
  break;
@@ -12183,7 +12632,8 @@
12183
12632
  var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
12184
12633
  var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
12185
12634
  && selectedForDelay.sessionKind !== "structured";
12186
- var delay = isActiveStream ? 150 : 30;
12635
+ // 活跃流时拉到 LIVE 减少高频重渲;空闲时用 IDLE 快速响应。
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
@@ -14555,6 +15005,49 @@
14555
15005
  return source;
14556
15006
  }
14557
15007
 
15008
+ function isWordChar(code) {
15009
+ return (code >= 48 && code <= 57) ||
15010
+ (code >= 65 && code <= 90) ||
15011
+ (code >= 97 && code <= 122) ||
15012
+ code === 95;
15013
+ }
15014
+
15015
+ function replaceUnderscoreEmphasis(source, openTag, closeTag) {
15016
+ var cursor = 0;
15017
+ while (cursor < source.length) {
15018
+ var start = source.indexOf("_", cursor);
15019
+ if (start === -1) break;
15020
+ var leftCode = start > 0 ? source.charCodeAt(start - 1) : 0;
15021
+ if (isWordChar(leftCode)) {
15022
+ cursor = start + 1;
15023
+ continue;
15024
+ }
15025
+ var searchFrom = start + 1;
15026
+ var end = -1;
15027
+ while (searchFrom < source.length) {
15028
+ var candidate = source.indexOf("_", searchFrom);
15029
+ if (candidate === -1) break;
15030
+ var rightIdx = candidate + 1;
15031
+ var rightCode = rightIdx < source.length ? source.charCodeAt(rightIdx) : 0;
15032
+ if (!isWordChar(rightCode)) {
15033
+ end = candidate;
15034
+ break;
15035
+ }
15036
+ searchFrom = candidate + 1;
15037
+ }
15038
+ if (end === -1) break;
15039
+ var inner = source.slice(start + 1, end);
15040
+ if (!inner) {
15041
+ cursor = end + 1;
15042
+ continue;
15043
+ }
15044
+ var replacement = openTag + inner + closeTag;
15045
+ source = source.slice(0, start) + replacement + source.slice(end + 1);
15046
+ cursor = start + replacement.length;
15047
+ }
15048
+ return source;
15049
+ }
15050
+
14558
15051
  function replaceLinePrefix(source, marker, openTag, closeTag) {
14559
15052
  return source.split(newline).map(function(line) {
14560
15053
  if (line.indexOf(marker) !== 0) return line;
@@ -14616,12 +15109,13 @@
14616
15109
  }
14617
15110
 
14618
15111
  var highlighted = highlightCode(code.trim(), lang);
15112
+ var protectedHighlighted = highlighted.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
14619
15113
  var replacement = '<div class="code-block">' +
14620
15114
  '<div class="code-block-header">' +
14621
15115
  '<span class="code-lang">' + (lang || "code") + '</span>' +
14622
15116
  '<button class="code-copy">Copy</button>' +
14623
15117
  '</div>' +
14624
- '<pre><code>' + highlighted + '</code></pre>' +
15118
+ '<pre><code>' + protectedHighlighted + '</code></pre>' +
14625
15119
  '</div>';
14626
15120
  result = result.slice(0, start) + replacement + result.slice(endTag + 3);
14627
15121
  pos = start + replacement.length;
@@ -14638,14 +15132,15 @@
14638
15132
  continue;
14639
15133
  }
14640
15134
  var inlineCode = result.slice(inlineStart + 1, inlineEnd);
14641
- var inlineReplacement = '<code class="code-inline">' + inlineCode + '</code>';
15135
+ var protectedInlineCode = inlineCode.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
15136
+ var inlineReplacement = '<code class="code-inline">' + protectedInlineCode + '</code>';
14642
15137
  result = result.slice(0, inlineStart) + inlineReplacement + result.slice(inlineEnd + 1);
14643
15138
  pos = inlineStart + inlineReplacement.length;
14644
15139
  }
14645
15140
 
14646
15141
  result = replacePair(result, "**", '<strong>', '</strong>');
14647
15142
  result = replacePair(result, "*", '<em>', '</em>');
14648
- result = replacePair(result, "_", '<em>', '</em>');
15143
+ result = replaceUnderscoreEmphasis(result, '<em>', '</em>');
14649
15144
  result = replaceLinePrefix(result, "### ", '<h3>', '</h3>');
14650
15145
  result = replaceLinePrefix(result, "## ", '<h2>', '</h2>');
14651
15146
  result = replaceLinePrefix(result, "# ", '<h1>', '</h1>');
@@ -15029,6 +15524,21 @@
15029
15524
  var _progressSyncTimers = {};
15030
15525
  var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
15031
15526
 
15527
+ // Strip markdown formatting and clamp to a single short line so the
15528
+ // native Live Activity / lock-screen card stays readable. 100 chars
15529
+ // matches getLastAssistantSummary; OPPO truncates harder anyway.
15530
+ function _compactNotificationText(text) {
15531
+ if (!text) return "";
15532
+ var t = String(text)
15533
+ .replace(/^#+\s+/gm, "")
15534
+ .replace(/\*\*/g, "")
15535
+ .replace(/`/g, "")
15536
+ .trim();
15537
+ var firstLine = t.split("\n")[0].trim();
15538
+ if (firstLine.length > 100) firstLine = firstLine.slice(0, 100) + "…";
15539
+ return firstLine;
15540
+ }
15541
+
15032
15542
  function syncSessionProgressToNative(sessionId) {
15033
15543
  if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
15034
15544
  if (!sessionId) return;
@@ -15054,21 +15564,56 @@
15054
15564
  return;
15055
15565
  }
15056
15566
 
15057
- // Get latest todos from session messages
15567
+ // Get latest todos from session messages, plus the most recent user
15568
+ // prompt and assistant text in the same scan. sessionLabel is frozen
15569
+ // to the first prompt (session.summary), so without these fields the
15570
+ // OPPO Live Activity / lock-screen card stays stuck on round-1 text
15571
+ // forever. We carry the latest round across so native can refresh.
15058
15572
  var todos = null;
15573
+ var latestUserText = "";
15574
+ var latestAssistantText = "";
15059
15575
  var messages = session.messages || [];
15060
15576
  for (var i = messages.length - 1; i >= 0; i--) {
15061
15577
  var msg = messages[i];
15062
15578
  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;
15579
+
15580
+ if (!latestAssistantText && msg.role === "assistant") {
15581
+ for (var ai = msg.content.length - 1; ai >= 0; ai--) {
15582
+ var ablock = msg.content[ai];
15583
+ if (ablock && ablock.type === "text" && ablock.text && ablock.text.trim()) {
15584
+ latestAssistantText = _compactNotificationText(ablock.text);
15585
+ break;
15586
+ }
15069
15587
  }
15070
15588
  }
15071
- if (todos) break;
15589
+
15590
+ if (!latestUserText && msg.role === "user") {
15591
+ // Skip queued / synthetic placeholder turns — they don't represent
15592
+ // user-visible "I just asked this" prompts.
15593
+ var isPlaceholder = msg.content.some(function(b) { return b && b.__queued; });
15594
+ if (!isPlaceholder) {
15595
+ for (var ui = 0; ui < msg.content.length; ui++) {
15596
+ var ublock = msg.content[ui];
15597
+ if (ublock && ublock.type === "text" && ublock.text && ublock.text.trim()) {
15598
+ latestUserText = _compactNotificationText(ublock.text);
15599
+ break;
15600
+ }
15601
+ }
15602
+ }
15603
+ }
15604
+
15605
+ if (!todos) {
15606
+ for (var j = msg.content.length - 1; j >= 0; j--) {
15607
+ var block = msg.content[j];
15608
+ if (block && block.type === "tool_use" && block.name === "TodoWrite"
15609
+ && block.input && block.input.todos) {
15610
+ todos = block.input.todos;
15611
+ break;
15612
+ }
15613
+ }
15614
+ }
15615
+
15616
+ if (todos && latestUserText && latestAssistantText) break;
15072
15617
  }
15073
15618
 
15074
15619
  // Get current task
@@ -15081,6 +15626,8 @@
15081
15626
  sessionLabel: sessionLabel,
15082
15627
  status: sessionStatus,
15083
15628
  currentTask: currentTask,
15629
+ latestUserText: latestUserText,
15630
+ latestAssistantText: latestAssistantText,
15084
15631
  todos: todos || []
15085
15632
  };
15086
15633