@co0ontty/wand 1.21.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 +4 -9
- package/dist/claude-pty-bridge.js +6 -16
- package/dist/config.js +2 -0
- package/dist/process-manager.js +2 -2
- package/dist/pty-text-utils.d.ts +6 -0
- package/dist/pty-text-utils.js +6 -0
- package/dist/server-session-routes.js +9 -3
- package/dist/server.js +5 -1
- package/dist/session-logger.d.ts +3 -1
- package/dist/session-logger.js +29 -16
- package/dist/structured-session-manager.d.ts +33 -0
- package/dist/structured-session-manager.js +560 -28
- package/dist/types.d.ts +3 -1
- package/dist/web-ui/content/scripts.js +178 -133
- package/dist/ws-broadcast.d.ts +6 -0
- package/dist/ws-broadcast.js +25 -38
- package/package.json +2 -1
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type SessionKind = "pty" | "structured";
|
|
2
2
|
export type SessionCreateKind = "pty" | "structured";
|
|
3
3
|
export type SessionProvider = "claude" | "codex";
|
|
4
|
-
export type SessionRunner = "claude-cli" | "claude-cli-print" | "codex-cli-exec" | "pty";
|
|
4
|
+
export type SessionRunner = "claude-cli" | "claude-cli-print" | "claude-sdk" | "codex-cli-exec" | "pty";
|
|
5
5
|
export type ExecutionMode = "assist" | "agent" | "agent-max" | "default" | "auto-edit" | "full-access" | "native" | "managed";
|
|
6
6
|
export type AutonomyPolicy = "assist" | "agent" | "agent-max";
|
|
7
7
|
export type ApprovalPolicy = "ask-every-time" | "approve-once" | "remember-this-turn";
|
|
@@ -91,6 +91,8 @@ export interface WandConfig {
|
|
|
91
91
|
cardDefaults?: CardExpandDefaults;
|
|
92
92
|
/** 新建会话时默认使用的 Claude 模型(别名或完整 ID)。留空则不传 --model,由 claude 自行决定。 */
|
|
93
93
|
defaultModel?: string;
|
|
94
|
+
/** 结构化会话使用的 runner: "cli"(默认,spawn claude -p)或 "sdk"(@anthropic-ai/claude-agent-sdk)。 */
|
|
95
|
+
structuredRunner?: "cli" | "sdk";
|
|
94
96
|
}
|
|
95
97
|
export interface ClaudeModelInfo {
|
|
96
98
|
/** 传给 --model 的值(别名或完整模型 ID) */
|
|
@@ -2126,6 +2126,14 @@
|
|
|
2126
2126
|
'</div>' +
|
|
2127
2127
|
'</div>' +
|
|
2128
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>' +
|
|
2129
2137
|
'<div class="field">' +
|
|
2130
2138
|
'<label class="field-label" for="cfg-default-model">默认模型</label>' +
|
|
2131
2139
|
'<div class="settings-row-with-action">' +
|
|
@@ -3093,11 +3101,14 @@
|
|
|
3093
3101
|
// Code blocks with syntax highlighting
|
|
3094
3102
|
escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
|
|
3095
3103
|
var highlighted = highlightCodePreview(code.trim(), lang);
|
|
3096
|
-
|
|
3104
|
+
var protectedHighlighted = highlighted.replace(/_/g, '_').replace(/\*/g, '*');
|
|
3105
|
+
return '<pre><code class="language-' + lang + '">' + protectedHighlighted + '</code></pre>';
|
|
3097
3106
|
});
|
|
3098
3107
|
|
|
3099
3108
|
// Inline code
|
|
3100
|
-
escaped = escaped.replace(/`([^`]+)`/g,
|
|
3109
|
+
escaped = escaped.replace(/`([^`]+)`/g, function(_, code) {
|
|
3110
|
+
return '<code>' + code.replace(/_/g, '_').replace(/\*/g, '*') + '</code>';
|
|
3111
|
+
});
|
|
3101
3112
|
|
|
3102
3113
|
// Headers
|
|
3103
3114
|
escaped = escaped.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
|
|
@@ -3111,9 +3122,9 @@
|
|
|
3111
3122
|
escaped = escaped.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
3112
3123
|
escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
3113
3124
|
escaped = escaped.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
3114
|
-
escaped = escaped.replace(/___(
|
|
3115
|
-
escaped = escaped.replace(/__(
|
|
3116
|
-
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>');
|
|
3117
3128
|
|
|
3118
3129
|
// Strikethrough
|
|
3119
3130
|
escaped = escaped.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
|
@@ -5413,40 +5424,17 @@
|
|
|
5413
5424
|
}
|
|
5414
5425
|
stripWideFillerForCopy();
|
|
5415
5426
|
|
|
5416
|
-
//
|
|
5417
|
-
//
|
|
5418
|
-
//
|
|
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 还没到期"的堵塞。
|
|
5427
|
+
// PTY 链路节流不变式:
|
|
5428
|
+
// 服务端 OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
|
|
5429
|
+
// RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
|
|
5430
|
+
// 违反这两条会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
|
|
5438
5431
|
var CHAT_RENDER_LIVE_MS = 150;
|
|
5439
5432
|
var CHAT_RENDER_IDLE_MS = 30;
|
|
5440
5433
|
|
|
5441
|
-
//
|
|
5442
|
-
//
|
|
5443
|
-
//
|
|
5444
|
-
//
|
|
5445
|
-
//
|
|
5446
|
-
// 服务端 record.output 用 appendWindow(..., 200_000) 限了 200KB,这里
|
|
5447
|
-
// 客户端给一个稍宽的上限做兜底;超过就按行边界裁掉头部,行边界处
|
|
5448
|
-
// ANSI 状态机一定是 idle 状态,重放结果与未裁等价。找不到行边界时
|
|
5449
|
-
// 退化到字节切,并避开 UTF-16 半截、ANSI 半截。
|
|
5434
|
+
// state.terminalOutput 仅作 softResyncTerminal 的重放源(wterm 有自己的
|
|
5435
|
+
// scrollback),所以必须限长,否则长跑会话每次 resync 都喂几 MB 给 wterm。
|
|
5436
|
+
// 裁切优先在行边界(ANSI 状态机此时一定 idle,重放等价),找不到再按字节切
|
|
5437
|
+
// 并避开 UTF-16 半截 / ANSI 半截。
|
|
5450
5438
|
var CLIENT_OUTPUT_MAX = 256 * 1024;
|
|
5451
5439
|
var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
|
|
5452
5440
|
function clampClientTerminalOutput(buf) {
|
|
@@ -5510,17 +5498,13 @@
|
|
|
5510
5498
|
resetWideParserState();
|
|
5511
5499
|
}
|
|
5512
5500
|
|
|
5513
|
-
//
|
|
5514
|
-
//
|
|
5515
|
-
//
|
|
5516
|
-
//
|
|
5517
|
-
//
|
|
5518
|
-
//
|
|
5519
|
-
//
|
|
5520
|
-
//
|
|
5521
|
-
// 重放整 buffer 而非截短:alt screen 切换 / 滚动区 / 字符集等模式开关
|
|
5522
|
-
// 依赖从 buffer 开头开始消费。从中间切会丢失这些状态机指令,治标变
|
|
5523
|
-
// 造反。H1 已经把 buffer 限到 256KB,对 wterm WASM 来说这是 ms 级开销。
|
|
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.
|
|
5524
5508
|
var _resyncStatsWindowStart = 0;
|
|
5525
5509
|
var _resyncStatsCount = 0;
|
|
5526
5510
|
var _resyncLastWarnAt = 0;
|
|
@@ -5566,22 +5550,13 @@
|
|
|
5566
5550
|
}, typeof delayMs === "number" ? delayMs : 150);
|
|
5567
5551
|
}
|
|
5568
5552
|
|
|
5569
|
-
// Claude CLI 的 permission 菜单 /
|
|
5570
|
-
//
|
|
5571
|
-
//
|
|
5572
|
-
// DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
|
|
5573
|
-
// 用户体感就是"明明在改菜单,结果跑到最上面去了"。
|
|
5574
|
-
//
|
|
5575
|
-
// 兜底策略是"重置 wterm 状态机 + 重放整 buffer"(softResyncTerminal)。
|
|
5576
|
-
// 这里的关键是触发时机:旧实现用 350ms debounce,但用户实际持续
|
|
5577
|
-
// 按方向键时,每次按键都会让 PTY 回流一次原地重绘 chunk,timer
|
|
5578
|
-
// 被反复 reset,永远等不到静默期,softResync 实际从不触发——
|
|
5579
|
-
// 这是这个保护机制的根本逻辑错误。
|
|
5553
|
+
// Claude CLI 的 permission 菜单 / 选择列表在方向键下会发原地重绘序列
|
|
5554
|
+
// (CSI A-D / J / K / H / f)。wterm 在这种高频原地重绘下 DOM 行容易残留
|
|
5555
|
+
// 或错位,必须用 softResyncTerminal 兜底。
|
|
5580
5556
|
//
|
|
5581
|
-
//
|
|
5582
|
-
//
|
|
5583
|
-
//
|
|
5584
|
-
// 时由尾巴 timer 收尾。不依赖按键停顿这种永远不发生的条件。
|
|
5557
|
+
// 触发用 leading + tail 节流而非 debounce:用户持续按键时每次 chunk 都会
|
|
5558
|
+
// reset debounce timer,永远等不到静默期。leading 立即 resync、窗口内
|
|
5559
|
+
// 用尾巴 timer 收尾,不依赖按键停顿。
|
|
5585
5560
|
var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
|
|
5586
5561
|
var RESYNC_THROTTLE_MS = 400;
|
|
5587
5562
|
var RESYNC_TAIL_MS = 350;
|
|
@@ -6204,7 +6179,7 @@
|
|
|
6204
6179
|
cwd: cwdOverride || getEffectiveCwd(),
|
|
6205
6180
|
mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
|
|
6206
6181
|
provider: provider,
|
|
6207
|
-
runner: provider === "codex" ? "codex-cli-exec" : (state.structuredRunner || "claude-cli-print"),
|
|
6182
|
+
runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
|
|
6208
6183
|
prompt: prompt || undefined,
|
|
6209
6184
|
worktreeEnabled: worktreeEnabled === true,
|
|
6210
6185
|
model: modelPref || undefined
|
|
@@ -6371,19 +6346,28 @@
|
|
|
6371
6346
|
return block && block.__processing;
|
|
6372
6347
|
});
|
|
6373
6348
|
})());
|
|
6374
|
-
var preserveLocalStructuredProgress = (localSession.sessionKind === "structured")
|
|
6375
|
-
&& !!localStructuredState
|
|
6376
|
-
&& localStructuredState.inFlight === true
|
|
6377
|
-
&& (!serverStructuredState || serverStructuredState.inFlight !== true)
|
|
6378
|
-
&& localHasPendingAssistant
|
|
6379
|
-
&& !!localStructuredState.activeRequestId
|
|
6380
|
-
&& (!serverStructuredState || !serverStructuredState.activeRequestId || serverStructuredState.activeRequestId === localStructuredState.activeRequestId);
|
|
6381
6349
|
var localMessages = Array.isArray(localSession.messages)
|
|
6382
6350
|
? (structuredSession ? stripRenderOnlyStructuredMessages(localSession.messages) : localSession.messages)
|
|
6383
6351
|
: [];
|
|
6384
6352
|
var serverMessages = Array.isArray(serverSession.messages)
|
|
6385
6353
|
? (structuredSession ? stripRenderOnlyStructuredMessages(serverSession.messages) : serverSession.messages)
|
|
6386
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;
|
|
6387
6371
|
var preserveLocalMessages = localMessages.length > serverMessages.length
|
|
6388
6372
|
|| (localMessages.length > 0 && serverMessages.length > 0
|
|
6389
6373
|
&& JSON.stringify(localMessages[localMessages.length - 1]) !== JSON.stringify(serverMessages[serverMessages.length - 1])
|
|
@@ -7479,6 +7463,9 @@
|
|
|
7479
7463
|
var langEl = document.getElementById("cfg-language");
|
|
7480
7464
|
if (langEl) langEl.value = cfg.language || "";
|
|
7481
7465
|
|
|
7466
|
+
var srEl = document.getElementById("cfg-structured-runner");
|
|
7467
|
+
if (srEl) srEl.value = cfg.structuredRunner || "cli";
|
|
7468
|
+
|
|
7482
7469
|
// Default model
|
|
7483
7470
|
state.configDefaultModel = cfg.defaultModel || "";
|
|
7484
7471
|
updateSettingsDefaultModelSelect();
|
|
@@ -7537,6 +7524,7 @@
|
|
|
7537
7524
|
shell: (document.getElementById("cfg-shell") || {}).value,
|
|
7538
7525
|
language: (document.getElementById("cfg-language") || {}).value || "",
|
|
7539
7526
|
defaultModel: (document.getElementById("cfg-default-model") || {}).value || "",
|
|
7527
|
+
structuredRunner: (document.getElementById("cfg-structured-runner") || {}).value || "cli",
|
|
7540
7528
|
};
|
|
7541
7529
|
|
|
7542
7530
|
var previousDefaultModel = (state.config && state.config.defaultModel) || "";
|
|
@@ -9288,6 +9276,9 @@
|
|
|
9288
9276
|
return Promise.resolve();
|
|
9289
9277
|
}
|
|
9290
9278
|
|
|
9279
|
+
// 防止同一会话并发提交(快速双击 / 重复触发)
|
|
9280
|
+
var _structuredSubmittingSessions = {};
|
|
9281
|
+
|
|
9291
9282
|
function postStructuredInput(input, inputBox, session) {
|
|
9292
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 });
|
|
9293
9284
|
if (!state.selectedId || !input) return Promise.resolve();
|
|
@@ -9295,6 +9286,11 @@
|
|
|
9295
9286
|
showToast("会话不存在,请重新选择或新建会话。", "error");
|
|
9296
9287
|
return Promise.resolve();
|
|
9297
9288
|
}
|
|
9289
|
+
// 同一会话的上一次提交尚未落地,直接忽略防止重复发送
|
|
9290
|
+
if (_structuredSubmittingSessions[session.id]) {
|
|
9291
|
+
console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
|
|
9292
|
+
return Promise.resolve();
|
|
9293
|
+
}
|
|
9298
9294
|
|
|
9299
9295
|
var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
|
|
9300
9296
|
// Immediately render user message with thinking indicator
|
|
@@ -9327,23 +9323,36 @@
|
|
|
9327
9323
|
// the HTTP response arrives.
|
|
9328
9324
|
var epochBeforePost = state.queueEpoch;
|
|
9329
9325
|
|
|
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
|
+
|
|
9330
9334
|
// 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
|
|
9331
9335
|
// 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
|
|
9336
|
+
_structuredSubmittingSessions[session.id] = true;
|
|
9332
9337
|
return fetch("/api/structured-sessions/" + session.id + "/messages", {
|
|
9333
9338
|
method: "POST",
|
|
9334
9339
|
headers: { "Content-Type": "application/json" },
|
|
9335
9340
|
credentials: "same-origin",
|
|
9336
|
-
body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
|
|
9341
|
+
body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined, idempotencyKey: idempotencyKey })
|
|
9337
9342
|
})
|
|
9338
9343
|
.then(function(res) {
|
|
9339
9344
|
if (!res.ok) {
|
|
9340
9345
|
return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
|
|
9341
|
-
|
|
9346
|
+
var err = new Error((payload && payload.error) || "无法发送结构化消息。");
|
|
9347
|
+
err.errorCode = payload && payload.errorCode;
|
|
9348
|
+
err.httpStatus = res.status;
|
|
9349
|
+
throw err;
|
|
9342
9350
|
});
|
|
9343
9351
|
}
|
|
9344
9352
|
return res.json();
|
|
9345
9353
|
})
|
|
9346
9354
|
.then(function(snapshot) {
|
|
9355
|
+
_structuredSubmittingSessions[session.id] = false;
|
|
9347
9356
|
if (snapshot && snapshot.error) {
|
|
9348
9357
|
throw new Error(snapshot.error);
|
|
9349
9358
|
}
|
|
@@ -9364,10 +9373,33 @@
|
|
|
9364
9373
|
}
|
|
9365
9374
|
})
|
|
9366
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);
|
|
9367
9390
|
updateSessionSnapshot({
|
|
9368
9391
|
id: session.id,
|
|
9392
|
+
status: session.status,
|
|
9393
|
+
messages: rollbackMsgs,
|
|
9369
9394
|
structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
|
|
9370
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
|
+
}
|
|
9371
9403
|
var message = (error && error.message) || "";
|
|
9372
9404
|
var isTransientAbort =
|
|
9373
9405
|
message === "Failed to fetch" ||
|
|
@@ -9559,12 +9591,8 @@
|
|
|
9559
9591
|
}, Promise.resolve());
|
|
9560
9592
|
}
|
|
9561
9593
|
|
|
9562
|
-
// pendingMessages
|
|
9563
|
-
//
|
|
9564
|
-
// 问题:用户连续按方向键时几秒就把队列填满;shift 把最早按下的丢
|
|
9565
|
-
// 掉,剩下的反而是后期按的——重连后回放的"输入序列"和用户实际
|
|
9566
|
-
// 按下的顺序矛盾。给每条消息打时间戳,flush 时直接丢弃过期项,
|
|
9567
|
-
// 让"离线超过 N 秒后重连"恢复成一个干净状态而不是错位重放。
|
|
9594
|
+
// pendingMessages 缓存 ws 离线时的输入,重连后回放。每条带时间戳,
|
|
9595
|
+
// flush 时丢弃过期项——离线 >TTL 后回放老按键序列只会让 PTY 错位。
|
|
9568
9596
|
var PENDING_INPUT_TTL_MS = 5000;
|
|
9569
9597
|
var PENDING_INPUT_MAX = 100;
|
|
9570
9598
|
function enqueuePendingInput(input) {
|
|
@@ -9634,13 +9662,6 @@
|
|
|
9634
9662
|
return Promise.resolve();
|
|
9635
9663
|
}
|
|
9636
9664
|
|
|
9637
|
-
console.log("[wand] postInput: sending", {
|
|
9638
|
-
sessionId: state.selectedId,
|
|
9639
|
-
inputLength: input.length,
|
|
9640
|
-
view: effectiveView,
|
|
9641
|
-
wsConnected: state.wsConnected
|
|
9642
|
-
});
|
|
9643
|
-
|
|
9644
9665
|
return fetch("/api/sessions/" + requestSessionId + "/input", {
|
|
9645
9666
|
method: "POST",
|
|
9646
9667
|
headers: { "Content-Type": "application/json" },
|
|
@@ -9908,25 +9929,22 @@
|
|
|
9908
9929
|
: "Enter 发送 · Shift+Enter 换行";
|
|
9909
9930
|
}
|
|
9910
9931
|
}
|
|
9911
|
-
|
|
9912
|
-
//
|
|
9913
|
-
// 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
|
|
9932
|
+
// 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),输入框/发送按钮
|
|
9933
|
+
// 就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
|
|
9914
9934
|
var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
|
|
9915
9935
|
if (composer) {
|
|
9916
9936
|
composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
|
|
9917
|
-
composer.disabled = structured
|
|
9937
|
+
composer.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
|
|
9918
9938
|
composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
|
|
9919
|
-
//
|
|
9920
|
-
//
|
|
9921
|
-
//
|
|
9922
|
-
// 发到了 PTY、输入框里也留下了字"的双状态)。disabled 会让
|
|
9923
|
-
// textarea 失去焦点能力影响一些场景,readOnly 更轻、保留焦点。
|
|
9939
|
+
// 终端交互模式下按键由 document capture phase 透传到 PTY;用
|
|
9940
|
+
// readOnly 而非 disabled 防止 IME 组合输入等边界场景下字符同时
|
|
9941
|
+
// 落到 textarea,又保留 focus 能力。
|
|
9924
9942
|
composer.readOnly = !!state.terminalInteractive;
|
|
9925
9943
|
composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
|
|
9926
9944
|
}
|
|
9927
9945
|
var sendBtn = document.getElementById("send-input-button");
|
|
9928
9946
|
if (sendBtn) {
|
|
9929
|
-
sendBtn.disabled = structured
|
|
9947
|
+
sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
|
|
9930
9948
|
sendBtn.setAttribute("title", structured
|
|
9931
9949
|
? "发送"
|
|
9932
9950
|
: (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
|
|
@@ -9987,15 +10005,9 @@
|
|
|
9987
10005
|
scheduleShortcutResync();
|
|
9988
10006
|
}
|
|
9989
10007
|
|
|
9990
|
-
//
|
|
9991
|
-
//
|
|
9992
|
-
//
|
|
9993
|
-
// (比如 Codex 的菜单切换),导致 DOM 行残留 / 错位 —— 表现就是用户
|
|
9994
|
-
// 反馈"按方向键之后画面错位,必须按一下右上角缩放才恢复"。
|
|
9995
|
-
// 这里在每次快捷键点击之后安排一次延迟的 softResyncTerminal 兜底,
|
|
9996
|
-
// 等价于自动按一次缩放按钮:reset 状态机 + 重放 buffer,把残留的
|
|
9997
|
-
// 错位洗掉。延迟 ~500ms 是为了让服务端先把这次按键的回执完整推过来,
|
|
9998
|
-
// 避免 resync 时只回放到 chunk 一半。
|
|
10008
|
+
// 快捷键点击后做一次延迟 resync 兜底:maybeScheduleResyncForChunk 偶尔会漏
|
|
10009
|
+
// 抓 Codex 菜单切换之类的原地重绘,导致 DOM 行残留。500ms 是为了等服务端把
|
|
10010
|
+
// 本次按键的回执完整推过来,避免 resync 只回放到 chunk 一半。
|
|
9999
10011
|
function scheduleShortcutResync() {
|
|
10000
10012
|
if (!state.terminal) return;
|
|
10001
10013
|
scheduleSoftResyncTerminal(500);
|
|
@@ -10594,12 +10606,8 @@
|
|
|
10594
10606
|
}
|
|
10595
10607
|
|
|
10596
10608
|
function updateInputPanelViewportSpacing() {
|
|
10597
|
-
//
|
|
10598
|
-
//
|
|
10599
|
-
// panel 自身撑高、内部底部多出空白,textarea(panel 顶部)反而
|
|
10600
|
-
// 被往上推、离键盘更远。新方案改为让 body 高度跟随 visualViewport
|
|
10601
|
-
// 收缩(见 syncAppViewportHeight),input-panel 自然贴键盘上沿。
|
|
10602
|
-
// 这里清掉旧 keyboard-offset,避免新旧双重补偿。
|
|
10609
|
+
// 键盘空间通过 syncAppViewportHeight 让 body 跟随 visualViewport 收缩处理;
|
|
10610
|
+
// 这里清掉历史遗留的 --keyboard-offset 避免双重补偿。
|
|
10603
10611
|
var inputPanel = document.querySelector('.input-panel');
|
|
10604
10612
|
if (!inputPanel) return;
|
|
10605
10613
|
inputPanel.style.removeProperty('--keyboard-offset');
|
|
@@ -11991,13 +11999,10 @@
|
|
|
11991
11999
|
function handleWebSocketMessage(msg) {
|
|
11992
12000
|
switch (msg.type) {
|
|
11993
12001
|
case 'output':
|
|
11994
|
-
//
|
|
11995
|
-
//
|
|
11996
|
-
//
|
|
11997
|
-
//
|
|
11998
|
-
// 主动做一次 softResyncTerminal —— 等价于自动按一次右上角缩放按钮,
|
|
11999
|
-
// 把 Claude/Codex 流式渲染中残留的错位光标定位序列洗掉。
|
|
12000
|
-
// 用 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 触发多次重放。
|
|
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 多次重放。
|
|
12001
12006
|
if (msg.data && msg.sessionId
|
|
12002
12007
|
&& Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
|
|
12003
12008
|
if (!state._lastIsResponding) state._lastIsResponding = {};
|
|
@@ -12192,6 +12197,8 @@
|
|
|
12192
12197
|
|
|
12193
12198
|
if (isStructuredEnded && msg.sessionId === state.selectedId) {
|
|
12194
12199
|
flushStructuredInputQueue();
|
|
12200
|
+
// 结构化会话结束时也清 localStorage,防止下次加载恢复僵尸队列
|
|
12201
|
+
clearStructuredQueuePersistence(msg.sessionId);
|
|
12195
12202
|
} else if (!isStructuredEnded) {
|
|
12196
12203
|
state.structuredInputQueue = [];
|
|
12197
12204
|
clearStructuredQueuePersistence(state.selectedId);
|
|
@@ -12235,18 +12242,12 @@
|
|
|
12235
12242
|
renderChat(true);
|
|
12236
12243
|
updateTaskDisplay();
|
|
12237
12244
|
updateApprovalStats();
|
|
12238
|
-
//
|
|
12239
|
-
//
|
|
12240
|
-
//
|
|
12241
|
-
// 全量重写、全等就直接 return false——前者会把 alt-screen
|
|
12242
|
-
// 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
|
|
12243
|
-
// 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
|
|
12244
|
-
// 这是订阅时唯一可信的全量基线。
|
|
12245
|
+
// 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
|
|
12246
|
+
// 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
|
|
12247
|
+
// 可信的全量基线,避免 append 的 prefix 检查走错分支。
|
|
12245
12248
|
updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
|
|
12246
|
-
//
|
|
12247
|
-
//
|
|
12248
|
-
// ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
|
|
12249
|
-
// 一次,确保最终一定按真实宽度重排。
|
|
12249
|
+
// wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
|
|
12250
|
+
// 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
|
|
12250
12251
|
ensureTerminalFitWithRetry("init");
|
|
12251
12252
|
}
|
|
12252
12253
|
break;
|
|
@@ -12631,8 +12632,7 @@
|
|
|
12631
12632
|
var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
12632
12633
|
var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
|
|
12633
12634
|
&& selectedForDelay.sessionKind !== "structured";
|
|
12634
|
-
// 活跃流时拉到
|
|
12635
|
-
// 旧实现里这两档在 setTimeout 调用时被覆盖成固定 30ms,分档逻辑形同虚设。
|
|
12635
|
+
// 活跃流时拉到 LIVE 减少高频重渲;空闲时用 IDLE 快速响应。
|
|
12636
12636
|
var delay = isActiveStream ? CHAT_RENDER_LIVE_MS : CHAT_RENDER_IDLE_MS;
|
|
12637
12637
|
chatRenderTimer = setTimeout(function() {
|
|
12638
12638
|
chatRenderTimer = null;
|
|
@@ -15005,6 +15005,49 @@
|
|
|
15005
15005
|
return source;
|
|
15006
15006
|
}
|
|
15007
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
|
+
|
|
15008
15051
|
function replaceLinePrefix(source, marker, openTag, closeTag) {
|
|
15009
15052
|
return source.split(newline).map(function(line) {
|
|
15010
15053
|
if (line.indexOf(marker) !== 0) return line;
|
|
@@ -15066,12 +15109,13 @@
|
|
|
15066
15109
|
}
|
|
15067
15110
|
|
|
15068
15111
|
var highlighted = highlightCode(code.trim(), lang);
|
|
15112
|
+
var protectedHighlighted = highlighted.replace(/_/g, '_').replace(/\*/g, '*');
|
|
15069
15113
|
var replacement = '<div class="code-block">' +
|
|
15070
15114
|
'<div class="code-block-header">' +
|
|
15071
15115
|
'<span class="code-lang">' + (lang || "code") + '</span>' +
|
|
15072
15116
|
'<button class="code-copy">Copy</button>' +
|
|
15073
15117
|
'</div>' +
|
|
15074
|
-
'<pre><code>' +
|
|
15118
|
+
'<pre><code>' + protectedHighlighted + '</code></pre>' +
|
|
15075
15119
|
'</div>';
|
|
15076
15120
|
result = result.slice(0, start) + replacement + result.slice(endTag + 3);
|
|
15077
15121
|
pos = start + replacement.length;
|
|
@@ -15088,14 +15132,15 @@
|
|
|
15088
15132
|
continue;
|
|
15089
15133
|
}
|
|
15090
15134
|
var inlineCode = result.slice(inlineStart + 1, inlineEnd);
|
|
15091
|
-
var
|
|
15135
|
+
var protectedInlineCode = inlineCode.replace(/_/g, '_').replace(/\*/g, '*');
|
|
15136
|
+
var inlineReplacement = '<code class="code-inline">' + protectedInlineCode + '</code>';
|
|
15092
15137
|
result = result.slice(0, inlineStart) + inlineReplacement + result.slice(inlineEnd + 1);
|
|
15093
15138
|
pos = inlineStart + inlineReplacement.length;
|
|
15094
15139
|
}
|
|
15095
15140
|
|
|
15096
15141
|
result = replacePair(result, "**", '<strong>', '</strong>');
|
|
15097
15142
|
result = replacePair(result, "*", '<em>', '</em>');
|
|
15098
|
-
result =
|
|
15143
|
+
result = replaceUnderscoreEmphasis(result, '<em>', '</em>');
|
|
15099
15144
|
result = replaceLinePrefix(result, "### ", '<h3>', '</h3>');
|
|
15100
15145
|
result = replaceLinePrefix(result, "## ", '<h2>', '</h2>');
|
|
15101
15146
|
result = replaceLinePrefix(result, "# ", '<h1>', '</h1>');
|
package/dist/ws-broadcast.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export declare class WsBroadcastManager {
|
|
|
18
18
|
emitEvent(event: ProcessEvent): void;
|
|
19
19
|
/** Flush any pending debounced output for a session (e.g., before session close). */
|
|
20
20
|
flushOutput(sessionId: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Send an init/resync snapshot to a single client. Bumps the per-session
|
|
23
|
+
* sequence counter so the client can detect gaps between the init payload
|
|
24
|
+
* and the first incremental update.
|
|
25
|
+
*/
|
|
26
|
+
private sendInit;
|
|
21
27
|
private broadcast;
|
|
22
28
|
private processWsQueue;
|
|
23
29
|
private readSessionCookie;
|