@co0ontty/wand 1.20.4 → 1.21.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-pty-bridge.d.ts +8 -0
- package/dist/claude-pty-bridge.js +34 -11
- 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 +80 -17
- package/dist/pty-text-utils.d.ts +25 -1
- package/dist/pty-text-utils.js +158 -2
- package/dist/server-session-routes.d.ts +1 -1
- package/dist/server-session-routes.js +22 -8
- package/dist/server.d.ts +3 -0
- package/dist/server.js +49 -12
- package/dist/session-logger.d.ts +15 -4
- package/dist/session-logger.js +52 -4
- package/dist/structured-session-manager.d.ts +12 -2
- package/dist/structured-session-manager.js +465 -22
- package/dist/types.d.ts +13 -2
- package/dist/web-ui/content/scripts.js +680 -178
- 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.js +74 -12
- package/package.json +1 -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">' +
|
|
@@ -2672,10 +2765,16 @@
|
|
|
2672
2765
|
|
|
2673
2766
|
function applyTerminalScale() {
|
|
2674
2767
|
if (!state.terminal || !state.terminal.element) return;
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2768
|
+
// 字号和行高都向上取整到整数像素:PC 端 1× DPR 下浏览器对亚像素
|
|
2769
|
+
// 字号/行高的舍入策略不一致(fontSize 16.25 → 16 或 17,行高
|
|
2770
|
+
// 19.5 → 19 或 20),相邻行/列的吸附方向不同就会让 wterm 网格
|
|
2771
|
+
// 错位。强制整数 px 让 cell 高度、字符高度都稳定一致,等价于
|
|
2772
|
+
// 之前桌面端必须按右上角缩放才能恢复的"整像素重排"路径。
|
|
2773
|
+
var rawFontSize = state.terminalBaseFontSize * state.terminalScale;
|
|
2774
|
+
var fontPx = Math.max(1, Math.round(rawFontSize));
|
|
2775
|
+
var rowPx = Math.max(1, Math.round(rawFontSize * 1.5));
|
|
2776
|
+
state.terminal.element.style.setProperty("--term-font-size", fontPx + "px");
|
|
2777
|
+
state.terminal.element.style.setProperty("--term-row-height", rowPx + "px");
|
|
2679
2778
|
if (typeof state.terminal.remeasure === "function") {
|
|
2680
2779
|
requestAnimationFrame(function() {
|
|
2681
2780
|
if (state.terminal) state.terminal.remeasure();
|
|
@@ -3135,7 +3234,9 @@
|
|
|
3135
3234
|
if (!session) return "";
|
|
3136
3235
|
if (session.archived) return "已归档";
|
|
3137
3236
|
if (session.permissionBlocked) return "等待授权";
|
|
3237
|
+
if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "思考中";
|
|
3138
3238
|
var statusMap = {
|
|
3239
|
+
"idle": "空闲",
|
|
3139
3240
|
"stopped": "已停止",
|
|
3140
3241
|
"running": "运行中",
|
|
3141
3242
|
"exited": "已退出",
|
|
@@ -3148,6 +3249,7 @@
|
|
|
3148
3249
|
if (!session) return "";
|
|
3149
3250
|
if (session.archived) return "archived";
|
|
3150
3251
|
if (session.permissionBlocked) return "permission-blocked";
|
|
3252
|
+
if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) return "running";
|
|
3151
3253
|
return session.status || "";
|
|
3152
3254
|
}
|
|
3153
3255
|
|
|
@@ -3321,7 +3423,7 @@
|
|
|
3321
3423
|
function renderProviderOptions(selectedTool) {
|
|
3322
3424
|
var tools = [
|
|
3323
3425
|
{ id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
|
|
3324
|
-
{ id: "codex", label: "Codex", desc: "PTY
|
|
3426
|
+
{ id: "codex", label: "Codex", desc: "结构化 JSONL 或 PTY 会话" }
|
|
3325
3427
|
];
|
|
3326
3428
|
return tools.map(function(tool) {
|
|
3327
3429
|
var active = tool.id === selectedTool ? " active" : "";
|
|
@@ -3339,7 +3441,7 @@
|
|
|
3339
3441
|
];
|
|
3340
3442
|
return kinds.map(function(kind) {
|
|
3341
3443
|
var active = kind.id === selectedKind ? " active" : "";
|
|
3342
|
-
var disabled =
|
|
3444
|
+
var disabled = "";
|
|
3343
3445
|
return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
|
|
3344
3446
|
'<span class="mode-card-label">' + kind.label + '</span>' +
|
|
3345
3447
|
'<span class="mode-card-desc">' + kind.desc + '</span>' +
|
|
@@ -3357,10 +3459,12 @@
|
|
|
3357
3459
|
function getSessionKindHint(kind) {
|
|
3358
3460
|
var tool = state.sessionTool || "claude";
|
|
3359
3461
|
if (kind === "structured") {
|
|
3360
|
-
return "
|
|
3462
|
+
return tool === "codex"
|
|
3463
|
+
? "Codex JSONL 结构化聊天界面,支持多轮对话和工具调用展示。"
|
|
3464
|
+
: "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
|
|
3361
3465
|
}
|
|
3362
3466
|
if (tool === "codex") {
|
|
3363
|
-
return "Codex
|
|
3467
|
+
return "Codex PTY 终端会话;terminal 是原始输出,chat 是解析后的阅读视图。";
|
|
3364
3468
|
}
|
|
3365
3469
|
return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
|
|
3366
3470
|
}
|
|
@@ -3748,10 +3852,9 @@
|
|
|
3748
3852
|
if (provider) {
|
|
3749
3853
|
state.sessionTool = provider;
|
|
3750
3854
|
state.preferredCommand = provider;
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
}
|
|
3855
|
+
// Codex 现在同时支持 PTY 与结构化 runner,不再强制把 kind 切成 pty。
|
|
3856
|
+
// mode 由 syncSessionModalUI() 调用 getSafeModeForTool() 自动 clamp,
|
|
3857
|
+
// 不在这里硬写。
|
|
3755
3858
|
syncSessionModalUI();
|
|
3756
3859
|
}
|
|
3757
3860
|
});
|
|
@@ -4929,6 +5032,17 @@
|
|
|
4929
5032
|
return distance <= state.terminalScrollThreshold;
|
|
4930
5033
|
}
|
|
4931
5034
|
|
|
5035
|
+
// 严格"真正到底"判定(仅亚像素 jitter 容忍):用于把 autoFollow 从 false
|
|
5036
|
+
// 翻回 true。不能用 isTerminalNearBottom 的 12px 阈值,否则用户在底部小幅
|
|
5037
|
+
// 向上滚时,wheel handler 把 autoFollow 设 false 后紧接着触发的 scroll
|
|
5038
|
+
// 事件会因为"还没滚出阈值"而把 autoFollow 反转回 true,丢失用户意图。
|
|
5039
|
+
function isTerminalAtBottom() {
|
|
5040
|
+
var viewport = getTerminalViewport();
|
|
5041
|
+
if (!viewport) return true;
|
|
5042
|
+
var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
|
|
5043
|
+
return distance <= 2;
|
|
5044
|
+
}
|
|
5045
|
+
|
|
4932
5046
|
function scrollTerminalToBottom(smooth) {
|
|
4933
5047
|
if (!state.terminal) return;
|
|
4934
5048
|
var viewport = getTerminalViewport();
|
|
@@ -4969,11 +5083,13 @@
|
|
|
4969
5083
|
updateTerminalJumpToBottomButton();
|
|
4970
5084
|
return;
|
|
4971
5085
|
}
|
|
4972
|
-
|
|
5086
|
+
// 只看 autoFollow 标志:用户主动 wheel/touch 后该标志被设为 false,
|
|
5087
|
+
// 即使当前位置仍在底部 12px 阈值内也不再强行滚回,避免把用户刚滚上去
|
|
5088
|
+
// 的几像素吞掉。autoFollow 由 scroll handler 在"真正到底"时恢复。
|
|
5089
|
+
if (!state.terminalAutoFollow) {
|
|
4973
5090
|
updateTerminalJumpToBottomButton();
|
|
4974
5091
|
return;
|
|
4975
5092
|
}
|
|
4976
|
-
state.terminalAutoFollow = true;
|
|
4977
5093
|
scrollTerminalToBottom(false);
|
|
4978
5094
|
updateTerminalJumpToBottomButton();
|
|
4979
5095
|
}
|
|
@@ -5262,6 +5378,13 @@
|
|
|
5262
5378
|
if (!terminal || data == null) return;
|
|
5263
5379
|
if (!state.wideParserState) state.wideParserState = createWideParserState();
|
|
5264
5380
|
terminal.write(widePadAnsi(data, state.wideParserState));
|
|
5381
|
+
// wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
|
|
5382
|
+
// scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
|
|
5383
|
+
// true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
|
|
5384
|
+
// 的 autoFollow 状态,让 autoFollow 成为唯一真相。
|
|
5385
|
+
if ("_shouldScrollToBottom" in terminal) {
|
|
5386
|
+
terminal._shouldScrollToBottom = state.terminalAutoFollow !== false;
|
|
5387
|
+
}
|
|
5265
5388
|
}
|
|
5266
5389
|
|
|
5267
5390
|
function resetWideParserState() {
|
|
@@ -5290,6 +5413,85 @@
|
|
|
5290
5413
|
}
|
|
5291
5414
|
stripWideFillerForCopy();
|
|
5292
5415
|
|
|
5416
|
+
// ── PTY 链路时序参数索引 ──────────────────────────────────────────
|
|
5417
|
+
// 本文件中所有影响 PTY 输入/输出节流的常量集中说明(值仍在使用处定义,
|
|
5418
|
+
// 方便阅读上下文,但请保持一致命名以便此索引可 grep 跳转):
|
|
5419
|
+
//
|
|
5420
|
+
// 服务端(src/ws-broadcast.ts、src/process-manager.ts):
|
|
5421
|
+
// OUTPUT_DEBOUNCE_MS = 16 PTY data → ws 推送 debounce
|
|
5422
|
+
// OUTPUT_MAX_SIZE = 200_000 record.output ring buffer 字节上限
|
|
5423
|
+
//
|
|
5424
|
+
// 客户端(本文件):
|
|
5425
|
+
// CLIENT_OUTPUT_MAX / CLIENT_OUTPUT_TRIM_AT state.terminalOutput 窗口
|
|
5426
|
+
// RESYNC_THROTTLE_MS = 400 chunk → softResync 节流最小间隔
|
|
5427
|
+
// RESYNC_TAIL_MS = 350 节流尾巴 timer 等待
|
|
5428
|
+
// RESYNC_BUDGET_* 5s 内 resync 频次告警阈值
|
|
5429
|
+
// CHAT_RENDER_LIVE_MS = 150 活跃流时 renderChat debounce
|
|
5430
|
+
// CHAT_RENDER_IDLE_MS = 30 空闲时 renderChat debounce
|
|
5431
|
+
// PENDING_INPUT_TTL_MS = 5000 ws 离线输入队列 TTL
|
|
5432
|
+
// PENDING_INPUT_MAX = 100 离线队列长度上限
|
|
5433
|
+
//
|
|
5434
|
+
// 调参时的关键不变式:
|
|
5435
|
+
// OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
|
|
5436
|
+
// RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
|
|
5437
|
+
// 否则会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
|
|
5438
|
+
var CHAT_RENDER_LIVE_MS = 150;
|
|
5439
|
+
var CHAT_RENDER_IDLE_MS = 30;
|
|
5440
|
+
|
|
5441
|
+
// 客户端的 state.terminalOutput 仅用于 softResyncTerminal 时重放给
|
|
5442
|
+
// wterm 作状态恢复,并不是用户能看到的 scrollback —— wterm 自己有
|
|
5443
|
+
// 独立 scrollback。但如果不限长,长跑会话累加几 MB 后每次 resync
|
|
5444
|
+
// 都会把整段重新喂给 wterm,CPU/内存随时间线性变差。
|
|
5445
|
+
//
|
|
5446
|
+
// 服务端 record.output 用 appendWindow(..., 200_000) 限了 200KB,这里
|
|
5447
|
+
// 客户端给一个稍宽的上限做兜底;超过就按行边界裁掉头部,行边界处
|
|
5448
|
+
// ANSI 状态机一定是 idle 状态,重放结果与未裁等价。找不到行边界时
|
|
5449
|
+
// 退化到字节切,并避开 UTF-16 半截、ANSI 半截。
|
|
5450
|
+
var CLIENT_OUTPUT_MAX = 256 * 1024;
|
|
5451
|
+
var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
|
|
5452
|
+
function clampClientTerminalOutput(buf) {
|
|
5453
|
+
if (!buf || buf.length <= CLIENT_OUTPUT_TRIM_AT) return buf;
|
|
5454
|
+
var start = buf.length - CLIENT_OUTPUT_MAX;
|
|
5455
|
+
// UTF-16 low surrogate
|
|
5456
|
+
if (start > 0 && start < buf.length) {
|
|
5457
|
+
var c0 = buf.charCodeAt(start);
|
|
5458
|
+
if (c0 >= 0xdc00 && c0 <= 0xdfff) start++;
|
|
5459
|
+
}
|
|
5460
|
+
// 优先在 lookahead 内找下一个换行符切割
|
|
5461
|
+
var LOOKAHEAD = 4096;
|
|
5462
|
+
var upper = Math.min(start + LOOKAHEAD, buf.length);
|
|
5463
|
+
for (var i = start; i < upper; i++) {
|
|
5464
|
+
if (buf.charCodeAt(i) === 0x0a) return buf.slice(i + 1);
|
|
5465
|
+
}
|
|
5466
|
+
// 没换行 → 检查 start 是否落在未结束的 ESC 序列里
|
|
5467
|
+
var lookback = Math.max(0, start - 256);
|
|
5468
|
+
var escAt = -1;
|
|
5469
|
+
for (var j = start - 1; j >= lookback; j--) {
|
|
5470
|
+
var c = buf.charCodeAt(j);
|
|
5471
|
+
if (c === 0x1b) { escAt = j; break; }
|
|
5472
|
+
if (c === 0x07) break;
|
|
5473
|
+
if (c >= 0x40 && c <= 0x7e) break;
|
|
5474
|
+
}
|
|
5475
|
+
if (escAt !== -1) {
|
|
5476
|
+
var terminated = false;
|
|
5477
|
+
for (var k = escAt + 1; k < start; k++) {
|
|
5478
|
+
var ck = buf.charCodeAt(k);
|
|
5479
|
+
if (ck === 0x07) { terminated = true; break; }
|
|
5480
|
+
if (ck >= 0x40 && ck <= 0x7e) { terminated = true; break; }
|
|
5481
|
+
}
|
|
5482
|
+
if (!terminated) {
|
|
5483
|
+
var ahead = Math.min(start + 256, buf.length);
|
|
5484
|
+
for (var m = start; m < ahead; m++) {
|
|
5485
|
+
var cm = buf.charCodeAt(m);
|
|
5486
|
+
if (cm === 0x07 || (cm >= 0x40 && cm <= 0x7e)) {
|
|
5487
|
+
return buf.slice(m + 1);
|
|
5488
|
+
}
|
|
5489
|
+
}
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
return buf.slice(start);
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5293
5495
|
function resetTerminal() {
|
|
5294
5496
|
if (!state.terminal) return;
|
|
5295
5497
|
// 优先走 wterm-entry.js 自定义 WTerm 子类暴露的 reset():它会调用
|
|
@@ -5315,14 +5517,44 @@
|
|
|
5315
5517
|
// correctly (e.g. wterm.onResize fired this resync — bouncing back into
|
|
5316
5518
|
// ensureTerminalFit would just trigger another remeasure → resize → onResize
|
|
5317
5519
|
// → softResyncTerminal recursion).
|
|
5520
|
+
//
|
|
5521
|
+
// 重放整 buffer 而非截短:alt screen 切换 / 滚动区 / 字符集等模式开关
|
|
5522
|
+
// 依赖从 buffer 开头开始消费。从中间切会丢失这些状态机指令,治标变
|
|
5523
|
+
// 造反。H1 已经把 buffer 限到 256KB,对 wterm WASM 来说这是 ms 级开销。
|
|
5524
|
+
var _resyncStatsWindowStart = 0;
|
|
5525
|
+
var _resyncStatsCount = 0;
|
|
5526
|
+
var _resyncLastWarnAt = 0;
|
|
5527
|
+
var RESYNC_BUDGET_WINDOW_MS = 5000;
|
|
5528
|
+
var RESYNC_BUDGET_MAX = 12;
|
|
5529
|
+
var RESYNC_WARN_COOLDOWN_MS = 30000;
|
|
5318
5530
|
function softResyncTerminal(options) {
|
|
5319
5531
|
if (!state.terminal || !state.terminalOutput) return false;
|
|
5320
5532
|
var opts = options || {};
|
|
5533
|
+
var bufLen = state.terminalOutput.length;
|
|
5534
|
+
var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
|
|
5321
5535
|
resetTerminal();
|
|
5322
5536
|
wandTerminalWrite(state.terminal, state.terminalOutput);
|
|
5323
5537
|
state.lastTerminalResyncAt = Date.now();
|
|
5324
5538
|
maybeScrollTerminalToBottom("output");
|
|
5325
5539
|
if (!opts.skipFit) ensureTerminalFit("refresh");
|
|
5540
|
+
// 统计 5s 窗口内的 resync 次数,过密时打 warn 帮助诊断
|
|
5541
|
+
// ——比如 wterm 状态机被反复弄脏、上游持续推原地重绘的菜单。
|
|
5542
|
+
// 单次 warn 后冷却 30s,避免刷屏。
|
|
5543
|
+
var now = Date.now();
|
|
5544
|
+
if (now - _resyncStatsWindowStart > RESYNC_BUDGET_WINDOW_MS) {
|
|
5545
|
+
_resyncStatsWindowStart = now;
|
|
5546
|
+
_resyncStatsCount = 1;
|
|
5547
|
+
} else {
|
|
5548
|
+
_resyncStatsCount++;
|
|
5549
|
+
if (_resyncStatsCount > RESYNC_BUDGET_MAX && now - _resyncLastWarnAt > RESYNC_WARN_COOLDOWN_MS) {
|
|
5550
|
+
_resyncLastWarnAt = now;
|
|
5551
|
+
var endedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
|
|
5552
|
+
console.warn("[wand] softResyncTerminal high frequency",
|
|
5553
|
+
"count=" + _resyncStatsCount + "/" + Math.round((now - _resyncStatsWindowStart) / 100) / 10 + "s",
|
|
5554
|
+
"bufLen=" + bufLen,
|
|
5555
|
+
"lastReplayMs=" + Math.round(endedAt - startedAt));
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5326
5558
|
return true;
|
|
5327
5559
|
}
|
|
5328
5560
|
|
|
@@ -5340,20 +5572,43 @@
|
|
|
5340
5572
|
// DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
|
|
5341
5573
|
// 用户体感就是"明明在改菜单,结果跑到最上面去了"。
|
|
5342
5574
|
//
|
|
5343
|
-
//
|
|
5344
|
-
//
|
|
5345
|
-
//
|
|
5346
|
-
//
|
|
5575
|
+
// 兜底策略是"重置 wterm 状态机 + 重放整 buffer"(softResyncTerminal)。
|
|
5576
|
+
// 这里的关键是触发时机:旧实现用 350ms debounce,但用户实际持续
|
|
5577
|
+
// 按方向键时,每次按键都会让 PTY 回流一次原地重绘 chunk,timer
|
|
5578
|
+
// 被反复 reset,永远等不到静默期,softResync 实际从不触发——
|
|
5579
|
+
// 这是这个保护机制的根本逻辑错误。
|
|
5347
5580
|
//
|
|
5348
|
-
//
|
|
5349
|
-
//
|
|
5350
|
-
//
|
|
5581
|
+
// 改成 leading + tail 的节流:第一次进入立即 resync(leading),
|
|
5582
|
+
// 节流窗口内的连续 chunk 只挂一个尾巴 timer 兜底,不重置。这样
|
|
5583
|
+
// 持续按键期间每 RESYNC_THROTTLE_MS 强制 resync 一次,用户停手
|
|
5584
|
+
// 时由尾巴 timer 收尾。不依赖按键停顿这种永远不发生的条件。
|
|
5351
5585
|
var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
|
|
5586
|
+
var RESYNC_THROTTLE_MS = 400;
|
|
5587
|
+
var RESYNC_TAIL_MS = 350;
|
|
5588
|
+
var _resyncChunkLastAt = 0;
|
|
5589
|
+
var _resyncChunkTailTimer = null;
|
|
5352
5590
|
function maybeScheduleResyncForChunk(chunk) {
|
|
5353
5591
|
if (!chunk || typeof chunk !== "string") return;
|
|
5354
5592
|
if (chunk.indexOf("\x1b[") === -1) return;
|
|
5355
5593
|
if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
|
|
5356
|
-
|
|
5594
|
+
var now = Date.now();
|
|
5595
|
+
var sinceLast = now - _resyncChunkLastAt;
|
|
5596
|
+
if (sinceLast >= RESYNC_THROTTLE_MS) {
|
|
5597
|
+
if (_resyncChunkTailTimer) {
|
|
5598
|
+
clearTimeout(_resyncChunkTailTimer);
|
|
5599
|
+
_resyncChunkTailTimer = null;
|
|
5600
|
+
}
|
|
5601
|
+
_resyncChunkLastAt = now;
|
|
5602
|
+
softResyncTerminal();
|
|
5603
|
+
return;
|
|
5604
|
+
}
|
|
5605
|
+
if (_resyncChunkTailTimer) return;
|
|
5606
|
+
var wait = Math.max(RESYNC_TAIL_MS, RESYNC_THROTTLE_MS - sinceLast);
|
|
5607
|
+
_resyncChunkTailTimer = setTimeout(function() {
|
|
5608
|
+
_resyncChunkTailTimer = null;
|
|
5609
|
+
_resyncChunkLastAt = Date.now();
|
|
5610
|
+
softResyncTerminal();
|
|
5611
|
+
}, wait);
|
|
5357
5612
|
}
|
|
5358
5613
|
|
|
5359
5614
|
function syncTerminalBuffer(sessionId, output, options) {
|
|
@@ -5465,6 +5720,11 @@
|
|
|
5465
5720
|
termWrap.className = "terminal-scroll-wrap";
|
|
5466
5721
|
container.appendChild(termWrap);
|
|
5467
5722
|
|
|
5723
|
+
// cols/rows 给一个保守默认即可:wterm-entry.js 重写的 init()
|
|
5724
|
+
// 会在 super.init() 之前按 termWrap 真实尺寸做一次预校准,
|
|
5725
|
+
// 保证 super.init() 里 bridge.init / renderer.setup 一上来
|
|
5726
|
+
// 就按真实 cols 初始化,从源头消除"先按 120 写一遍 → 异步
|
|
5727
|
+
// remeasure 纠正"的时序窗口。
|
|
5468
5728
|
var term = new WTermLib.WTerm(termWrap, {
|
|
5469
5729
|
cols: 120,
|
|
5470
5730
|
rows: 36,
|
|
@@ -5502,13 +5762,32 @@
|
|
|
5502
5762
|
state.terminal = term;
|
|
5503
5763
|
state.terminalInitializing = false;
|
|
5504
5764
|
applyTerminalScale();
|
|
5765
|
+
|
|
5766
|
+
// wterm 构造时 cols/rows 是硬编码的 120/36,super.init() 内部
|
|
5767
|
+
// 的 ResizeObserver 要等下一个 layout 阶段异步 fire 才纠正。
|
|
5768
|
+
// 如果不在写入历史前就把 bridge reflow 到容器真实尺寸,
|
|
5769
|
+
// syncTerminalBuffer 会按 120 cols 把整段历史写进 WASM grid,
|
|
5770
|
+
// 用户首屏看到的就是错列宽折行——必须等 ResizeObserver 触发
|
|
5771
|
+
// softResync 才恢复,中间会有几帧明显错位("刚开终端布局错乱
|
|
5772
|
+
// 一下、resize 一下才正常")。这里先强制一次 layout flush,
|
|
5773
|
+
// 再同步 remeasure 把 bridge 校准到真实 cols/rows,把"写入"
|
|
5774
|
+
// 卡在正确尺寸之后,避免错位帧。
|
|
5775
|
+
if (termWrap.isConnected) {
|
|
5776
|
+
void termWrap.offsetHeight;
|
|
5777
|
+
if (typeof term.remeasure === "function") {
|
|
5778
|
+
try { term.remeasure(); } catch (e) { /* ignore: 非致命 */ }
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5781
|
+
|
|
5505
5782
|
state.terminalAutoFollow = true;
|
|
5506
5783
|
clearTerminalScrollIdleTimer();
|
|
5507
5784
|
|
|
5508
5785
|
var viewport = getTerminalViewport();
|
|
5509
5786
|
if (viewport) {
|
|
5510
5787
|
state.terminalViewportScrollHandler = function() {
|
|
5511
|
-
|
|
5788
|
+
// 严格"真正到底"才恢复 autoFollow:避免 wheel 设 false 后被
|
|
5789
|
+
// 紧接着的 scroll 事件因"接近底部 12px"而反转回 true。
|
|
5790
|
+
if (isTerminalAtBottom()) {
|
|
5512
5791
|
state.terminalAutoFollow = true;
|
|
5513
5792
|
clearTerminalScrollIdleTimer();
|
|
5514
5793
|
updateTerminalJumpToBottomButton();
|
|
@@ -5665,6 +5944,11 @@
|
|
|
5665
5944
|
if (terminalInteractive) {
|
|
5666
5945
|
return "终端交互模式开启中,请直接在终端中输入";
|
|
5667
5946
|
}
|
|
5947
|
+
if (session && isStructuredSession(session)) {
|
|
5948
|
+
return session.provider === "codex"
|
|
5949
|
+
? "向 Codex 发送消息;chat 为结构化对话视图"
|
|
5950
|
+
: "向 Claude 发送消息;chat 为结构化对话视图";
|
|
5951
|
+
}
|
|
5668
5952
|
if (session && session.provider === "codex") {
|
|
5669
5953
|
if (session.status !== "running") {
|
|
5670
5954
|
return "Codex 会话已结束,无法继续发送";
|
|
@@ -5686,7 +5970,7 @@
|
|
|
5686
5970
|
|
|
5687
5971
|
function getToolModeHint(tool, mode) {
|
|
5688
5972
|
if (tool === "codex") {
|
|
5689
|
-
return "Codex
|
|
5973
|
+
return "Codex 支持 PTY 终端与结构化(JSONL)两种会话,结构化模式按 full-access 启动。";
|
|
5690
5974
|
}
|
|
5691
5975
|
if (mode === "full-access") {
|
|
5692
5976
|
return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
|
|
@@ -5790,15 +6074,20 @@
|
|
|
5790
6074
|
return "";
|
|
5791
6075
|
}
|
|
5792
6076
|
|
|
5793
|
-
function
|
|
5794
|
-
var
|
|
6077
|
+
function getModelsForCurrentProvider(session) {
|
|
6078
|
+
var provider = (session && session.provider) || state.sessionTool || "claude";
|
|
6079
|
+
if (provider === "codex") return state.availableCodexModels || [];
|
|
6080
|
+
return state.availableModels || [];
|
|
6081
|
+
}
|
|
6082
|
+
|
|
6083
|
+
function renderChatModelOptions(selected, session) {
|
|
6084
|
+
var models = getModelsForCurrentProvider(session);
|
|
5795
6085
|
var html = '<option value="">默认(跟随设置)</option>';
|
|
5796
6086
|
for (var i = 0; i < models.length; i++) {
|
|
5797
6087
|
var m = models[i];
|
|
5798
6088
|
var label = m.label || m.id;
|
|
5799
6089
|
html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(label) + '</option>';
|
|
5800
6090
|
}
|
|
5801
|
-
// If selected is unknown (custom value), prepend it as a sticky option
|
|
5802
6091
|
if (selected && !models.some(function(m) { return m.id === selected; })) {
|
|
5803
6092
|
html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '(自定义)</option>';
|
|
5804
6093
|
}
|
|
@@ -5809,7 +6098,7 @@
|
|
|
5809
6098
|
var select = document.getElementById("chat-model-select");
|
|
5810
6099
|
if (!select) return;
|
|
5811
6100
|
var effective = getEffectiveModel(session);
|
|
5812
|
-
select.innerHTML = renderChatModelOptions(effective);
|
|
6101
|
+
select.innerHTML = renderChatModelOptions(effective, session);
|
|
5813
6102
|
select.value = effective;
|
|
5814
6103
|
}
|
|
5815
6104
|
|
|
@@ -5819,6 +6108,7 @@
|
|
|
5819
6108
|
.then(function(data) {
|
|
5820
6109
|
if (data && Array.isArray(data.models)) {
|
|
5821
6110
|
state.availableModels = data.models;
|
|
6111
|
+
state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
|
|
5822
6112
|
syncComposerModelSelect(getSelectedSession());
|
|
5823
6113
|
updateSettingsDefaultModelSelect(data);
|
|
5824
6114
|
}
|
|
@@ -5837,6 +6127,7 @@
|
|
|
5837
6127
|
.then(function(data) {
|
|
5838
6128
|
if (data && Array.isArray(data.models)) {
|
|
5839
6129
|
state.availableModels = data.models;
|
|
6130
|
+
state.availableCodexModels = Array.isArray(data.codexModels) ? data.codexModels : [];
|
|
5840
6131
|
syncComposerModelSelect(getSelectedSession());
|
|
5841
6132
|
updateSettingsDefaultModelSelect(data);
|
|
5842
6133
|
if (typeof showToast === "function") {
|
|
@@ -5860,7 +6151,7 @@
|
|
|
5860
6151
|
if (!select) return;
|
|
5861
6152
|
var previous = select.value;
|
|
5862
6153
|
var current = previous || state.configDefaultModel || (state.config && state.config.defaultModel) || "";
|
|
5863
|
-
select.innerHTML = renderChatModelOptions(current);
|
|
6154
|
+
select.innerHTML = renderChatModelOptions(current, { provider: "claude" });
|
|
5864
6155
|
select.value = current;
|
|
5865
6156
|
var versionEl = document.getElementById("cfg-default-model-version");
|
|
5866
6157
|
if (versionEl && data) {
|
|
@@ -5882,7 +6173,6 @@
|
|
|
5882
6173
|
try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
|
|
5883
6174
|
var session = getSelectedSession();
|
|
5884
6175
|
if (!session) return;
|
|
5885
|
-
if (session.provider && session.provider !== "claude") return;
|
|
5886
6176
|
fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
|
|
5887
6177
|
method: "POST",
|
|
5888
6178
|
headers: { "Content-Type": "application/json" },
|
|
@@ -5899,7 +6189,8 @@
|
|
|
5899
6189
|
updateSessionSnapshot(data);
|
|
5900
6190
|
if (typeof showToast === "function") {
|
|
5901
6191
|
var display = normalized || "默认";
|
|
5902
|
-
|
|
6192
|
+
var hint = session.provider === "codex" ? "(下次对话生效)" : "";
|
|
6193
|
+
showToast("已切换模型 → " + display + hint, "success");
|
|
5903
6194
|
}
|
|
5904
6195
|
}
|
|
5905
6196
|
})
|
|
@@ -5907,11 +6198,13 @@
|
|
|
5907
6198
|
}
|
|
5908
6199
|
|
|
5909
6200
|
function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
|
|
6201
|
+
var provider = state.sessionTool === "codex" ? "codex" : "claude";
|
|
5910
6202
|
var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
|
|
5911
6203
|
var payload = {
|
|
5912
6204
|
cwd: cwdOverride || getEffectiveCwd(),
|
|
5913
6205
|
mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
|
|
5914
|
-
|
|
6206
|
+
provider: provider,
|
|
6207
|
+
runner: provider === "codex" ? "codex-cli-exec" : (state.structuredRunner || "claude-cli-print"),
|
|
5915
6208
|
prompt: prompt || undefined,
|
|
5916
6209
|
worktreeEnabled: worktreeEnabled === true,
|
|
5917
6210
|
model: modelPref || undefined
|
|
@@ -5981,11 +6274,6 @@
|
|
|
5981
6274
|
var tool = state.sessionTool || "claude";
|
|
5982
6275
|
var sessionKind = state.sessionCreateKind || "structured";
|
|
5983
6276
|
|
|
5984
|
-
if (tool === "codex" && sessionKind === "structured") {
|
|
5985
|
-
sessionKind = "pty";
|
|
5986
|
-
state.sessionCreateKind = "pty";
|
|
5987
|
-
}
|
|
5988
|
-
|
|
5989
6277
|
state.sessionTool = tool;
|
|
5990
6278
|
state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
|
|
5991
6279
|
|
|
@@ -6002,7 +6290,7 @@
|
|
|
6002
6290
|
if (kindCards.length) {
|
|
6003
6291
|
kindCards.forEach(function(card) {
|
|
6004
6292
|
var kind = card.getAttribute("data-session-kind");
|
|
6005
|
-
var disabled =
|
|
6293
|
+
var disabled = false;
|
|
6006
6294
|
card.classList.toggle("active", kind === sessionKind);
|
|
6007
6295
|
card.classList.toggle("disabled", disabled);
|
|
6008
6296
|
});
|
|
@@ -6384,12 +6672,22 @@
|
|
|
6384
6672
|
// 设计原则:terminal 写入只走 ws init 与 chunk hot-path 两条
|
|
6385
6673
|
// 权威路径——参见 case "init" 的 replace 写入与 onmessage
|
|
6386
6674
|
// chunk 处理。这里只在 ws 离线兜底时才 append 写入。
|
|
6387
|
-
|
|
6675
|
+
//
|
|
6676
|
+
// wsLikelyTakingOver: 即使 wsConnected=false(onopen 还没 fire),
|
|
6677
|
+
// 只要 ws.readyState 是 CONNECTING 或 OPEN,就视为 ws 即将
|
|
6678
|
+
// 接管。否则 selectSession → loadOutput resolve 比 ws onopen
|
|
6679
|
+
// 早时(常见于刷新页面后的首次连接)会误走 fallback,写入
|
|
6680
|
+
// terminal 后 ws init 又写一次,造成双路重叠。
|
|
6681
|
+
var wsLikelyTakingOver = !!state.ws && (
|
|
6682
|
+
state.ws.readyState === WebSocket.OPEN ||
|
|
6683
|
+
state.ws.readyState === WebSocket.CONNECTING
|
|
6684
|
+
);
|
|
6685
|
+
if (!wsLikelyTakingOver) {
|
|
6388
6686
|
syncTerminalBuffer(id, data.output, { mode: "append" });
|
|
6389
6687
|
// 离线兜底路径自己负责 fit + replay,否则尺寸不对。
|
|
6390
6688
|
ensureTerminalFit("session-switch", { forceReplay: true });
|
|
6391
6689
|
} else {
|
|
6392
|
-
// ws
|
|
6690
|
+
// ws 在线/连接中:仅校准列宽,不重 replay(init 的
|
|
6393
6691
|
// ensureTerminalFitWithRetry("init") 会负责按真实
|
|
6394
6692
|
// 宽度的全量基线写入)。
|
|
6395
6693
|
ensureTerminalFit("session-switch");
|
|
@@ -6545,7 +6843,7 @@
|
|
|
6545
6843
|
lastFocusedElement = document.activeElement;
|
|
6546
6844
|
state.sessionTool = getPreferredTool();
|
|
6547
6845
|
state.preferredCommand = state.sessionTool;
|
|
6548
|
-
state.sessionCreateKind =
|
|
6846
|
+
state.sessionCreateKind = "structured";
|
|
6549
6847
|
state.sessionCreateWorktree = false;
|
|
6550
6848
|
state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
|
|
6551
6849
|
syncSessionModalUI();
|
|
@@ -7752,6 +8050,29 @@
|
|
|
7752
8050
|
el.classList.remove("hidden");
|
|
7753
8051
|
}
|
|
7754
8052
|
|
|
8053
|
+
// 创建 PTY 会话时把当前终端的真实 cols/rows 注入 body,让后端 pty.spawn
|
|
8054
|
+
// 直接落在正确尺寸下。否则 PTY 先按 cols=120 启动,Claude/Codex 会基于
|
|
8055
|
+
// 120 列输出 \x1b[120G 这类绝对列定位序列;等前端 remeasure 触发 resize
|
|
8056
|
+
// 时这些早期内容已经被以 80 等真实列数渲染,整条历史就错位。
|
|
8057
|
+
function withTerminalDimensions(body) {
|
|
8058
|
+
if (!body || typeof body !== "object") return body;
|
|
8059
|
+
if (!state.terminal) return body;
|
|
8060
|
+
try {
|
|
8061
|
+
if (typeof state.terminal.remeasure === "function") {
|
|
8062
|
+
state.terminal.remeasure();
|
|
8063
|
+
}
|
|
8064
|
+
} catch (e) {}
|
|
8065
|
+
var cols = state.terminal.cols;
|
|
8066
|
+
var rows = state.terminal.rows;
|
|
8067
|
+
if (typeof cols === "number" && typeof rows === "number"
|
|
8068
|
+
&& Number.isFinite(cols) && Number.isFinite(rows)
|
|
8069
|
+
&& cols > 0 && rows > 0) {
|
|
8070
|
+
body.cols = cols;
|
|
8071
|
+
body.rows = rows;
|
|
8072
|
+
}
|
|
8073
|
+
return body;
|
|
8074
|
+
}
|
|
8075
|
+
|
|
7755
8076
|
function quickStartSession() {
|
|
7756
8077
|
var command = getPreferredTool();
|
|
7757
8078
|
var defaultCwd = getEffectiveCwd();
|
|
@@ -7762,7 +8083,7 @@
|
|
|
7762
8083
|
method: "POST",
|
|
7763
8084
|
headers: { "Content-Type": "application/json" },
|
|
7764
8085
|
credentials: "same-origin",
|
|
7765
|
-
body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
|
|
8086
|
+
body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
|
|
7766
8087
|
})
|
|
7767
8088
|
.then(function(res) { return res.json(); })
|
|
7768
8089
|
.then(function(data) {
|
|
@@ -7807,12 +8128,13 @@
|
|
|
7807
8128
|
}
|
|
7808
8129
|
|
|
7809
8130
|
function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
|
|
7810
|
-
|
|
8131
|
+
var provider = state.sessionTool === "codex" ? "codex" : "claude";
|
|
8132
|
+
console.log("[WAND] startStructuredSessionFromModal provider:", provider, "cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
|
|
7811
8133
|
_sessionCreating = true;
|
|
7812
8134
|
state.modeValue = mode;
|
|
7813
8135
|
state.chatMode = mode;
|
|
7814
|
-
state.sessionTool =
|
|
7815
|
-
state.preferredCommand =
|
|
8136
|
+
state.sessionTool = provider;
|
|
8137
|
+
state.preferredCommand = provider;
|
|
7816
8138
|
syncComposerModeSelect();
|
|
7817
8139
|
syncComposerModelSelect(getSelectedSession());
|
|
7818
8140
|
return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
|
|
@@ -7842,13 +8164,13 @@
|
|
|
7842
8164
|
method: "POST",
|
|
7843
8165
|
headers: { "Content-Type": "application/json" },
|
|
7844
8166
|
credentials: "same-origin",
|
|
7845
|
-
body: JSON.stringify({
|
|
8167
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
7846
8168
|
command: command,
|
|
7847
8169
|
provider: command,
|
|
7848
8170
|
cwd: cwd,
|
|
7849
8171
|
mode: mode,
|
|
7850
8172
|
worktreeEnabled: worktreeEnabled
|
|
7851
|
-
})
|
|
8173
|
+
}))
|
|
7852
8174
|
})
|
|
7853
8175
|
.then(function(res) { return res.json(); })
|
|
7854
8176
|
.then(function(data) {
|
|
@@ -8339,47 +8661,6 @@
|
|
|
8339
8661
|
}
|
|
8340
8662
|
}
|
|
8341
8663
|
|
|
8342
|
-
function createOptimizeShimmer(inputBox, text) {
|
|
8343
|
-
var composer = inputBox.closest(".input-composer");
|
|
8344
|
-
if (!composer) return null;
|
|
8345
|
-
// 用 mirror 元素同步 textarea 的文字 + 字体 + 排版参数,叠在
|
|
8346
|
-
// textarea 之上。mirror 自身 color: transparent + background-clip:
|
|
8347
|
-
// text,让动画渐变只在文字字符形状内显示——空白处完全透明,所以
|
|
8348
|
-
// 视觉上只有"几个字"被光扫过,像魔法橡皮擦掠过文字本身,而不是
|
|
8349
|
-
// 整个输入框背景在闪。
|
|
8350
|
-
var rect = inputBox.getBoundingClientRect();
|
|
8351
|
-
var composerRect = composer.getBoundingClientRect();
|
|
8352
|
-
var style = window.getComputedStyle(inputBox);
|
|
8353
|
-
var mirror = document.createElement("div");
|
|
8354
|
-
mirror.className = "prompt-optimize-shimmer-overlay";
|
|
8355
|
-
mirror.textContent = text;
|
|
8356
|
-
// 与 textarea 同坐标同尺寸(composer 是 position:relative,所以
|
|
8357
|
-
// 用相对 composer 的 offset;不直接用 inputBox.offsetTop 是因为
|
|
8358
|
-
// textarea 可能有变换/包裹元素,getBoundingClientRect 更稳)。
|
|
8359
|
-
mirror.style.top = (rect.top - composerRect.top) + "px";
|
|
8360
|
-
mirror.style.left = (rect.left - composerRect.left) + "px";
|
|
8361
|
-
mirror.style.width = rect.width + "px";
|
|
8362
|
-
mirror.style.height = rect.height + "px";
|
|
8363
|
-
// 复制 textarea 的字符排版,确保 mirror 上的字符与 textarea 渲染
|
|
8364
|
-
// 的字符像素级对齐(错位会让"光从文字扫过"看起来糊掉)。
|
|
8365
|
-
mirror.style.fontFamily = style.fontFamily;
|
|
8366
|
-
mirror.style.fontSize = style.fontSize;
|
|
8367
|
-
mirror.style.fontWeight = style.fontWeight;
|
|
8368
|
-
mirror.style.fontStyle = style.fontStyle;
|
|
8369
|
-
mirror.style.lineHeight = style.lineHeight;
|
|
8370
|
-
mirror.style.letterSpacing = style.letterSpacing;
|
|
8371
|
-
mirror.style.wordSpacing = style.wordSpacing;
|
|
8372
|
-
mirror.style.textAlign = style.textAlign;
|
|
8373
|
-
mirror.style.textIndent = style.textIndent;
|
|
8374
|
-
mirror.style.paddingTop = style.paddingTop;
|
|
8375
|
-
mirror.style.paddingRight = style.paddingRight;
|
|
8376
|
-
mirror.style.paddingBottom = style.paddingBottom;
|
|
8377
|
-
mirror.style.paddingLeft = style.paddingLeft;
|
|
8378
|
-
mirror.style.boxSizing = style.boxSizing;
|
|
8379
|
-
composer.appendChild(mirror);
|
|
8380
|
-
return mirror;
|
|
8381
|
-
}
|
|
8382
|
-
|
|
8383
8664
|
var promptOptimizeInFlight = false;
|
|
8384
8665
|
function optimizePromptText() {
|
|
8385
8666
|
if (promptOptimizeInFlight) return;
|
|
@@ -8400,7 +8681,6 @@
|
|
|
8400
8681
|
btn.setAttribute("title", "正在优化…");
|
|
8401
8682
|
}
|
|
8402
8683
|
if (composer) composer.classList.add("is-optimizing");
|
|
8403
|
-
var shimmerOverlay = createOptimizeShimmer(inputBox, raw);
|
|
8404
8684
|
inputBox.setAttribute("aria-busy", "true");
|
|
8405
8685
|
var prevReadOnly = inputBox.readOnly;
|
|
8406
8686
|
inputBox.readOnly = true;
|
|
@@ -8439,9 +8719,6 @@
|
|
|
8439
8719
|
btn.setAttribute("title", "提示词优化(AI)");
|
|
8440
8720
|
}
|
|
8441
8721
|
if (composer) composer.classList.remove("is-optimizing");
|
|
8442
|
-
if (shimmerOverlay && shimmerOverlay.parentNode) {
|
|
8443
|
-
shimmerOverlay.parentNode.removeChild(shimmerOverlay);
|
|
8444
|
-
}
|
|
8445
8722
|
inputBox.removeAttribute("aria-busy");
|
|
8446
8723
|
inputBox.readOnly = prevReadOnly;
|
|
8447
8724
|
});
|
|
@@ -8474,8 +8751,6 @@
|
|
|
8474
8751
|
} else {
|
|
8475
8752
|
setDraftValue(finalText, true);
|
|
8476
8753
|
try { inputBox.setSelectionRange(finalText.length, finalText.length); } catch (e) { /* ignore */ }
|
|
8477
|
-
inputBox.classList.add("optimize-flash");
|
|
8478
|
-
setTimeout(function() { inputBox.classList.remove("optimize-flash"); }, 900);
|
|
8479
8754
|
}
|
|
8480
8755
|
}
|
|
8481
8756
|
tick();
|
|
@@ -8515,6 +8790,9 @@
|
|
|
8515
8790
|
function isSelectedSessionRunning() {
|
|
8516
8791
|
if (!state.selectedId) return false;
|
|
8517
8792
|
var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
|
|
8793
|
+
if (isStructuredSession(selectedSession)) {
|
|
8794
|
+
return !!(selectedSession.structuredState && selectedSession.structuredState.inFlight);
|
|
8795
|
+
}
|
|
8518
8796
|
return !!selectedSession && selectedSession.status === "running";
|
|
8519
8797
|
}
|
|
8520
8798
|
|
|
@@ -8524,6 +8802,9 @@
|
|
|
8524
8802
|
|
|
8525
8803
|
function hasAnyBusySession() {
|
|
8526
8804
|
return state.sessions.some(function(s) {
|
|
8805
|
+
if (isStructuredSession(s)) {
|
|
8806
|
+
return !!(s.structuredState && s.structuredState.inFlight) && !s.archived;
|
|
8807
|
+
}
|
|
8527
8808
|
return s.status === "running" && !s.archived;
|
|
8528
8809
|
});
|
|
8529
8810
|
}
|
|
@@ -8553,12 +8834,12 @@
|
|
|
8553
8834
|
method: "POST",
|
|
8554
8835
|
headers: { "Content-Type": "application/json" },
|
|
8555
8836
|
credentials: "same-origin",
|
|
8556
|
-
body: JSON.stringify({
|
|
8837
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
8557
8838
|
command: item.tool,
|
|
8558
8839
|
cwd: item.cwd,
|
|
8559
8840
|
mode: item.mode,
|
|
8560
8841
|
initialInput: item.text
|
|
8561
|
-
})
|
|
8842
|
+
}))
|
|
8562
8843
|
})
|
|
8563
8844
|
.then(function(res) { return res.json(); })
|
|
8564
8845
|
.then(function(data) {
|
|
@@ -8593,12 +8874,12 @@
|
|
|
8593
8874
|
method: "POST",
|
|
8594
8875
|
headers: { "Content-Type": "application/json" },
|
|
8595
8876
|
credentials: "same-origin",
|
|
8596
|
-
body: JSON.stringify({
|
|
8877
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
8597
8878
|
command: item.tool,
|
|
8598
8879
|
cwd: item.cwd,
|
|
8599
8880
|
mode: item.mode,
|
|
8600
8881
|
initialInput: item.text
|
|
8601
|
-
})
|
|
8882
|
+
}))
|
|
8602
8883
|
})
|
|
8603
8884
|
.then(function(res) { return res.json(); })
|
|
8604
8885
|
.then(function(data) {
|
|
@@ -8774,13 +9055,13 @@
|
|
|
8774
9055
|
method: "POST",
|
|
8775
9056
|
headers: { "Content-Type": "application/json" },
|
|
8776
9057
|
credentials: "same-origin",
|
|
8777
|
-
body: JSON.stringify({
|
|
9058
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
8778
9059
|
command: preferredTool,
|
|
8779
9060
|
provider: preferredTool,
|
|
8780
9061
|
cwd: defaultCwd,
|
|
8781
9062
|
mode: mode,
|
|
8782
9063
|
initialInput: value
|
|
8783
|
-
})
|
|
9064
|
+
}))
|
|
8784
9065
|
})
|
|
8785
9066
|
.then(function(res) { return res.json(); })
|
|
8786
9067
|
.then(function(data) {
|
|
@@ -8844,13 +9125,13 @@
|
|
|
8844
9125
|
method: "POST",
|
|
8845
9126
|
headers: { "Content-Type": "application/json" },
|
|
8846
9127
|
credentials: "same-origin",
|
|
8847
|
-
body: JSON.stringify({
|
|
9128
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
8848
9129
|
command: preferredTool,
|
|
8849
9130
|
provider: preferredTool,
|
|
8850
9131
|
cwd: defaultCwd,
|
|
8851
9132
|
mode: mode,
|
|
8852
9133
|
initialInput: value || undefined
|
|
8853
|
-
})
|
|
9134
|
+
}))
|
|
8854
9135
|
})
|
|
8855
9136
|
.then(function(res) { return res.json(); })
|
|
8856
9137
|
.then(function(data) {
|
|
@@ -9046,7 +9327,9 @@
|
|
|
9046
9327
|
// the HTTP response arrives.
|
|
9047
9328
|
var epochBeforePost = state.queueEpoch;
|
|
9048
9329
|
|
|
9049
|
-
|
|
9330
|
+
// 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
|
|
9331
|
+
// 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
|
|
9332
|
+
return fetch("/api/structured-sessions/" + session.id + "/messages", {
|
|
9050
9333
|
method: "POST",
|
|
9051
9334
|
headers: { "Content-Type": "application/json" },
|
|
9052
9335
|
credentials: "same-origin",
|
|
@@ -9069,11 +9352,14 @@
|
|
|
9069
9352
|
delete snapshot.queuedMessages;
|
|
9070
9353
|
}
|
|
9071
9354
|
updateSessionSnapshot(snapshot);
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9355
|
+
// 仅当 snapshot 仍属当前选中会话时才覆盖视图状态,否则只更新底层数据。
|
|
9356
|
+
if (snapshot.id === state.selectedId) {
|
|
9357
|
+
var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
|
|
9358
|
+
state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
|
|
9359
|
+
renderChat(true);
|
|
9360
|
+
if (isInterrupting) {
|
|
9361
|
+
showToast("已中断上一条回复,正在处理新消息…", "info");
|
|
9362
|
+
}
|
|
9077
9363
|
}
|
|
9078
9364
|
}
|
|
9079
9365
|
})
|
|
@@ -9273,13 +9559,26 @@
|
|
|
9273
9559
|
}, Promise.resolve());
|
|
9274
9560
|
}
|
|
9275
9561
|
|
|
9562
|
+
// pendingMessages 用于 ws 离线时缓存输入,重连后批量回放。
|
|
9563
|
+
// 旧实现:按字符串入队,仅靠长度上限 100 控制;超出 shift 最早一条。
|
|
9564
|
+
// 问题:用户连续按方向键时几秒就把队列填满;shift 把最早按下的丢
|
|
9565
|
+
// 掉,剩下的反而是后期按的——重连后回放的"输入序列"和用户实际
|
|
9566
|
+
// 按下的顺序矛盾。给每条消息打时间戳,flush 时直接丢弃过期项,
|
|
9567
|
+
// 让"离线超过 N 秒后重连"恢复成一个干净状态而不是错位重放。
|
|
9568
|
+
var PENDING_INPUT_TTL_MS = 5000;
|
|
9569
|
+
var PENDING_INPUT_MAX = 100;
|
|
9570
|
+
function enqueuePendingInput(input) {
|
|
9571
|
+
if (!input) return;
|
|
9572
|
+
if (state.pendingMessages.length >= PENDING_INPUT_MAX) {
|
|
9573
|
+
state.pendingMessages.shift();
|
|
9574
|
+
}
|
|
9575
|
+
state.pendingMessages.push({ input: input, at: Date.now() });
|
|
9576
|
+
}
|
|
9577
|
+
|
|
9276
9578
|
function queueOfflineTerminalChunks(chunks) {
|
|
9277
9579
|
var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
|
|
9278
9580
|
sequence.forEach(function(chunk) {
|
|
9279
|
-
|
|
9280
|
-
state.pendingMessages.shift();
|
|
9281
|
-
}
|
|
9282
|
-
state.pendingMessages.push(chunk);
|
|
9581
|
+
enqueuePendingInput(chunk);
|
|
9283
9582
|
});
|
|
9284
9583
|
}
|
|
9285
9584
|
|
|
@@ -9297,16 +9596,21 @@
|
|
|
9297
9596
|
|
|
9298
9597
|
function postInput(input, shortcutKey, viewOverride) {
|
|
9299
9598
|
if (!state.selectedId) return Promise.resolve();
|
|
9599
|
+
// 锁定本次请求归属的 sessionId。fetch 发起后用户可能切到别的会话,
|
|
9600
|
+
// 后续 then 回调里直接用 state.selectedId 会误把 A 的响应应用到 B:
|
|
9601
|
+
// - URL 上拼错会话(虽然 fetch 已经求值过 URL,但 markSessionStopped
|
|
9602
|
+
// 等 in-flight 引用会读最新值 → 把 B 标为 stopped 但实际是 A 失败)
|
|
9603
|
+
// - response.snapshot 属于 A,被 setCurrentMessages 误覆盖到 B 视图
|
|
9604
|
+
// 用 requestSessionId 锁住请求方,渲染相关动作再单独判断 snapshot.id
|
|
9605
|
+
// === 当前 state.selectedId 才执行。
|
|
9606
|
+
var requestSessionId = state.selectedId;
|
|
9300
9607
|
var effectiveView = viewOverride || state.currentView;
|
|
9301
9608
|
|
|
9302
9609
|
// Pre-check: don't send if session is not running
|
|
9303
9610
|
if (!isSelectedSessionRunning()) {
|
|
9304
9611
|
// If WebSocket is disconnected, queue for flush on reconnect
|
|
9305
9612
|
if (!state.wsConnected) {
|
|
9306
|
-
|
|
9307
|
-
state.pendingMessages.shift();
|
|
9308
|
-
}
|
|
9309
|
-
state.pendingMessages.push(input);
|
|
9613
|
+
enqueuePendingInput(input);
|
|
9310
9614
|
console.log("[wand] postInput: session not running, queued for reconnect", {
|
|
9311
9615
|
sessionId: state.selectedId,
|
|
9312
9616
|
inputLength: input.length
|
|
@@ -9322,10 +9626,7 @@
|
|
|
9322
9626
|
|
|
9323
9627
|
// If WebSocket is disconnected, queue the message (no HTTP fetch while offline)
|
|
9324
9628
|
if (!state.wsConnected) {
|
|
9325
|
-
|
|
9326
|
-
state.pendingMessages.shift();
|
|
9327
|
-
}
|
|
9328
|
-
state.pendingMessages.push(input);
|
|
9629
|
+
enqueuePendingInput(input);
|
|
9329
9630
|
console.log("[wand] postInput: WebSocket disconnected, queued message", {
|
|
9330
9631
|
sessionId: state.selectedId,
|
|
9331
9632
|
inputLength: input.length
|
|
@@ -9340,7 +9641,7 @@
|
|
|
9340
9641
|
wsConnected: state.wsConnected
|
|
9341
9642
|
});
|
|
9342
9643
|
|
|
9343
|
-
return fetch("/api/sessions/" +
|
|
9644
|
+
return fetch("/api/sessions/" + requestSessionId + "/input", {
|
|
9344
9645
|
method: "POST",
|
|
9345
9646
|
headers: { "Content-Type": "application/json" },
|
|
9346
9647
|
credentials: "same-origin",
|
|
@@ -9355,11 +9656,11 @@
|
|
|
9355
9656
|
status: res.status,
|
|
9356
9657
|
errorCode: error.errorCode,
|
|
9357
9658
|
message: error.message,
|
|
9358
|
-
sessionId:
|
|
9659
|
+
sessionId: requestSessionId
|
|
9359
9660
|
});
|
|
9360
9661
|
// Mark session as stopped for unavailable errors
|
|
9361
9662
|
if (isSessionUnavailableError(error)) {
|
|
9362
|
-
markSessionStopped(
|
|
9663
|
+
markSessionStopped(requestSessionId, error.sessionStatus || "exited");
|
|
9363
9664
|
}
|
|
9364
9665
|
throw error;
|
|
9365
9666
|
});
|
|
@@ -9368,11 +9669,18 @@
|
|
|
9368
9669
|
})
|
|
9369
9670
|
.then(function(snapshot) {
|
|
9370
9671
|
if (snapshot && snapshot.id) {
|
|
9672
|
+
// 底层 sessions 数据按 id 索引,无论是否仍是当前选中都可以
|
|
9673
|
+
// 安全更新(不会污染其他会话)。
|
|
9371
9674
|
updateSessionSnapshot(snapshot);
|
|
9372
|
-
|
|
9373
|
-
|
|
9675
|
+
// 但 currentMessages / renderChat 是当前视图状态,必须仅当
|
|
9676
|
+
// snapshot 仍属当前选中会话时才执行;否则会把 A 的消息列表
|
|
9677
|
+
// 渲染到 B 的 chat 视图。
|
|
9678
|
+
if (snapshot.id === state.selectedId) {
|
|
9679
|
+
if (snapshot.messages && snapshot.messages.length > 0) {
|
|
9680
|
+
state.currentMessages = snapshot.messages;
|
|
9681
|
+
}
|
|
9682
|
+
renderChat(true);
|
|
9374
9683
|
}
|
|
9375
|
-
renderChat(true);
|
|
9376
9684
|
}
|
|
9377
9685
|
return snapshot;
|
|
9378
9686
|
});
|
|
@@ -9394,6 +9702,18 @@
|
|
|
9394
9702
|
return !!state.selectedId && state.currentView === "terminal";
|
|
9395
9703
|
}
|
|
9396
9704
|
|
|
9705
|
+
// 判断一条带 sessionId 的 ws 消息是否应该被当前 wterm 实例消费。
|
|
9706
|
+
// 收敛多处散落的"selectedId 一致 + terminalSessionId 兼容"判断,避免
|
|
9707
|
+
// 后续重构时漏改某一处导致旧会话的输出污染当前终端。
|
|
9708
|
+
// terminalSessionId 为空(尚未首次 init/切换刚发生)视为可接受任何
|
|
9709
|
+
// sessionId —— 这是首条 chunk 触发自我初始化的场景。
|
|
9710
|
+
function isCurrentTerminalSession(sessionId) {
|
|
9711
|
+
if (!state.terminal || !sessionId) return false;
|
|
9712
|
+
if (sessionId !== state.selectedId) return false;
|
|
9713
|
+
if (state.terminalSessionId && state.terminalSessionId !== sessionId) return false;
|
|
9714
|
+
return true;
|
|
9715
|
+
}
|
|
9716
|
+
|
|
9397
9717
|
function shouldCaptureTerminalEvent(event) {
|
|
9398
9718
|
if (!state.terminalInteractive || !isTerminalInteractionAvailable()) return false;
|
|
9399
9719
|
if (event.defaultPrevented || event.isComposing) return false;
|
|
@@ -9561,7 +9881,9 @@
|
|
|
9561
9881
|
var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
|
|
9562
9882
|
var structured = isStructuredSession(selectedSession);
|
|
9563
9883
|
var isCodex = selectedSession && selectedSession.provider === "codex";
|
|
9564
|
-
var isRunning =
|
|
9884
|
+
var isRunning = structured
|
|
9885
|
+
? !!(selectedSession && selectedSession.structuredState && selectedSession.structuredState.inFlight)
|
|
9886
|
+
: !!selectedSession && selectedSession.status === "running";
|
|
9565
9887
|
var composer = document.getElementById("input-box");
|
|
9566
9888
|
// Update both toggle buttons (topbar and terminal-header)
|
|
9567
9889
|
var toggles = ["terminal-interactive-toggle-top"];
|
|
@@ -9586,7 +9908,7 @@
|
|
|
9586
9908
|
: "Enter 发送 · Shift+Enter 换行";
|
|
9587
9909
|
}
|
|
9588
9910
|
}
|
|
9589
|
-
var disableStructuredInput =
|
|
9911
|
+
var disableStructuredInput = false;
|
|
9590
9912
|
// 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
|
|
9591
9913
|
// 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
|
|
9592
9914
|
var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
|
|
@@ -9594,13 +9916,20 @@
|
|
|
9594
9916
|
composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
|
|
9595
9917
|
composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
|
|
9596
9918
|
composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
|
|
9919
|
+
// 终端交互模式下,按键由 document capture phase 直接透传到 PTY;
|
|
9920
|
+
// 把 textarea 设为 readonly 避免浏览器同时把字符落进输入框
|
|
9921
|
+
// (IME 组合输入、preventDefault 不彻底等边界场景下会出现"键
|
|
9922
|
+
// 发到了 PTY、输入框里也留下了字"的双状态)。disabled 会让
|
|
9923
|
+
// textarea 失去焦点能力影响一些场景,readOnly 更轻、保留焦点。
|
|
9924
|
+
composer.readOnly = !!state.terminalInteractive;
|
|
9925
|
+
composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
|
|
9597
9926
|
}
|
|
9598
9927
|
var sendBtn = document.getElementById("send-input-button");
|
|
9599
9928
|
if (sendBtn) {
|
|
9600
9929
|
sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
|
|
9601
|
-
sendBtn.setAttribute("title",
|
|
9602
|
-
?
|
|
9603
|
-
: (
|
|
9930
|
+
sendBtn.setAttribute("title", structured
|
|
9931
|
+
? "发送"
|
|
9932
|
+
: (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
|
|
9604
9933
|
}
|
|
9605
9934
|
var container = document.getElementById("output");
|
|
9606
9935
|
if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
|
|
@@ -9631,6 +9960,7 @@
|
|
|
9631
9960
|
var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: state.modifiers.shift });
|
|
9632
9961
|
if (sequence) sendTerminalSequence(sequence, key);
|
|
9633
9962
|
clearModifiers();
|
|
9963
|
+
scheduleShortcutResync();
|
|
9634
9964
|
}
|
|
9635
9965
|
|
|
9636
9966
|
function handleInlineKeyboardClick(event) {
|
|
@@ -9647,12 +9977,28 @@
|
|
|
9647
9977
|
if (key === "ctrl_enter") {
|
|
9648
9978
|
var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
|
|
9649
9979
|
if (sequence) sendTerminalSequence(sequence, "ctrl_enter");
|
|
9980
|
+
scheduleShortcutResync();
|
|
9650
9981
|
return;
|
|
9651
9982
|
}
|
|
9652
9983
|
var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: false });
|
|
9653
9984
|
if (sequence) sendTerminalSequence(sequence, key);
|
|
9654
9985
|
clearModifiers();
|
|
9655
9986
|
updateKeyboardPopupUI();
|
|
9987
|
+
scheduleShortcutResync();
|
|
9988
|
+
}
|
|
9989
|
+
|
|
9990
|
+
// 用户点击下方快捷键栏(↑↓←→/Enter/Esc 等)后,PTY 通常会回流大量
|
|
9991
|
+
// 原地重绘序列(CSI A-D / J / K / H / f)。maybeScheduleResyncForChunk
|
|
9992
|
+
// 已经在收到这类 chunk 时做节流 resync,但 wterm 状态机偶尔会漏抓
|
|
9993
|
+
// (比如 Codex 的菜单切换),导致 DOM 行残留 / 错位 —— 表现就是用户
|
|
9994
|
+
// 反馈"按方向键之后画面错位,必须按一下右上角缩放才恢复"。
|
|
9995
|
+
// 这里在每次快捷键点击之后安排一次延迟的 softResyncTerminal 兜底,
|
|
9996
|
+
// 等价于自动按一次缩放按钮:reset 状态机 + 重放 buffer,把残留的
|
|
9997
|
+
// 错位洗掉。延迟 ~500ms 是为了让服务端先把这次按键的回执完整推过来,
|
|
9998
|
+
// 避免 resync 时只回放到 chunk 一半。
|
|
9999
|
+
function scheduleShortcutResync() {
|
|
10000
|
+
if (!state.terminal) return;
|
|
10001
|
+
scheduleSoftResyncTerminal(500);
|
|
9656
10002
|
}
|
|
9657
10003
|
|
|
9658
10004
|
function updateKeyboardPopupUI() {
|
|
@@ -9771,8 +10117,20 @@
|
|
|
9771
10117
|
|
|
9772
10118
|
// Send queued messages in order, bypassing the session-running check
|
|
9773
10119
|
// since our local state may be stale right after reconnect
|
|
9774
|
-
var
|
|
10120
|
+
var now = Date.now();
|
|
10121
|
+
var queue = [];
|
|
10122
|
+
var dropped = 0;
|
|
10123
|
+
state.pendingMessages.forEach(function(item) {
|
|
10124
|
+
// Backward-compatible: 老逻辑里 entries 可能是裸字符串。
|
|
10125
|
+
if (typeof item === "string") { queue.push(item); return; }
|
|
10126
|
+
if (!item || typeof item.input !== "string") return;
|
|
10127
|
+
if (now - (item.at || 0) > PENDING_INPUT_TTL_MS) { dropped++; return; }
|
|
10128
|
+
queue.push(item.input);
|
|
10129
|
+
});
|
|
9775
10130
|
state.pendingMessages = [];
|
|
10131
|
+
if (dropped > 0) {
|
|
10132
|
+
console.log("[wand] flushPendingMessages: dropped " + dropped + " stale input(s)");
|
|
10133
|
+
}
|
|
9776
10134
|
|
|
9777
10135
|
var sendPromise = Promise.resolve();
|
|
9778
10136
|
queue.forEach(function(input) {
|
|
@@ -9786,7 +10144,10 @@
|
|
|
9786
10144
|
|
|
9787
10145
|
function sendInputDirect(input) {
|
|
9788
10146
|
if (!input || !state.selectedId) return Promise.resolve();
|
|
9789
|
-
|
|
10147
|
+
// 同 postInput:flushPendingMessages 重连后批量回放离线消息时,
|
|
10148
|
+
// 用户可能已在切到别的会话,必须用本次请求的 sessionId 快照。
|
|
10149
|
+
var requestSessionId = state.selectedId;
|
|
10150
|
+
return fetch("/api/sessions/" + requestSessionId + "/input", {
|
|
9790
10151
|
method: "POST",
|
|
9791
10152
|
headers: { "Content-Type": "application/json" },
|
|
9792
10153
|
credentials: "same-origin",
|
|
@@ -9801,7 +10162,7 @@
|
|
|
9801
10162
|
// on the user's next message, and stale queue items would cause duplicates
|
|
9802
10163
|
if (isSessionUnavailableError(error)) {
|
|
9803
10164
|
console.log("[wand] sendInputDirect: session unavailable, dropping", {
|
|
9804
|
-
sessionId:
|
|
10165
|
+
sessionId: requestSessionId,
|
|
9805
10166
|
errorCode: error.errorCode
|
|
9806
10167
|
});
|
|
9807
10168
|
return null;
|
|
@@ -9814,10 +10175,13 @@
|
|
|
9814
10175
|
.then(function(snapshot) {
|
|
9815
10176
|
if (snapshot && snapshot.id) {
|
|
9816
10177
|
updateSessionSnapshot(snapshot);
|
|
9817
|
-
|
|
9818
|
-
|
|
10178
|
+
// 仅当 snapshot 仍属当前选中会话时才覆盖视图,否则只更新底层数据。
|
|
10179
|
+
if (snapshot.id === state.selectedId) {
|
|
10180
|
+
if (snapshot.messages && snapshot.messages.length > 0) {
|
|
10181
|
+
state.currentMessages = snapshot.messages;
|
|
10182
|
+
}
|
|
10183
|
+
renderChat(true);
|
|
9819
10184
|
}
|
|
9820
|
-
renderChat(true);
|
|
9821
10185
|
}
|
|
9822
10186
|
return snapshot;
|
|
9823
10187
|
});
|
|
@@ -9946,12 +10310,12 @@
|
|
|
9946
10310
|
method: "POST",
|
|
9947
10311
|
headers: { "Content-Type": "application/json" },
|
|
9948
10312
|
credentials: "same-origin",
|
|
9949
|
-
body: JSON.stringify({
|
|
10313
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
9950
10314
|
command: command,
|
|
9951
10315
|
cwd: cwd || "",
|
|
9952
10316
|
mode: state.chatMode || state.config.defaultMode || "default",
|
|
9953
10317
|
model: modelPref || undefined
|
|
9954
|
-
})
|
|
10318
|
+
}))
|
|
9955
10319
|
})
|
|
9956
10320
|
.then(function(res) { return res.json(); })
|
|
9957
10321
|
.then(function(data) {
|
|
@@ -9976,9 +10340,9 @@
|
|
|
9976
10340
|
method: "POST",
|
|
9977
10341
|
headers: { "Content-Type": "application/json" },
|
|
9978
10342
|
credentials: "same-origin",
|
|
9979
|
-
body: JSON.stringify({
|
|
10343
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
9980
10344
|
mode: state.chatMode || state.config.defaultMode || "default"
|
|
9981
|
-
})
|
|
10345
|
+
}))
|
|
9982
10346
|
})
|
|
9983
10347
|
.then(function(res) { return res.json(); })
|
|
9984
10348
|
.then(function(data) {
|
|
@@ -10007,9 +10371,9 @@
|
|
|
10007
10371
|
method: "POST",
|
|
10008
10372
|
headers: { "Content-Type": "application/json" },
|
|
10009
10373
|
credentials: "same-origin",
|
|
10010
|
-
body: JSON.stringify({
|
|
10374
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
10011
10375
|
mode: state.chatMode || state.config.defaultMode || "default"
|
|
10012
|
-
})
|
|
10376
|
+
}))
|
|
10013
10377
|
})
|
|
10014
10378
|
.then(function(res) { return res.json(); })
|
|
10015
10379
|
.then(function(data) {
|
|
@@ -10087,13 +10451,13 @@
|
|
|
10087
10451
|
method: "POST",
|
|
10088
10452
|
headers: { "Content-Type": "application/json" },
|
|
10089
10453
|
credentials: "same-origin",
|
|
10090
|
-
body: JSON.stringify({
|
|
10454
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
10091
10455
|
command: preferredTool,
|
|
10092
10456
|
cwd: defaultCwd,
|
|
10093
10457
|
mode: mode,
|
|
10094
10458
|
initialInput: value,
|
|
10095
10459
|
model: modelPref || undefined
|
|
10096
|
-
})
|
|
10460
|
+
}))
|
|
10097
10461
|
})
|
|
10098
10462
|
.then(function(res) { return res.json(); })
|
|
10099
10463
|
.then(function(data) {
|
|
@@ -10125,13 +10489,13 @@
|
|
|
10125
10489
|
method: "POST",
|
|
10126
10490
|
headers: { "Content-Type": "application/json" },
|
|
10127
10491
|
credentials: "same-origin",
|
|
10128
|
-
body: JSON.stringify({
|
|
10492
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
10129
10493
|
command: preferredTool,
|
|
10130
10494
|
cwd: defaultCwd,
|
|
10131
10495
|
mode: mode,
|
|
10132
10496
|
initialInput: value || undefined,
|
|
10133
10497
|
model: modelPref || undefined
|
|
10134
|
-
})
|
|
10498
|
+
}))
|
|
10135
10499
|
})
|
|
10136
10500
|
.then(function(res) { return res.json(); })
|
|
10137
10501
|
.then(function(data) {
|
|
@@ -10187,10 +10551,10 @@
|
|
|
10187
10551
|
method: "POST",
|
|
10188
10552
|
headers: { "Content-Type": "application/json" },
|
|
10189
10553
|
credentials: "same-origin",
|
|
10190
|
-
body: JSON.stringify({
|
|
10554
|
+
body: JSON.stringify(withTerminalDimensions({
|
|
10191
10555
|
mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
|
|
10192
10556
|
cwd: cwd
|
|
10193
|
-
})
|
|
10557
|
+
}))
|
|
10194
10558
|
})
|
|
10195
10559
|
.then(function(res) { return res.json(); })
|
|
10196
10560
|
.then(function(data) {
|
|
@@ -11300,11 +11664,31 @@
|
|
|
11300
11664
|
if (wrap.parentNode === output) output.removeChild(wrap);
|
|
11301
11665
|
}
|
|
11302
11666
|
}
|
|
11667
|
+
// widePadAnsi 是模块级状态机,跨终端实例时若卡在 esc/csi/string 等
|
|
11668
|
+
// 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
|
|
11669
|
+
// ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
|
|
11670
|
+
resetWideParserState();
|
|
11303
11671
|
state.terminalSessionId = null;
|
|
11304
11672
|
state.terminalOutput = "";
|
|
11305
11673
|
state.terminalAutoFollow = true;
|
|
11306
11674
|
state.showTerminalJumpToBottom = false;
|
|
11307
11675
|
updateTerminalJumpToBottomButton();
|
|
11676
|
+
// 清理本轮新增的、依赖当前 wterm 实例的模块级 timer 和频次统计。
|
|
11677
|
+
// 不清掉的话,旧会话上挂起的 tail timer 在新 wterm 实例上触发会
|
|
11678
|
+
// 用 state.terminalOutput 做一次无意义的 resync;resyncStats 计数
|
|
11679
|
+
// 跨会话累加也会让告警阈值在新会话立即触发误报。
|
|
11680
|
+
if (state.softResyncTimer) {
|
|
11681
|
+
clearTimeout(state.softResyncTimer);
|
|
11682
|
+
state.softResyncTimer = null;
|
|
11683
|
+
}
|
|
11684
|
+
if (_resyncChunkTailTimer) {
|
|
11685
|
+
clearTimeout(_resyncChunkTailTimer);
|
|
11686
|
+
_resyncChunkTailTimer = null;
|
|
11687
|
+
}
|
|
11688
|
+
_resyncChunkLastAt = 0;
|
|
11689
|
+
_resyncStatsWindowStart = 0;
|
|
11690
|
+
_resyncStatsCount = 0;
|
|
11691
|
+
_resyncLastWarnAt = 0;
|
|
11308
11692
|
}
|
|
11309
11693
|
|
|
11310
11694
|
function sendTerminalResize(cols, rows) {
|
|
@@ -11523,6 +11907,9 @@
|
|
|
11523
11907
|
// starts the ladder from 500ms again.
|
|
11524
11908
|
state.wsReconnectAttempts = 0;
|
|
11525
11909
|
cancelWsReconnect();
|
|
11910
|
+
// Server's per-client output sequence counter restarts on every
|
|
11911
|
+
// new socket; clear ours so the first init isn't treated as a gap.
|
|
11912
|
+
state.lastSeqBySession = {};
|
|
11526
11913
|
// Subscribe to current session if any
|
|
11527
11914
|
subscribeToSession(state.selectedId);
|
|
11528
11915
|
// Flush pending messages after reconnection
|
|
@@ -11539,6 +11926,43 @@
|
|
|
11539
11926
|
ws.onmessage = function(event) {
|
|
11540
11927
|
try {
|
|
11541
11928
|
var msg = JSON.parse(event.data);
|
|
11929
|
+
if (msg && msg.type === "resync_required" && msg.sessionId) {
|
|
11930
|
+
// Server dropped some output events under backpressure and
|
|
11931
|
+
// is asking us for a fresh snapshot. Send a resync so the
|
|
11932
|
+
// server replies with a new init carrying the full output.
|
|
11933
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
11934
|
+
try {
|
|
11935
|
+
state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
|
|
11936
|
+
} catch (sendErr) { /* ignore */ }
|
|
11937
|
+
}
|
|
11938
|
+
if (!state.lastSeqBySession) state.lastSeqBySession = {};
|
|
11939
|
+
state.lastSeqBySession[msg.sessionId] = 0;
|
|
11940
|
+
return;
|
|
11941
|
+
}
|
|
11942
|
+
if (msg && (msg.type === "init" || msg.type === "output") && msg.sessionId && typeof msg.seq === "number") {
|
|
11943
|
+
if (!state.lastSeqBySession) state.lastSeqBySession = {};
|
|
11944
|
+
var prevSeq = state.lastSeqBySession[msg.sessionId] || 0;
|
|
11945
|
+
if (msg.type === "init") {
|
|
11946
|
+
state.lastSeqBySession[msg.sessionId] = msg.seq;
|
|
11947
|
+
} else if (msg.seq === prevSeq + 1) {
|
|
11948
|
+
state.lastSeqBySession[msg.sessionId] = msg.seq;
|
|
11949
|
+
} else if (msg.seq > prevSeq + 1 && prevSeq > 0) {
|
|
11950
|
+
// We missed at least one event — request a resync and
|
|
11951
|
+
// skip this stale event so we don't apply a partial gap.
|
|
11952
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
11953
|
+
try {
|
|
11954
|
+
state.ws.send(JSON.stringify({ type: "resync", sessionId: msg.sessionId }));
|
|
11955
|
+
} catch (sendErr) { /* ignore */ }
|
|
11956
|
+
}
|
|
11957
|
+
state.lastSeqBySession[msg.sessionId] = 0;
|
|
11958
|
+
return;
|
|
11959
|
+
} else {
|
|
11960
|
+
// seq <= prevSeq: duplicate or out-of-order from a stale
|
|
11961
|
+
// queue; drop quietly.
|
|
11962
|
+
if (msg.seq < prevSeq) return;
|
|
11963
|
+
state.lastSeqBySession[msg.sessionId] = msg.seq;
|
|
11964
|
+
}
|
|
11965
|
+
}
|
|
11542
11966
|
handleWebSocketMessage(msg);
|
|
11543
11967
|
} catch (e) {
|
|
11544
11968
|
// Ignore parse errors
|
|
@@ -11569,6 +11993,30 @@
|
|
|
11569
11993
|
case 'output':
|
|
11570
11994
|
// Update session output (for terminal display and local message parsing)
|
|
11571
11995
|
// NOTE: For structured sessions, output may be "" during streaming — check messages too
|
|
11996
|
+
// thinking → idle 边界自愈:桥接层在 output.chat 事件里把 isResponding
|
|
11997
|
+
// 透传过来。当某会话由 true 变 false(assistant 完成一轮响应)时,
|
|
11998
|
+
// 主动做一次 softResyncTerminal —— 等价于自动按一次右上角缩放按钮,
|
|
11999
|
+
// 把 Claude/Codex 流式渲染中残留的错位光标定位序列洗掉。
|
|
12000
|
+
// 用 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 触发多次重放。
|
|
12001
|
+
if (msg.data && msg.sessionId
|
|
12002
|
+
&& Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
|
|
12003
|
+
if (!state._lastIsResponding) state._lastIsResponding = {};
|
|
12004
|
+
var _prevResp = !!state._lastIsResponding[msg.sessionId];
|
|
12005
|
+
var _nextResp = !!msg.data.isResponding;
|
|
12006
|
+
state._lastIsResponding[msg.sessionId] = _nextResp;
|
|
12007
|
+
if (_prevResp && !_nextResp
|
|
12008
|
+
&& msg.sessionId === state.selectedId
|
|
12009
|
+
&& state.terminal
|
|
12010
|
+
&& state.terminalOutput) {
|
|
12011
|
+
if (state._idleResyncTimer) clearTimeout(state._idleResyncTimer);
|
|
12012
|
+
var _idleResyncSid = msg.sessionId;
|
|
12013
|
+
state._idleResyncTimer = setTimeout(function() {
|
|
12014
|
+
state._idleResyncTimer = null;
|
|
12015
|
+
if (state.selectedId !== _idleResyncSid) return;
|
|
12016
|
+
try { softResyncTerminal({ skipFit: true }); } catch (e) {}
|
|
12017
|
+
}, 120);
|
|
12018
|
+
}
|
|
12019
|
+
}
|
|
11572
12020
|
if (msg.data && msg.sessionId) {
|
|
11573
12021
|
var isIncremental = !!msg.data.incremental;
|
|
11574
12022
|
var snapshot = { id: msg.sessionId };
|
|
@@ -11642,7 +12090,7 @@
|
|
|
11642
12090
|
}
|
|
11643
12091
|
// Real-time terminal output
|
|
11644
12092
|
if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
|
|
11645
|
-
if (msg.data.chunk && (
|
|
12093
|
+
if (msg.data.chunk && isCurrentTerminalSession(msg.sessionId)) {
|
|
11646
12094
|
// Fast path: write chunk directly to avoid full-output comparison.
|
|
11647
12095
|
state.lastChunkAt = Date.now();
|
|
11648
12096
|
state.terminalLiveStreamSessions[msg.sessionId] = true;
|
|
@@ -11656,9 +12104,9 @@
|
|
|
11656
12104
|
maybeScheduleResyncForChunk(msg.data.chunk);
|
|
11657
12105
|
state.terminalSessionId = msg.sessionId;
|
|
11658
12106
|
if (msg.data.output) {
|
|
11659
|
-
state.terminalOutput = normalizeTerminalOutput(msg.data.output);
|
|
12107
|
+
state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
|
|
11660
12108
|
} else {
|
|
11661
|
-
state.terminalOutput = (state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk);
|
|
12109
|
+
state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
|
|
11662
12110
|
}
|
|
11663
12111
|
maybeScrollTerminalToBottom("output");
|
|
11664
12112
|
updateTerminalJumpToBottomButton();
|
|
@@ -12183,7 +12631,9 @@
|
|
|
12183
12631
|
var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
12184
12632
|
var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
|
|
12185
12633
|
&& selectedForDelay.sessionKind !== "structured";
|
|
12186
|
-
|
|
12634
|
+
// 活跃流时拉到 CHAT_RENDER_LIVE_MS 减少高频重渲;空闲时用 IDLE 快速响应。
|
|
12635
|
+
// 旧实现里这两档在 setTimeout 调用时被覆盖成固定 30ms,分档逻辑形同虚设。
|
|
12636
|
+
var delay = isActiveStream ? CHAT_RENDER_LIVE_MS : CHAT_RENDER_IDLE_MS;
|
|
12187
12637
|
chatRenderTimer = setTimeout(function() {
|
|
12188
12638
|
chatRenderTimer = null;
|
|
12189
12639
|
var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
@@ -12191,7 +12641,7 @@
|
|
|
12191
12641
|
state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
|
|
12192
12642
|
}
|
|
12193
12643
|
renderChat();
|
|
12194
|
-
},
|
|
12644
|
+
}, delay);
|
|
12195
12645
|
}
|
|
12196
12646
|
|
|
12197
12647
|
// Extract system info from PTY output that's not in structured messages
|
|
@@ -15029,6 +15479,21 @@
|
|
|
15029
15479
|
var _progressSyncTimers = {};
|
|
15030
15480
|
var _PROGRESS_SYNC_DEBOUNCE_MS = 300;
|
|
15031
15481
|
|
|
15482
|
+
// Strip markdown formatting and clamp to a single short line so the
|
|
15483
|
+
// native Live Activity / lock-screen card stays readable. 100 chars
|
|
15484
|
+
// matches getLastAssistantSummary; OPPO truncates harder anyway.
|
|
15485
|
+
function _compactNotificationText(text) {
|
|
15486
|
+
if (!text) return "";
|
|
15487
|
+
var t = String(text)
|
|
15488
|
+
.replace(/^#+\s+/gm, "")
|
|
15489
|
+
.replace(/\*\*/g, "")
|
|
15490
|
+
.replace(/`/g, "")
|
|
15491
|
+
.trim();
|
|
15492
|
+
var firstLine = t.split("\n")[0].trim();
|
|
15493
|
+
if (firstLine.length > 100) firstLine = firstLine.slice(0, 100) + "…";
|
|
15494
|
+
return firstLine;
|
|
15495
|
+
}
|
|
15496
|
+
|
|
15032
15497
|
function syncSessionProgressToNative(sessionId) {
|
|
15033
15498
|
if (!_hasNativeBridge || typeof WandNative.updateSessionProgress !== "function") return;
|
|
15034
15499
|
if (!sessionId) return;
|
|
@@ -15054,21 +15519,56 @@
|
|
|
15054
15519
|
return;
|
|
15055
15520
|
}
|
|
15056
15521
|
|
|
15057
|
-
// Get latest todos from session messages
|
|
15522
|
+
// Get latest todos from session messages, plus the most recent user
|
|
15523
|
+
// prompt and assistant text in the same scan. sessionLabel is frozen
|
|
15524
|
+
// to the first prompt (session.summary), so without these fields the
|
|
15525
|
+
// OPPO Live Activity / lock-screen card stays stuck on round-1 text
|
|
15526
|
+
// forever. We carry the latest round across so native can refresh.
|
|
15058
15527
|
var todos = null;
|
|
15528
|
+
var latestUserText = "";
|
|
15529
|
+
var latestAssistantText = "";
|
|
15059
15530
|
var messages = session.messages || [];
|
|
15060
15531
|
for (var i = messages.length - 1; i >= 0; i--) {
|
|
15061
15532
|
var msg = messages[i];
|
|
15062
15533
|
if (!msg.content || !Array.isArray(msg.content)) continue;
|
|
15063
|
-
|
|
15064
|
-
|
|
15065
|
-
|
|
15066
|
-
|
|
15067
|
-
|
|
15068
|
-
|
|
15534
|
+
|
|
15535
|
+
if (!latestAssistantText && msg.role === "assistant") {
|
|
15536
|
+
for (var ai = msg.content.length - 1; ai >= 0; ai--) {
|
|
15537
|
+
var ablock = msg.content[ai];
|
|
15538
|
+
if (ablock && ablock.type === "text" && ablock.text && ablock.text.trim()) {
|
|
15539
|
+
latestAssistantText = _compactNotificationText(ablock.text);
|
|
15540
|
+
break;
|
|
15541
|
+
}
|
|
15069
15542
|
}
|
|
15070
15543
|
}
|
|
15071
|
-
|
|
15544
|
+
|
|
15545
|
+
if (!latestUserText && msg.role === "user") {
|
|
15546
|
+
// Skip queued / synthetic placeholder turns — they don't represent
|
|
15547
|
+
// user-visible "I just asked this" prompts.
|
|
15548
|
+
var isPlaceholder = msg.content.some(function(b) { return b && b.__queued; });
|
|
15549
|
+
if (!isPlaceholder) {
|
|
15550
|
+
for (var ui = 0; ui < msg.content.length; ui++) {
|
|
15551
|
+
var ublock = msg.content[ui];
|
|
15552
|
+
if (ublock && ublock.type === "text" && ublock.text && ublock.text.trim()) {
|
|
15553
|
+
latestUserText = _compactNotificationText(ublock.text);
|
|
15554
|
+
break;
|
|
15555
|
+
}
|
|
15556
|
+
}
|
|
15557
|
+
}
|
|
15558
|
+
}
|
|
15559
|
+
|
|
15560
|
+
if (!todos) {
|
|
15561
|
+
for (var j = msg.content.length - 1; j >= 0; j--) {
|
|
15562
|
+
var block = msg.content[j];
|
|
15563
|
+
if (block && block.type === "tool_use" && block.name === "TodoWrite"
|
|
15564
|
+
&& block.input && block.input.todos) {
|
|
15565
|
+
todos = block.input.todos;
|
|
15566
|
+
break;
|
|
15567
|
+
}
|
|
15568
|
+
}
|
|
15569
|
+
}
|
|
15570
|
+
|
|
15571
|
+
if (todos && latestUserText && latestAssistantText) break;
|
|
15072
15572
|
}
|
|
15073
15573
|
|
|
15074
15574
|
// Get current task
|
|
@@ -15081,6 +15581,8 @@
|
|
|
15081
15581
|
sessionLabel: sessionLabel,
|
|
15082
15582
|
status: sessionStatus,
|
|
15083
15583
|
currentTask: currentTask,
|
|
15584
|
+
latestUserText: latestUserText,
|
|
15585
|
+
latestAssistantText: latestAssistantText,
|
|
15084
15586
|
todos: todos || []
|
|
15085
15587
|
};
|
|
15086
15588
|
|