@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.
- package/dist/claude-pty-bridge.d.ts +5 -2
- package/dist/claude-pty-bridge.js +26 -13
- package/dist/config.js +2 -0
- package/dist/git-quick-commit.js +12 -4
- package/dist/models.d.ts +3 -1
- package/dist/models.js +45 -7
- package/dist/process-manager.d.ts +2 -0
- package/dist/process-manager.js +82 -19
- package/dist/pty-text-utils.d.ts +31 -1
- package/dist/pty-text-utils.js +164 -2
- package/dist/server-session-routes.d.ts +1 -1
- package/dist/server-session-routes.js +31 -11
- package/dist/server.d.ts +3 -0
- package/dist/server.js +54 -13
- package/dist/session-logger.d.ts +18 -5
- package/dist/session-logger.js +81 -20
- package/dist/structured-session-manager.d.ts +45 -2
- package/dist/structured-session-manager.js +1010 -35
- package/dist/types.d.ts +15 -2
- package/dist/web-ui/content/scripts.js +785 -238
- package/dist/web-ui/content/styles.css +137 -41
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/dist/ws-broadcast.d.ts +6 -0
- package/dist/ws-broadcast.js +69 -20
- package/package.json +2 -1
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
|
|
120
120
|
})(),
|
|
121
121
|
availableModels: [],
|
|
122
|
+
availableCodexModels: [],
|
|
122
123
|
modelsRefreshing: false,
|
|
123
124
|
sessionCreateKind: "structured",
|
|
124
125
|
sessionCreateWorktree: false,
|
|
@@ -149,6 +150,10 @@
|
|
|
149
150
|
try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
|
|
150
151
|
})(),
|
|
151
152
|
toolContentCache: {},
|
|
153
|
+
// Per-session WS output sequence tracker. Reset on connect/reconnect.
|
|
154
|
+
// Used to detect gaps caused by server-side backpressure drops and
|
|
155
|
+
// request a fresh snapshot.
|
|
156
|
+
lastSeqBySession: {},
|
|
152
157
|
currentView: "terminal",
|
|
153
158
|
terminalScale: (function() {
|
|
154
159
|
try {
|
|
@@ -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
|
-
//
|
|
241
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
271
|
-
|
|
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
|
|
1319
|
-
'<div
|
|
1320
|
-
'<div class="todo-progress-
|
|
1321
|
-
'<
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2776
|
+
// 字号和行高都向上取整到整数像素:PC 端 1× 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
|
-
|
|
3104
|
+
var protectedHighlighted = highlighted.replace(/_/g, '_').replace(/\*/g, '*');
|
|
3105
|
+
return '<pre><code class="language-' + lang + '">' + protectedHighlighted + '</code></pre>';
|
|
2998
3106
|
});
|
|
2999
3107
|
|
|
3000
3108
|
// Inline code
|
|
3001
|
-
escaped = escaped.replace(/`([^`]+)`/g,
|
|
3109
|
+
escaped = escaped.replace(/`([^`]+)`/g, function(_, code) {
|
|
3110
|
+
return '<code>' + code.replace(/_/g, '_').replace(/\*/g, '*') + '</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(/___(
|
|
3016
|
-
escaped = escaped.replace(/__(
|
|
3017
|
-
escaped = escaped.replace(/_(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
5312
|
-
//
|
|
5313
|
-
//
|
|
5314
|
-
//
|
|
5315
|
-
//
|
|
5316
|
-
//
|
|
5317
|
-
//
|
|
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
|
-
//
|
|
5339
|
-
//
|
|
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
|
-
//
|
|
5349
|
-
// debounce
|
|
5350
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5794
|
-
var
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
7815
|
-
state.preferredCommand =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
9680
|
+
sessionId: requestSessionId
|
|
9359
9681
|
});
|
|
9360
9682
|
// Mark session as stopped for unavailable errors
|
|
9361
9683
|
if (isSessionUnavailableError(error)) {
|
|
9362
|
-
markSessionStopped(
|
|
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
|
-
|
|
9373
|
-
|
|
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 =
|
|
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
|
-
|
|
9590
|
-
//
|
|
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
|
|
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
|
|
9601
|
-
sendBtn.setAttribute("title",
|
|
9602
|
-
?
|
|
9603
|
-
: (
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
9818
|
-
|
|
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
|
-
//
|
|
10234
|
-
//
|
|
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
|
-
//
|
|
11571
|
-
//
|
|
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 && (
|
|
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
|
-
//
|
|
11791
|
-
//
|
|
11792
|
-
//
|
|
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
|
-
//
|
|
11799
|
-
//
|
|
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
|
-
|
|
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
|
-
},
|
|
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, '_').replace(/\*/g, '*');
|
|
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>' +
|
|
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
|
|
15135
|
+
var protectedInlineCode = inlineCode.replace(/_/g, '_').replace(/\*/g, '*');
|
|
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 =
|
|
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
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15066
|
-
|
|
15067
|
-
|
|
15068
|
-
|
|
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
|
-
|
|
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
|
|