@co0ontty/wand 1.29.3 → 1.30.0
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/process-manager.js +5 -2
- package/dist/server.js +4 -0
- package/dist/structured-session-manager.js +132 -24
- package/dist/types.d.ts +19 -0
- package/dist/web-ui/content/scripts.js +719 -137
- package/dist/web-ui/content/styles.css +860 -262
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/dist/web-ui/index.js +33 -3
- package/package.json +1 -1
|
@@ -86,7 +86,22 @@
|
|
|
86
86
|
terminalFitInProgress: false,
|
|
87
87
|
terminalSessionId: null,
|
|
88
88
|
terminalOutput: "",
|
|
89
|
+
// R8: /clear marker。Claude 的 /clear 不发任何 ANSI 清屏序列,它只
|
|
90
|
+
// 就地把对话框重画成空、把旧对话推进 scrollback。但 wand 的
|
|
91
|
+
// state.terminalOutput 是 append-only buffer,softResync 一触发就
|
|
92
|
+
// 把 /clear 之前的历史全部重放回 wterm(用户看到"/clear 后短暂闪
|
|
93
|
+
// 回旧内容")。marker 表示 buffer 里"用户上次 /clear 时刻的位置",
|
|
94
|
+
// softResync 只重放 slice(marker),从根上避免历史被重放。
|
|
95
|
+
terminalOutputMarker: 0,
|
|
89
96
|
terminalLiveStreamSessions: {},
|
|
97
|
+
// CSI ?2026h..l 同步输出缓冲:begin 时拿到 "\x1b[?2026h" 后开始缓冲,
|
|
98
|
+
// end 时拿到 "\x1b[?2026l" 一次性 flush 给 wterm。null 表示当前不在
|
|
99
|
+
// sync 包帧内。@wterm/core 0.1.8 不实现 sync output,begin/end 之间
|
|
100
|
+
// 每个 write 立即落到 grid + mark dirty —— 跨 server-debounce 窗口
|
|
101
|
+
// 时浏览器看到中间帧 + 触发 softResync 时状态机被打断,正是
|
|
102
|
+
// askuserquestion 菜单多份叠加的最强候选根因。
|
|
103
|
+
syncOutputBuffer: null,
|
|
104
|
+
syncOutputDeadline: 0,
|
|
90
105
|
lastChunkAt: 0,
|
|
91
106
|
terminalHealthTimer: null,
|
|
92
107
|
lastTerminalResyncAt: 0,
|
|
@@ -1148,14 +1163,24 @@
|
|
|
1148
1163
|
}
|
|
1149
1164
|
|
|
1150
1165
|
function restoreLoginSession() {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1166
|
+
// Probe an unauthenticated endpoint first so an anonymous visit
|
|
1167
|
+
// does not leave a noisy 401 on /api/config in DevTools.
|
|
1168
|
+
fetch("/api/session-check", { credentials: "same-origin" })
|
|
1169
|
+
.then(function(res) { return res.ok ? res.json() : { authed: false }; })
|
|
1170
|
+
.then(function(info) {
|
|
1171
|
+
if (!info || !info.authed) {
|
|
1154
1172
|
state.loginChecked = true;
|
|
1155
1173
|
render();
|
|
1156
1174
|
return null;
|
|
1157
1175
|
}
|
|
1158
|
-
return
|
|
1176
|
+
return fetch("/api/config", { credentials: "same-origin" }).then(function(res) {
|
|
1177
|
+
if (!res.ok) {
|
|
1178
|
+
state.loginChecked = true;
|
|
1179
|
+
render();
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
return res.json();
|
|
1183
|
+
});
|
|
1159
1184
|
})
|
|
1160
1185
|
.then(function(config) {
|
|
1161
1186
|
if (!config) return;
|
|
@@ -1286,6 +1311,7 @@
|
|
|
1286
1311
|
'<span class="shortcut-sep">·</span>' +
|
|
1287
1312
|
'<button class="shortcut-key" data-key="enter" type="button">↵</button>' +
|
|
1288
1313
|
'<button class="shortcut-key" data-key="ctrl_enter" type="button">C-↵</button>' +
|
|
1314
|
+
'<button class="shortcut-key" data-key="shift_tab" type="button" title="Shift+Tab(切换 plan / 自动接受 模式)">⇧⇥</button>' +
|
|
1289
1315
|
'<button class="shortcut-key" data-key="escape" type="button">Esc</button>';
|
|
1290
1316
|
}
|
|
1291
1317
|
|
|
@@ -1365,7 +1391,8 @@
|
|
|
1365
1391
|
'</div>' +
|
|
1366
1392
|
'<div class="login-subtitle">在浏览器中运行本机终端</div>' +
|
|
1367
1393
|
'</div>' +
|
|
1368
|
-
'<
|
|
1394
|
+
'<form id="login-form" class="login-body" autocomplete="on">' +
|
|
1395
|
+
'<input type="text" name="username" autocomplete="username" value="wand" tabindex="-1" aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none" readonly />' +
|
|
1369
1396
|
'<p class="login-hint">输入 Wand 访问密码以进入控制台。</p>' +
|
|
1370
1397
|
'<div class="field">' +
|
|
1371
1398
|
'<label class="field-label" for="password">密码</label>' +
|
|
@@ -1376,7 +1403,7 @@
|
|
|
1376
1403
|
'<p id="password-hint" class="hint">使用你在 Wand 中设置的访问密码。</p>' +
|
|
1377
1404
|
'<p id="login-error" class="error-message hidden" role="alert"></p>' +
|
|
1378
1405
|
'</div>' +
|
|
1379
|
-
'<button id="login-button" class="btn btn-primary btn-block">进入控制台</button>' +
|
|
1406
|
+
'<button id="login-button" type="submit" class="btn btn-primary btn-block">进入控制台</button>' +
|
|
1380
1407
|
(hasNativeSwitchServer() ?
|
|
1381
1408
|
'<button id="login-switch-server-button" class="btn btn-ghost btn-block login-switch-server" type="button">' +
|
|
1382
1409
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>' +
|
|
@@ -1384,7 +1411,7 @@
|
|
|
1384
1411
|
'</button>'
|
|
1385
1412
|
: ''
|
|
1386
1413
|
) +
|
|
1387
|
-
'</
|
|
1414
|
+
'</form>' +
|
|
1388
1415
|
'</div>' +
|
|
1389
1416
|
'</div>';
|
|
1390
1417
|
}
|
|
@@ -1437,12 +1464,13 @@
|
|
|
1437
1464
|
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="10 6 16 12 10 18"/><line x1="20" y1="5" x2="20" y2="19"/></svg>'
|
|
1438
1465
|
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="14 6 8 12 14 18"/><line x1="4" y1="5" x2="4" y2="19"/></svg>') +
|
|
1439
1466
|
'</button>' +
|
|
1467
|
+
'<button id="sidebar-close-fully-btn" class="btn btn-ghost btn-sm sidebar-close-fully" type="button" title="完全关闭侧栏" aria-label="完全关闭侧栏"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
|
|
1440
1468
|
'<button id="close-drawer-button" class="btn btn-ghost btn-icon sidebar-close drawer-close-btn" type="button" aria-label="关闭菜单"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
|
|
1441
1469
|
'</div>' +
|
|
1442
1470
|
'</div>' +
|
|
1443
1471
|
'<div class="sidebar-body">' +
|
|
1444
1472
|
'<div id="sessions-panel">' +
|
|
1445
|
-
'<div class="sessions-list" id="sessions-list">' +
|
|
1473
|
+
'<div class="sessions-list" id="sessions-list">' + renderSessionsListContent() + '</div>' +
|
|
1446
1474
|
'</div>' +
|
|
1447
1475
|
'</div>' +
|
|
1448
1476
|
'<div class="sidebar-footer">' +
|
|
@@ -2943,19 +2971,22 @@
|
|
|
2943
2971
|
'<p class="settings-card-desc">至少 6 个字符;保存后下次登录生效。</p>' +
|
|
2944
2972
|
'</div>' +
|
|
2945
2973
|
'</div>' +
|
|
2946
|
-
'<
|
|
2947
|
-
'<
|
|
2948
|
-
'<
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
'
|
|
2952
|
-
'<
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
'
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2974
|
+
'<form id="change-password-form" autocomplete="on" onsubmit="return false;">' +
|
|
2975
|
+
'<input type="text" name="username" autocomplete="username" value="wand" tabindex="-1" aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none" readonly />' +
|
|
2976
|
+
'<div class="field">' +
|
|
2977
|
+
'<label class="field-label" for="new-password">新密码</label>' +
|
|
2978
|
+
'<input id="new-password" type="password" class="field-input" placeholder="输入新密码(至少 6 个字符)" autocomplete="new-password" />' +
|
|
2979
|
+
'</div>' +
|
|
2980
|
+
'<div class="field">' +
|
|
2981
|
+
'<label class="field-label" for="confirm-password">确认密码</label>' +
|
|
2982
|
+
'<input id="confirm-password" type="password" class="field-input" placeholder="再次输入新密码" autocomplete="new-password" />' +
|
|
2983
|
+
'</div>' +
|
|
2984
|
+
'<div class="settings-card-actions">' +
|
|
2985
|
+
'<button id="save-password-button" class="btn btn-primary" type="submit">保存密码</button>' +
|
|
2986
|
+
'</div>' +
|
|
2987
|
+
'<p id="settings-error" class="error-message hidden"></p>' +
|
|
2988
|
+
'<p id="settings-success" class="hint settings-success-message hidden"></p>' +
|
|
2989
|
+
'</form>' +
|
|
2959
2990
|
'</div>' +
|
|
2960
2991
|
'<div class="settings-card">' +
|
|
2961
2992
|
'<div class="settings-card-head">' +
|
|
@@ -3091,6 +3122,48 @@
|
|
|
3091
3122
|
return groups.join("");
|
|
3092
3123
|
}
|
|
3093
3124
|
|
|
3125
|
+
function isSidebarNarrow() {
|
|
3126
|
+
return !!state.sidebarPinned && !isMobileLayout() && !!state.sidebarCollapsed;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function renderCollapsedSessionTiles() {
|
|
3130
|
+
var activeSessions = state.sessions.filter(function(s) { return !s.archived; });
|
|
3131
|
+
activeSessions.sort(function(a, b) {
|
|
3132
|
+
var ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
3133
|
+
var tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
3134
|
+
return ta - tb;
|
|
3135
|
+
});
|
|
3136
|
+
var recentHistorySessions = [];
|
|
3137
|
+
if (state.claudeHistoryLoaded) {
|
|
3138
|
+
var cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
3139
|
+
recentHistorySessions = getVisibleClaudeHistorySessions().filter(function(s) {
|
|
3140
|
+
return s.timestamp && new Date(s.timestamp).getTime() > cutoff;
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
if (activeSessions.length === 0 && recentHistorySessions.length === 0) {
|
|
3144
|
+
return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
|
|
3145
|
+
}
|
|
3146
|
+
var tiles = "";
|
|
3147
|
+
var idx = 0;
|
|
3148
|
+
activeSessions.forEach(function(s) {
|
|
3149
|
+
idx += 1;
|
|
3150
|
+
var activeCls = s.id === state.selectedId ? " active" : "";
|
|
3151
|
+
var title = s.summary || s.command || ("会话 " + idx);
|
|
3152
|
+
tiles += '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
|
|
3153
|
+
});
|
|
3154
|
+
recentHistorySessions.forEach(function(s) {
|
|
3155
|
+
idx += 1;
|
|
3156
|
+
var preview = s.firstUserMessage || "(空会话)";
|
|
3157
|
+
var title = preview + " · " + formatHistoryTime(s.timestamp);
|
|
3158
|
+
tiles += '<button class="sidebar-collapsed-tile history" type="button" data-collapsed-history-id="' + escapeHtml(s.claudeSessionId) + '" data-cwd="' + escapeHtml(s.cwd || "") + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
|
|
3159
|
+
});
|
|
3160
|
+
return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function renderSessionsListContent() {
|
|
3164
|
+
return isSidebarNarrow() ? renderCollapsedSessionTiles() : renderSessions();
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3094
3167
|
function renderSessionManageBar() {
|
|
3095
3168
|
if (!state.sessionsManageMode) {
|
|
3096
3169
|
return '<div class="session-manage-bar">' +
|
|
@@ -4874,6 +4947,32 @@
|
|
|
4874
4947
|
return "";
|
|
4875
4948
|
}
|
|
4876
4949
|
|
|
4950
|
+
/** Get the most recent user-sent text from messages (for narrow-strip hover bubble). */
|
|
4951
|
+
function getSessionLatestUserText(session) {
|
|
4952
|
+
var msgs = session && session.messages;
|
|
4953
|
+
if (!msgs || msgs.length === 0) return "";
|
|
4954
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
4955
|
+
var msg = msgs[i];
|
|
4956
|
+
if (!msg || msg.role !== "user") continue;
|
|
4957
|
+
var content = msg.content;
|
|
4958
|
+
if (typeof content === "string") {
|
|
4959
|
+
var t = content.trim();
|
|
4960
|
+
if (t) return t;
|
|
4961
|
+
continue;
|
|
4962
|
+
}
|
|
4963
|
+
if (Array.isArray(content)) {
|
|
4964
|
+
for (var j = 0; j < content.length; j++) {
|
|
4965
|
+
var block = content[j];
|
|
4966
|
+
if (!block || block.type !== "text" || !block.text) continue;
|
|
4967
|
+
if (block.__queued) continue;
|
|
4968
|
+
var bt = String(block.text).trim();
|
|
4969
|
+
if (bt) return bt;
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
return "";
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4877
4976
|
/** Get the last meaningful assistant text from messages for notification/display */
|
|
4878
4977
|
function getLastAssistantSummary(session) {
|
|
4879
4978
|
var msgs = session && session.messages;
|
|
@@ -5378,6 +5477,11 @@
|
|
|
5378
5477
|
var loginButton = document.getElementById("login-button");
|
|
5379
5478
|
if (loginButton) {
|
|
5380
5479
|
loginButton.addEventListener("click", login);
|
|
5480
|
+
var loginForm = document.getElementById("login-form");
|
|
5481
|
+
if (loginForm) loginForm.addEventListener("submit", function(e) {
|
|
5482
|
+
e.preventDefault();
|
|
5483
|
+
login();
|
|
5484
|
+
});
|
|
5381
5485
|
var loginSwitchServerBtn = document.getElementById("login-switch-server-button");
|
|
5382
5486
|
if (loginSwitchServerBtn) loginSwitchServerBtn.addEventListener("click", switchServer);
|
|
5383
5487
|
var passwordEl = document.getElementById("password");
|
|
@@ -5455,8 +5559,12 @@
|
|
|
5455
5559
|
if (sessionsList) {
|
|
5456
5560
|
sessionsList.addEventListener("click", handleSessionItemClick);
|
|
5457
5561
|
sessionsList.addEventListener("keydown", handleSessionItemKeydown);
|
|
5562
|
+
sessionsList.addEventListener("mouseover", handleCollapsedTileHover);
|
|
5563
|
+
sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
|
|
5458
5564
|
initSwipeToDelete(sessionsList);
|
|
5459
5565
|
}
|
|
5566
|
+
window.addEventListener("scroll", hideCollapsedTileBubble, true);
|
|
5567
|
+
window.addEventListener("resize", hideCollapsedTileBubble);
|
|
5460
5568
|
|
|
5461
5569
|
// Claude session ID badge click-to-copy (event delegation on document)
|
|
5462
5570
|
document.addEventListener("click", handleClaudeIdCopy);
|
|
@@ -5519,6 +5627,8 @@
|
|
|
5519
5627
|
if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
|
|
5520
5628
|
var collapseBtn = document.getElementById("sidebar-collapse-btn");
|
|
5521
5629
|
if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
|
|
5630
|
+
var closeFullyBtn = document.getElementById("sidebar-close-fully-btn");
|
|
5631
|
+
if (closeFullyBtn) closeFullyBtn.addEventListener("click", closeSidebarCompletely);
|
|
5522
5632
|
var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
|
|
5523
5633
|
var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
|
|
5524
5634
|
if (sidebarMoreBtn && sidebarOverflow) {
|
|
@@ -6540,6 +6650,34 @@
|
|
|
6540
6650
|
var target = event.target;
|
|
6541
6651
|
if (!target || !(target instanceof Element)) return;
|
|
6542
6652
|
|
|
6653
|
+
var collapsedTile = target.closest(".sidebar-collapsed-tile");
|
|
6654
|
+
if (collapsedTile && collapsedTile instanceof HTMLElement) {
|
|
6655
|
+
if (collapsedTile.dataset.collapsedSessionId) {
|
|
6656
|
+
event.preventDefault();
|
|
6657
|
+
event.stopPropagation();
|
|
6658
|
+
activateSessionItem(collapsedTile.dataset.collapsedSessionId);
|
|
6659
|
+
return;
|
|
6660
|
+
}
|
|
6661
|
+
if (collapsedTile.dataset.collapsedHistoryId) {
|
|
6662
|
+
event.preventDefault();
|
|
6663
|
+
event.stopPropagation();
|
|
6664
|
+
var historyCid = collapsedTile.dataset.collapsedHistoryId;
|
|
6665
|
+
var historyCwd = collapsedTile.dataset.cwd || "";
|
|
6666
|
+
resumeClaudeHistorySession(historyCid, historyCwd)
|
|
6667
|
+
.then(function(data) {
|
|
6668
|
+
if (data && data.id) {
|
|
6669
|
+
state.selectedId = data.id;
|
|
6670
|
+
persistSelectedId();
|
|
6671
|
+
state.drafts[data.id] = "";
|
|
6672
|
+
loadSessions().then(function() {
|
|
6673
|
+
selectSession(data.id);
|
|
6674
|
+
});
|
|
6675
|
+
}
|
|
6676
|
+
});
|
|
6677
|
+
return;
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
|
|
6543
6681
|
var historyToggle = target.closest("#claude-history-toggle");
|
|
6544
6682
|
if (historyToggle) {
|
|
6545
6683
|
event.preventDefault();
|
|
@@ -7088,10 +7226,93 @@
|
|
|
7088
7226
|
return out;
|
|
7089
7227
|
}
|
|
7090
7228
|
|
|
7229
|
+
// CSI ?2026 同步输出(DEC mode 2026)的 wand 软实现。Claude Code 用
|
|
7230
|
+
// \x1b[?2026h ... 重画 ... \x1b[?2026l
|
|
7231
|
+
// 包裹每一帧 askuserquestion / model / 任意菜单的原地重绘,期望终端
|
|
7232
|
+
// 在 end 之前不渲染中间态。@wterm/core 0.1.8 未实现 sync output,于是
|
|
7233
|
+
// 我们在 JS 层先 buffer,遇到 end 时一次性下发。这条修复直接解决
|
|
7234
|
+
// 菜单逐帧叠加(image 2 的主因)。
|
|
7235
|
+
//
|
|
7236
|
+
// 安全护栏:
|
|
7237
|
+
// - 单帧字节 > SYNC_OUTPUT_MAX_BYTES → 强制 flush(防 buffer 爆)
|
|
7238
|
+
// - 单帧滞留 > SYNC_OUTPUT_MAX_BUFFER_MS → 强制 flush(防 begin 没 end)
|
|
7239
|
+
// - 没有 ?2026 字节流时透传,零开销
|
|
7240
|
+
var SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
|
|
7241
|
+
var SYNC_OUTPUT_END = "\x1b[?2026l";
|
|
7242
|
+
var SYNC_OUTPUT_MAX_BUFFER_MS = 200;
|
|
7243
|
+
var SYNC_OUTPUT_MAX_BYTES = 256 * 1024;
|
|
7244
|
+
|
|
7245
|
+
function processSyncOutputFraming(data) {
|
|
7246
|
+
if (!data) return data;
|
|
7247
|
+
// 快路径:当前不在 sync 内、本批数据也不含 begin → 直接透传
|
|
7248
|
+
if (state.syncOutputBuffer === null && data.indexOf(SYNC_OUTPUT_BEGIN) === -1) {
|
|
7249
|
+
return data;
|
|
7250
|
+
}
|
|
7251
|
+
var out = "";
|
|
7252
|
+
var i = 0;
|
|
7253
|
+
while (i < data.length) {
|
|
7254
|
+
if (state.syncOutputBuffer !== null) {
|
|
7255
|
+
// 在 sync 内:扫到 end 才 flush
|
|
7256
|
+
var endIdx = data.indexOf(SYNC_OUTPUT_END, i);
|
|
7257
|
+
if (endIdx === -1) {
|
|
7258
|
+
state.syncOutputBuffer += data.slice(i);
|
|
7259
|
+
if (state.syncOutputBuffer.length > SYNC_OUTPUT_MAX_BYTES
|
|
7260
|
+
|| Date.now() > state.syncOutputDeadline) {
|
|
7261
|
+
// 护栏:超长/超时强制 flush,避免永久卡死
|
|
7262
|
+
out += state.syncOutputBuffer;
|
|
7263
|
+
state.syncOutputBuffer = null;
|
|
7264
|
+
}
|
|
7265
|
+
return out;
|
|
7266
|
+
}
|
|
7267
|
+
state.syncOutputBuffer += data.slice(i, endIdx + SYNC_OUTPUT_END.length);
|
|
7268
|
+
out += state.syncOutputBuffer;
|
|
7269
|
+
state.syncOutputBuffer = null;
|
|
7270
|
+
i = endIdx + SYNC_OUTPUT_END.length;
|
|
7271
|
+
} else {
|
|
7272
|
+
// 不在 sync 内:扫 begin
|
|
7273
|
+
var beginIdx = data.indexOf(SYNC_OUTPUT_BEGIN, i);
|
|
7274
|
+
if (beginIdx === -1) {
|
|
7275
|
+
out += data.slice(i);
|
|
7276
|
+
return out;
|
|
7277
|
+
}
|
|
7278
|
+
// begin 之前的字节立即透传给 wterm
|
|
7279
|
+
out += data.slice(i, beginIdx);
|
|
7280
|
+
state.syncOutputBuffer = SYNC_OUTPUT_BEGIN;
|
|
7281
|
+
state.syncOutputDeadline = Date.now() + SYNC_OUTPUT_MAX_BUFFER_MS;
|
|
7282
|
+
i = beginIdx + SYNC_OUTPUT_BEGIN.length;
|
|
7283
|
+
}
|
|
7284
|
+
}
|
|
7285
|
+
return out;
|
|
7286
|
+
}
|
|
7287
|
+
|
|
7288
|
+
function flushSyncOutputBuffer() {
|
|
7289
|
+
if (state.syncOutputBuffer !== null) {
|
|
7290
|
+
var buffered = state.syncOutputBuffer;
|
|
7291
|
+
state.syncOutputBuffer = null;
|
|
7292
|
+
return buffered;
|
|
7293
|
+
}
|
|
7294
|
+
return "";
|
|
7295
|
+
}
|
|
7296
|
+
|
|
7297
|
+
// NEW-B (DA1/XTVERSION 应答) 已暂缓:实测在 PTY ECHO 阶段(claude
|
|
7298
|
+
// 启动早期 / claude 不在 raw mode 的窗口)回灌的字节会被 PTY 自动
|
|
7299
|
+
// echo 到 stdout 并显示成 ^[[?6c^[P>|wterm-wand^[\ 字面字符,污染
|
|
7300
|
+
// 终端。需要先在服务端 ProcessManager 写到 PTY master 时识别 ECHO
|
|
7301
|
+
// 状态再决定是否回包,挪到 Phase 2 重新设计。
|
|
7302
|
+
|
|
7091
7303
|
function wandTerminalWrite(terminal, data) {
|
|
7092
7304
|
if (!terminal || data == null) return;
|
|
7093
7305
|
if (!state.wideParserState) state.wideParserState = createWideParserState();
|
|
7094
|
-
|
|
7306
|
+
var padded = widePadAnsi(data, state.wideParserState);
|
|
7307
|
+
var framed = processSyncOutputFraming(padded);
|
|
7308
|
+
if (framed) terminal.write(framed);
|
|
7309
|
+
// R6: 在 chunk 热路径上识别原地重绘序列(CSI nA/B/C/D/f/H/J/K),
|
|
7310
|
+
// 节流安排一次 softResync 兜底。Claude 用相对光标位移重画菜单时,
|
|
7311
|
+
// 如果 NEW-A 的 sync output buffer 因某种原因没拦截到完整帧(比如
|
|
7312
|
+
// ?2026 begin 之后跨 200ms 超时强制 flush),CSI 序列残留会让 wterm
|
|
7313
|
+
// 错位。此 fallback 仅在真出现错位序列时触发,正常输出零开销。
|
|
7314
|
+
// 与 R2 策略 A 配合:移除被动 5 处触发后,这是唯一的主动救场路径。
|
|
7315
|
+
maybeScheduleResyncForChunk(data);
|
|
7095
7316
|
// wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
|
|
7096
7317
|
// scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
|
|
7097
7318
|
// true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
|
|
@@ -7138,10 +7359,27 @@
|
|
|
7138
7359
|
// scrollback),所以必须限长,否则长跑会话每次 resync 都喂几 MB 给 wterm。
|
|
7139
7360
|
// 裁切优先在行边界(ANSI 状态机此时一定 idle,重放等价),找不到再按字节切
|
|
7140
7361
|
// 并避开 UTF-16 半截 / ANSI 半截。
|
|
7141
|
-
|
|
7142
|
-
|
|
7362
|
+
//
|
|
7363
|
+
// R10: 客户端 buffer 必须 < 服务端 PTY_OUTPUT_MAX_SIZE=200KB,否则长跑会话
|
|
7364
|
+
// 服务端先于客户端裁头,发 init 时携带的 output 是字节 ~56KB 起的尾段,
|
|
7365
|
+
// 与客户端本地 0..256KB 的完整 buffer 做 prefix 检查必然失败 → fall back
|
|
7366
|
+
// 到 replace 全量重写 → 每次 ws-reconnect / 切 tab 都踩一次 softResync 灾难。
|
|
7367
|
+
// 让 client < server 保证客户端永远是服务端的子集,prefix 永远成立。
|
|
7368
|
+
var CLIENT_OUTPUT_MAX = 160 * 1024;
|
|
7369
|
+
var CLIENT_OUTPUT_TRIM_AT = 192 * 1024;
|
|
7143
7370
|
function clampClientTerminalOutput(buf) {
|
|
7144
7371
|
if (!buf || buf.length <= CLIENT_OUTPUT_TRIM_AT) return buf;
|
|
7372
|
+
var preTrimLen = buf.length;
|
|
7373
|
+
// 内部 helper:根据裁掉的字节数同步缩减 marker,保证 marker 始终指向
|
|
7374
|
+
// "/clear 之后的字节"。如果 marker 落到了被裁掉的区间里,clamp 到 0
|
|
7375
|
+
// (/clear 之前的历史本来就要丢,marker=0 等于 fall back 重放全部)。
|
|
7376
|
+
var _adjustMarker = function(trimmedLen) {
|
|
7377
|
+
if (typeof state === "undefined" || !state) return;
|
|
7378
|
+
var mk = state.terminalOutputMarker | 0;
|
|
7379
|
+
if (mk <= 0) return;
|
|
7380
|
+
var dropped = preTrimLen - trimmedLen;
|
|
7381
|
+
state.terminalOutputMarker = mk > dropped ? mk - dropped : 0;
|
|
7382
|
+
};
|
|
7145
7383
|
var start = buf.length - CLIENT_OUTPUT_MAX;
|
|
7146
7384
|
// UTF-16 low surrogate
|
|
7147
7385
|
if (start > 0 && start < buf.length) {
|
|
@@ -7152,7 +7390,11 @@
|
|
|
7152
7390
|
var LOOKAHEAD = 4096;
|
|
7153
7391
|
var upper = Math.min(start + LOOKAHEAD, buf.length);
|
|
7154
7392
|
for (var i = start; i < upper; i++) {
|
|
7155
|
-
if (buf.charCodeAt(i) === 0x0a)
|
|
7393
|
+
if (buf.charCodeAt(i) === 0x0a) {
|
|
7394
|
+
var trimmed1 = buf.slice(i + 1);
|
|
7395
|
+
_adjustMarker(trimmed1.length);
|
|
7396
|
+
return trimmed1;
|
|
7397
|
+
}
|
|
7156
7398
|
}
|
|
7157
7399
|
// 没换行 → 检查 start 是否落在未结束的 ESC 序列里
|
|
7158
7400
|
var lookback = Math.max(0, start - 256);
|
|
@@ -7175,12 +7417,16 @@
|
|
|
7175
7417
|
for (var m = start; m < ahead; m++) {
|
|
7176
7418
|
var cm = buf.charCodeAt(m);
|
|
7177
7419
|
if (cm === 0x07 || (cm >= 0x40 && cm <= 0x7e)) {
|
|
7178
|
-
|
|
7420
|
+
var trimmed2 = buf.slice(m + 1);
|
|
7421
|
+
_adjustMarker(trimmed2.length);
|
|
7422
|
+
return trimmed2;
|
|
7179
7423
|
}
|
|
7180
7424
|
}
|
|
7181
7425
|
}
|
|
7182
7426
|
}
|
|
7183
|
-
|
|
7427
|
+
var trimmed3 = buf.slice(start);
|
|
7428
|
+
_adjustMarker(trimmed3.length);
|
|
7429
|
+
return trimmed3;
|
|
7184
7430
|
}
|
|
7185
7431
|
|
|
7186
7432
|
function resetTerminal() {
|
|
@@ -7193,12 +7439,16 @@
|
|
|
7193
7439
|
if (typeof state.terminal.reset === "function") {
|
|
7194
7440
|
state.terminal.reset();
|
|
7195
7441
|
resetWideParserState();
|
|
7442
|
+
state.syncOutputBuffer = null;
|
|
7443
|
+
state.syncOutputDeadline = 0;
|
|
7196
7444
|
return;
|
|
7197
7445
|
}
|
|
7198
7446
|
if (typeof state.terminal.write === "function") {
|
|
7199
7447
|
state.terminal.write("\x1bc");
|
|
7200
7448
|
}
|
|
7201
7449
|
resetWideParserState();
|
|
7450
|
+
state.syncOutputBuffer = null;
|
|
7451
|
+
state.syncOutputDeadline = 0;
|
|
7202
7452
|
}
|
|
7203
7453
|
|
|
7204
7454
|
// Reset wterm WASM grid and replay the full output buffer to clear stale
|
|
@@ -7217,10 +7467,17 @@
|
|
|
7217
7467
|
function softResyncTerminal(options) {
|
|
7218
7468
|
if (!state.terminal || !state.terminalOutput) return false;
|
|
7219
7469
|
var opts = options || {};
|
|
7220
|
-
|
|
7470
|
+
// R8: 只重放 marker 之后的字节。marker = 0 时等同于"重放整段"(与旧
|
|
7471
|
+
// 行为一致);用户输入过 /clear 后 marker 标到当时 buffer 长度,重放
|
|
7472
|
+
// 跳过 /clear 之前的历史,杜绝"/clear 后短暂闪回旧内容"。
|
|
7473
|
+
var marker = state.terminalOutputMarker | 0;
|
|
7474
|
+
if (marker < 0) marker = 0;
|
|
7475
|
+
if (marker > state.terminalOutput.length) marker = state.terminalOutput.length;
|
|
7476
|
+
var replaySource = marker > 0 ? state.terminalOutput.slice(marker) : state.terminalOutput;
|
|
7477
|
+
var bufLen = replaySource.length;
|
|
7221
7478
|
var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
|
|
7222
7479
|
resetTerminal();
|
|
7223
|
-
wandTerminalWrite(state.terminal,
|
|
7480
|
+
wandTerminalWrite(state.terminal, replaySource);
|
|
7224
7481
|
state.lastTerminalResyncAt = Date.now();
|
|
7225
7482
|
maybeScrollTerminalToBottom("output");
|
|
7226
7483
|
if (!opts.skipFit) ensureTerminalFit("refresh");
|
|
@@ -7260,9 +7517,14 @@
|
|
|
7260
7517
|
// 触发用 leading + tail 节流而非 debounce:用户持续按键时每次 chunk 都会
|
|
7261
7518
|
// reset debounce timer,永远等不到静默期。leading 立即 resync、窗口内
|
|
7262
7519
|
// 用尾巴 timer 收尾,不依赖按键停顿。
|
|
7520
|
+
// R6 chunk 热路径救场 throttle:原值 400/350 让 Claude thinking 期间
|
|
7521
|
+
// 大量 CSI A/B/K 重绘(spinner、状态行)每秒触发 ~2.5 次 softResync,
|
|
7522
|
+
// 13 次/5s 直接撞警戒线。NEW-A 已经把 askuserquestion 这种 ?2026 包帧
|
|
7523
|
+
// 重绘原子化,R6 退化为"NEW-A 失手时的弱兜底",频率拉到 1.5s/0.8s
|
|
7524
|
+
// 即可。代价:错位状态最长滞留 1.5s 才修,可接受。
|
|
7263
7525
|
var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
|
|
7264
|
-
var RESYNC_THROTTLE_MS =
|
|
7265
|
-
var RESYNC_TAIL_MS =
|
|
7526
|
+
var RESYNC_THROTTLE_MS = 1500;
|
|
7527
|
+
var RESYNC_TAIL_MS = 800;
|
|
7266
7528
|
var _resyncChunkLastAt = 0;
|
|
7267
7529
|
var _resyncChunkTailTimer = null;
|
|
7268
7530
|
function maybeScheduleResyncForChunk(chunk) {
|
|
@@ -7311,6 +7573,7 @@
|
|
|
7311
7573
|
resetTerminal();
|
|
7312
7574
|
currentOutput = "";
|
|
7313
7575
|
state.terminalOutput = "";
|
|
7576
|
+
state.terminalOutputMarker = 0; // R8: 切会话重置 /clear marker
|
|
7314
7577
|
state.terminalAutoFollow = true;
|
|
7315
7578
|
clearTerminalScrollIdleTimer();
|
|
7316
7579
|
updateTerminalJumpToBottomButton();
|
|
@@ -7353,6 +7616,11 @@
|
|
|
7353
7616
|
|
|
7354
7617
|
state.terminalSessionId = nextSessionId;
|
|
7355
7618
|
state.terminalOutput = normalizedOutput;
|
|
7619
|
+
// R8: syncTerminalBuffer 是整段 replace / sessionChanged 路径,旧
|
|
7620
|
+
// marker 已不属于新 buffer,重置为 0。append-delta 子路径(startsWith
|
|
7621
|
+
// 命中那条)虽然在 buffer 末尾延伸,但 normalizedOutput 也是延续值,
|
|
7622
|
+
// 把 marker 截到不超过新长度即可;为简单起见统一 reset 0。
|
|
7623
|
+
state.terminalOutputMarker = 0;
|
|
7356
7624
|
if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
|
|
7357
7625
|
maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
|
|
7358
7626
|
} else {
|
|
@@ -8147,13 +8415,19 @@
|
|
|
8147
8415
|
// redraw sequences from Claude CLI. When they appear or dismiss, schedule a
|
|
8148
8416
|
// debounced terminal resync so residual DOM rows get cleaned up automatically
|
|
8149
8417
|
// — same fix the user used to have to reach for via the refresh button.
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8418
|
+
// R2 策略 A:移除 permissionBlocked / pendingEscalation 翻转触发的
|
|
8419
|
+
// softResync。原本是为了"权限菜单消失后清掉残留 DOM 行",但 softResync
|
|
8420
|
+
// 全量重放在 fresh buffer 上会把 Claude 用相对位移画的菜单帧顺序堆叠
|
|
8421
|
+
// (截图 2 的根因之一)。NEW-A(CSI ?2026 同步输出缓冲)已经把菜单帧
|
|
8422
|
+
// 渲染原子化,R6(wandTerminalWrite 内的 maybeScheduleResyncForChunk)
|
|
8423
|
+
// 在出现原地重绘序列时兜底。这条翻转触发现在是多余且有害的。
|
|
8424
|
+
// var prevEsc = prevSession && prevSession.pendingEscalation ? 1 : 0;
|
|
8425
|
+
// var nextEsc = updatedSession && updatedSession.pendingEscalation ? 1 : 0;
|
|
8426
|
+
// var prevBlocked = prevSession && prevSession.permissionBlocked ? 1 : 0;
|
|
8427
|
+
// var nextBlocked = updatedSession && updatedSession.permissionBlocked ? 1 : 0;
|
|
8428
|
+
// if (prevEsc !== nextEsc || prevBlocked !== nextBlocked) {
|
|
8429
|
+
// scheduleSoftResyncTerminal(200);
|
|
8430
|
+
// }
|
|
8157
8431
|
}
|
|
8158
8432
|
// When a session transitions to a non-running state, try flushing cross-session queue
|
|
8159
8433
|
if (normalizedSnapshot.status && normalizedSnapshot.status !== "running" && state.crossSessionQueue.length > 0) {
|
|
@@ -8309,7 +8583,7 @@
|
|
|
8309
8583
|
updateSessionsList();
|
|
8310
8584
|
} else {
|
|
8311
8585
|
var listEl = document.getElementById("sessions-list");
|
|
8312
|
-
var rendered =
|
|
8586
|
+
var rendered = renderSessionsListContent();
|
|
8313
8587
|
if (listEl && listEl.innerHTML === rendered) {
|
|
8314
8588
|
var countEl = document.getElementById("session-count");
|
|
8315
8589
|
if (countEl) countEl.textContent = String(state.sessions.length);
|
|
@@ -8368,8 +8642,9 @@
|
|
|
8368
8642
|
function updateSessionsList() {
|
|
8369
8643
|
var listEl = document.getElementById("sessions-list");
|
|
8370
8644
|
var countEl = document.getElementById("session-count");
|
|
8371
|
-
if (listEl) listEl.innerHTML =
|
|
8645
|
+
if (listEl) listEl.innerHTML = renderSessionsListContent();
|
|
8372
8646
|
if (countEl) countEl.textContent = String(state.sessions.length);
|
|
8647
|
+
if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
|
|
8373
8648
|
updateShellChrome();
|
|
8374
8649
|
// Re-render cross-session queue (container may have been destroyed by DOM rebuild)
|
|
8375
8650
|
if (state.crossSessionQueue.length > 0) renderCrossSessionQueue();
|
|
@@ -8424,6 +8699,7 @@
|
|
|
8424
8699
|
if (!selectedSession) {
|
|
8425
8700
|
state.terminalSessionId = null;
|
|
8426
8701
|
state.terminalOutput = "";
|
|
8702
|
+
state.terminalOutputMarker = 0; // R8: 取消选中会话时重置 /clear marker
|
|
8427
8703
|
}
|
|
8428
8704
|
// 之前这里会用 selectedSession.output 再 syncTerminalBuffer 一次。
|
|
8429
8705
|
// 但 updateShellChrome 在 updateSessionsList、status 推送、init
|
|
@@ -8635,9 +8911,103 @@
|
|
|
8635
8911
|
updateLayoutState();
|
|
8636
8912
|
}
|
|
8637
8913
|
|
|
8914
|
+
var collapsedTileBubbleEl = null;
|
|
8915
|
+
function ensureCollapsedTileBubble() {
|
|
8916
|
+
if (collapsedTileBubbleEl && document.body.contains(collapsedTileBubbleEl)) {
|
|
8917
|
+
return collapsedTileBubbleEl;
|
|
8918
|
+
}
|
|
8919
|
+
collapsedTileBubbleEl = document.createElement("div");
|
|
8920
|
+
collapsedTileBubbleEl.className = "sidebar-tile-bubble";
|
|
8921
|
+
collapsedTileBubbleEl.setAttribute("role", "tooltip");
|
|
8922
|
+
document.body.appendChild(collapsedTileBubbleEl);
|
|
8923
|
+
return collapsedTileBubbleEl;
|
|
8924
|
+
}
|
|
8925
|
+
function hideCollapsedTileBubble() {
|
|
8926
|
+
if (collapsedTileBubbleEl) collapsedTileBubbleEl.classList.remove("visible");
|
|
8927
|
+
}
|
|
8928
|
+
function showCollapsedTileBubble(tile, text) {
|
|
8929
|
+
if (!text) { hideCollapsedTileBubble(); return; }
|
|
8930
|
+
var bubble = ensureCollapsedTileBubble();
|
|
8931
|
+
bubble.textContent = text.length > 400 ? text.slice(0, 400) + "…" : text;
|
|
8932
|
+
var rect = tile.getBoundingClientRect();
|
|
8933
|
+
bubble.classList.add("visible");
|
|
8934
|
+
// Measure after content set; clamp vertically to viewport.
|
|
8935
|
+
var bubbleRect = bubble.getBoundingClientRect();
|
|
8936
|
+
var centerY = rect.top + rect.height / 2;
|
|
8937
|
+
var top = centerY - bubbleRect.height / 2;
|
|
8938
|
+
var minTop = 8;
|
|
8939
|
+
var maxTop = window.innerHeight - bubbleRect.height - 8;
|
|
8940
|
+
if (top < minTop) top = minTop;
|
|
8941
|
+
if (top > maxTop) top = Math.max(minTop, maxTop);
|
|
8942
|
+
bubble.style.left = (rect.right + 12) + "px";
|
|
8943
|
+
bubble.style.top = top + "px";
|
|
8944
|
+
bubble.style.setProperty("--bubble-tail-y", (centerY - top) + "px");
|
|
8945
|
+
}
|
|
8946
|
+
function getCollapsedTileBubbleText(tile) {
|
|
8947
|
+
if (tile.dataset.collapsedSessionId) {
|
|
8948
|
+
var session = state.sessions.find(function(s) { return s.id === tile.dataset.collapsedSessionId; });
|
|
8949
|
+
if (!session) return "";
|
|
8950
|
+
var latest = getSessionLatestUserText(session);
|
|
8951
|
+
if (latest) return latest;
|
|
8952
|
+
return session.summary || session.command || "";
|
|
8953
|
+
}
|
|
8954
|
+
if (tile.dataset.collapsedHistoryId) {
|
|
8955
|
+
var hist = state.claudeHistory.find(function(s) { return s.claudeSessionId === tile.dataset.collapsedHistoryId; });
|
|
8956
|
+
if (hist && hist.firstUserMessage) return hist.firstUserMessage;
|
|
8957
|
+
}
|
|
8958
|
+
return "";
|
|
8959
|
+
}
|
|
8960
|
+
function handleCollapsedTileHover(event) {
|
|
8961
|
+
var target = event.target;
|
|
8962
|
+
if (!target || !(target instanceof Element)) return;
|
|
8963
|
+
var tile = target.closest(".sidebar-collapsed-tile");
|
|
8964
|
+
if (!tile) { hideCollapsedTileBubble(); return; }
|
|
8965
|
+
var text = getCollapsedTileBubbleText(tile);
|
|
8966
|
+
if (!text) { hideCollapsedTileBubble(); return; }
|
|
8967
|
+
showCollapsedTileBubble(tile, text);
|
|
8968
|
+
}
|
|
8969
|
+
function handleCollapsedTileLeave(event) {
|
|
8970
|
+
var related = event.relatedTarget;
|
|
8971
|
+
if (related && related instanceof Element && related.closest(".sidebar-collapsed-tile")) {
|
|
8972
|
+
return;
|
|
8973
|
+
}
|
|
8974
|
+
hideCollapsedTileBubble();
|
|
8975
|
+
}
|
|
8976
|
+
|
|
8977
|
+
function closeSidebarCompletely() {
|
|
8978
|
+
if (isMobileLayout()) return;
|
|
8979
|
+
state.sidebarPinned = false;
|
|
8980
|
+
state.sidebarCollapsed = false;
|
|
8981
|
+
state.sessionsDrawerOpen = false;
|
|
8982
|
+
try {
|
|
8983
|
+
localStorage.setItem("wand-sidebar-pinned", "false");
|
|
8984
|
+
localStorage.setItem("wand-sidebar-collapsed", "false");
|
|
8985
|
+
} catch (e) {}
|
|
8986
|
+
render();
|
|
8987
|
+
var mainLayout = document.querySelector(".main-layout");
|
|
8988
|
+
if (mainLayout) {
|
|
8989
|
+
var onEnd = function(e) {
|
|
8990
|
+
if (e.propertyName === "padding-left") {
|
|
8991
|
+
mainLayout.removeEventListener("transitionend", onEnd);
|
|
8992
|
+
scheduleTerminalResize(true);
|
|
8993
|
+
}
|
|
8994
|
+
};
|
|
8995
|
+
mainLayout.addEventListener("transitionend", onEnd);
|
|
8996
|
+
}
|
|
8997
|
+
setTimeout(function() { scheduleTerminalResize(true); }, 350);
|
|
8998
|
+
}
|
|
8999
|
+
|
|
8638
9000
|
function toggleSidebarCollapsed() {
|
|
8639
9001
|
if (isMobileLayout()) return;
|
|
8640
|
-
|
|
9002
|
+
// 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
|
|
9003
|
+
// 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
|
|
9004
|
+
if (!state.sidebarPinned) {
|
|
9005
|
+
state.sidebarPinned = true;
|
|
9006
|
+
state.sessionsDrawerOpen = true;
|
|
9007
|
+
try {
|
|
9008
|
+
localStorage.setItem("wand-sidebar-pinned", "true");
|
|
9009
|
+
} catch (e) {}
|
|
9010
|
+
}
|
|
8641
9011
|
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
8642
9012
|
try {
|
|
8643
9013
|
localStorage.setItem("wand-sidebar-collapsed", String(state.sidebarCollapsed));
|
|
@@ -10222,17 +10592,42 @@
|
|
|
10222
10592
|
return body;
|
|
10223
10593
|
}
|
|
10224
10594
|
|
|
10595
|
+
// 会话创建路径:保证 wterm 已经按真实容器尺寸校准,再向服务端 POST
|
|
10596
|
+
// 新会话——否则 withTerminalDimensions 拿不到 cols/rows,body 不带尺寸
|
|
10597
|
+
// → 服务端兜底 120/36 → Claude 按 120 列画 banner/box → wterm 实际渲
|
|
10598
|
+
// 染宽 ≠ 120 → 横线断行(图 1 现象)。带 2s 兜底超时,避免
|
|
10599
|
+
// initTerminal 失败时 UI 永久卡在"创建会话"按钮。
|
|
10600
|
+
function ensureTerminalReady() {
|
|
10601
|
+
if (state.terminal && state.terminal.cols) return Promise.resolve();
|
|
10602
|
+
return new Promise(function(resolve) {
|
|
10603
|
+
var done = false;
|
|
10604
|
+
var settle = function() { if (!done) { done = true; resolve(); } };
|
|
10605
|
+
var hardTimeout = setTimeout(settle, 2000);
|
|
10606
|
+
try { initTerminal(); } catch (e) {}
|
|
10607
|
+
requestAnimationFrame(function() {
|
|
10608
|
+
requestAnimationFrame(function() {
|
|
10609
|
+
if (state.terminal && state.terminal.cols) {
|
|
10610
|
+
clearTimeout(hardTimeout);
|
|
10611
|
+
settle();
|
|
10612
|
+
}
|
|
10613
|
+
});
|
|
10614
|
+
});
|
|
10615
|
+
});
|
|
10616
|
+
}
|
|
10617
|
+
|
|
10225
10618
|
function quickStartSession() {
|
|
10226
10619
|
var command = getPreferredTool();
|
|
10227
10620
|
var defaultCwd = getEffectiveCwd();
|
|
10228
10621
|
var defaultMode = getSafeModeForTool(command, (state.config && state.config.defaultMode) ? state.config.defaultMode : "default");
|
|
10229
10622
|
state.preferredCommand = command;
|
|
10230
10623
|
state.chatMode = getSafeModeForTool(command, state.chatMode);
|
|
10231
|
-
|
|
10624
|
+
ensureTerminalReady().then(function() {
|
|
10625
|
+
return fetch("/api/commands", {
|
|
10232
10626
|
method: "POST",
|
|
10233
10627
|
headers: { "Content-Type": "application/json" },
|
|
10234
10628
|
credentials: "same-origin",
|
|
10235
10629
|
body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
|
|
10630
|
+
});
|
|
10236
10631
|
})
|
|
10237
10632
|
.then(function(res) { return res.json(); })
|
|
10238
10633
|
.then(function(data) {
|
|
@@ -10309,7 +10704,8 @@
|
|
|
10309
10704
|
state.preferredCommand = command;
|
|
10310
10705
|
syncComposerModeSelect();
|
|
10311
10706
|
|
|
10312
|
-
|
|
10707
|
+
ensureTerminalReady().then(function() {
|
|
10708
|
+
return fetch("/api/commands", {
|
|
10313
10709
|
method: "POST",
|
|
10314
10710
|
headers: { "Content-Type": "application/json" },
|
|
10315
10711
|
credentials: "same-origin",
|
|
@@ -10320,6 +10716,7 @@
|
|
|
10320
10716
|
mode: mode,
|
|
10321
10717
|
worktreeEnabled: worktreeEnabled
|
|
10322
10718
|
}))
|
|
10719
|
+
});
|
|
10323
10720
|
})
|
|
10324
10721
|
.then(function(res) { return res.json(); })
|
|
10325
10722
|
.then(function(data) {
|
|
@@ -11813,8 +12210,26 @@
|
|
|
11813
12210
|
});
|
|
11814
12211
|
}
|
|
11815
12212
|
|
|
12213
|
+
// R8: 检测用户输入是否包含 /clear 命令,命中时把 marker 标到当前 buffer
|
|
12214
|
+
// 长度,下次 softResync 时就不会重放 /clear 之前的历史。
|
|
12215
|
+
// 检测点放在 queueDirectInput 是因为:所有用户 input(chat 框发送、终端
|
|
12216
|
+
// interactive 直写、shortcut 按键、bracketed paste 等)最终都汇到这条
|
|
12217
|
+
// 路径。先 strip bracketed-paste 包络(\x1b[200~ ... \x1b[201~)再做行首
|
|
12218
|
+
// 匹配,覆盖多种粘贴形式。
|
|
12219
|
+
function _detectAndMarkClear(input) {
|
|
12220
|
+
if (typeof input !== "string" || !input) return;
|
|
12221
|
+
var stripped = input.replace(/\x1b\[200~/g, "").replace(/\x1b\[201~/g, "");
|
|
12222
|
+
// 必须 /clear 在某一行起始位置,且后接 \r 或 \n 或行尾
|
|
12223
|
+
if (/(?:^|\n)\s*\/clear\s*(?:\r|\n|$)/.test(stripped)) {
|
|
12224
|
+
if (typeof state !== "undefined" && state) {
|
|
12225
|
+
state.terminalOutputMarker = (state.terminalOutput && state.terminalOutput.length) | 0;
|
|
12226
|
+
}
|
|
12227
|
+
}
|
|
12228
|
+
}
|
|
12229
|
+
|
|
11816
12230
|
function queueDirectInput(input, shortcutKey, viewOverride) {
|
|
11817
12231
|
if (!input || !state.selectedId) return Promise.resolve();
|
|
12232
|
+
_detectAndMarkClear(input);
|
|
11818
12233
|
state.messageQueue.push(input);
|
|
11819
12234
|
state.inputQueue = state.inputQueue.then(function() {
|
|
11820
12235
|
return postInput(input, shortcutKey, viewOverride).finally(function() {
|
|
@@ -11969,6 +12384,7 @@
|
|
|
11969
12384
|
var ptySpecialKeyMap = {
|
|
11970
12385
|
space: " ",
|
|
11971
12386
|
tab: String.fromCharCode(9),
|
|
12387
|
+
shift_tab: String.fromCharCode(27) + "[Z",
|
|
11972
12388
|
backspace: String.fromCharCode(127),
|
|
11973
12389
|
home: String.fromCharCode(27) + "[H",
|
|
11974
12390
|
end: String.fromCharCode(27) + "[F",
|
|
@@ -12026,7 +12442,9 @@
|
|
|
12026
12442
|
return {
|
|
12027
12443
|
ctrl: event.ctrlKey,
|
|
12028
12444
|
alt: event.altKey,
|
|
12029
|
-
|
|
12445
|
+
// 仅对单字符键保留 shift(控制 toUpperCase 路径),
|
|
12446
|
+
// 但 Tab 特例:物理 Shift+Tab 要走 buildPtySequence 的 back-tab 分支。
|
|
12447
|
+
shift: event.shiftKey && (key.length === 1 || key === "tab"),
|
|
12030
12448
|
meta: event.metaKey
|
|
12031
12449
|
};
|
|
12032
12450
|
}
|
|
@@ -12249,6 +12667,8 @@
|
|
|
12249
12667
|
function buildPtySequence(key, modifiers) {
|
|
12250
12668
|
var mods = modifiers || { ctrl: false, alt: false, shift: false };
|
|
12251
12669
|
if (isModifierKey(key)) return "";
|
|
12670
|
+
// Shift+Tab → CSI Z (back-tab)。Claude Code 用它在 plan / 自动接受 模式间切换。
|
|
12671
|
+
if (key === "tab" && mods.shift) return String.fromCharCode(27) + "[Z";
|
|
12252
12672
|
var specialSequence = getPtySpecialSequence(key);
|
|
12253
12673
|
if (specialSequence) return specialSequence;
|
|
12254
12674
|
if (key.indexOf("ctrl_") === 0) {
|
|
@@ -13910,8 +14330,13 @@
|
|
|
13910
14330
|
// 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
|
|
13911
14331
|
// ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
|
|
13912
14332
|
resetWideParserState();
|
|
14333
|
+
// sync output 缓冲跨会话也要清,否则旧会话最后没收完的 ?2026h 帧
|
|
14334
|
+
// 会让新会话的首批 PTY 字节全部被吞进 buffer 等永远不会来的 end。
|
|
14335
|
+
state.syncOutputBuffer = null;
|
|
14336
|
+
state.syncOutputDeadline = 0;
|
|
13913
14337
|
state.terminalSessionId = null;
|
|
13914
14338
|
state.terminalOutput = "";
|
|
14339
|
+
state.terminalOutputMarker = 0; // R8: teardown 时重置 /clear marker
|
|
13915
14340
|
state.terminalAutoFollow = true;
|
|
13916
14341
|
state.showTerminalJumpToBottom = false;
|
|
13917
14342
|
updateTerminalJumpToBottomButton();
|
|
@@ -13936,7 +14361,13 @@
|
|
|
13936
14361
|
function sendTerminalResize(cols, rows) {
|
|
13937
14362
|
if (!state.selectedId) return;
|
|
13938
14363
|
var selectedSess = state.sessions.find(function(s) { return s.id === state.selectedId; });
|
|
14364
|
+
// 会话已被清除(如服务重启后 localStorage 还残留旧 id),后端 resize 会
|
|
14365
|
+
// 直接 400/404,console 留一条红色错误;这里提前剪掉,避免噪音。
|
|
14366
|
+
if (!selectedSess || selectedSess.status !== "running") return;
|
|
13939
14367
|
if (isStructuredSession(selectedSess)) return;
|
|
14368
|
+
// wterm WASM grid 的 maxCols 硬编码 256。POST 给服务端的 cols 也同步
|
|
14369
|
+
// clamp,避免服务端 pty.resize 给 Claude 一个 wterm 实际渲不下的列宽。
|
|
14370
|
+
if (cols > 256) cols = 256;
|
|
13940
14371
|
var nextSize = { cols: cols, rows: rows };
|
|
13941
14372
|
if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
|
|
13942
14373
|
state.lastResize = nextSize;
|
|
@@ -14293,21 +14724,11 @@
|
|
|
14293
14724
|
if (msg.data && msg.sessionId
|
|
14294
14725
|
&& Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
|
|
14295
14726
|
if (!state._lastIsResponding) state._lastIsResponding = {};
|
|
14296
|
-
|
|
14297
|
-
|
|
14298
|
-
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
&& state.terminal
|
|
14302
|
-
&& state.terminalOutput) {
|
|
14303
|
-
if (state._idleResyncTimer) clearTimeout(state._idleResyncTimer);
|
|
14304
|
-
var _idleResyncSid = msg.sessionId;
|
|
14305
|
-
state._idleResyncTimer = setTimeout(function() {
|
|
14306
|
-
state._idleResyncTimer = null;
|
|
14307
|
-
if (state.selectedId !== _idleResyncSid) return;
|
|
14308
|
-
try { softResyncTerminal({ skipFit: true }); } catch (e) {}
|
|
14309
|
-
}, 120);
|
|
14310
|
-
}
|
|
14727
|
+
state._lastIsResponding[msg.sessionId] = !!msg.data.isResponding;
|
|
14728
|
+
// R2 策略 A:移除 isResponding true→false 翻转触发的 softResync。
|
|
14729
|
+
// 原本是想在"流式回答结束"瞬间洗掉错位的 cursor 定位残留,但
|
|
14730
|
+
// softResync 全量重放在 fresh buffer 上会把 askuserquestion 的多
|
|
14731
|
+
// 帧字节顺序堆叠(截图 2 的根因之一)。NEW-A + R6 兜底后不再需要。
|
|
14311
14732
|
}
|
|
14312
14733
|
if (msg.data && msg.sessionId) {
|
|
14313
14734
|
var isIncremental = !!msg.data.incremental;
|
|
@@ -14405,8 +14826,12 @@
|
|
|
14405
14826
|
// "被覆盖中间帧"反复塞进 scrollback。thinking→idle 兜底就够了。
|
|
14406
14827
|
state.terminalSessionId = msg.sessionId;
|
|
14407
14828
|
if (msg.data.output) {
|
|
14829
|
+
// R8: full output replace → marker 失效,重置为 0
|
|
14408
14830
|
state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
|
|
14831
|
+
state.terminalOutputMarker = 0;
|
|
14409
14832
|
} else {
|
|
14833
|
+
// append-delta:buffer 延续,marker 保持(clampClientTerminalOutput
|
|
14834
|
+
// 内部已经按裁掉字节数同步缩减 marker)
|
|
14410
14835
|
state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
|
|
14411
14836
|
}
|
|
14412
14837
|
maybeScrollTerminalToBottom("output");
|
|
@@ -16313,55 +16738,116 @@
|
|
|
16313
16738
|
}
|
|
16314
16739
|
|
|
16315
16740
|
// ── 像素风猫咪头像 ──
|
|
16316
|
-
|
|
16317
|
-
|
|
16318
|
-
|
|
16319
|
-
|
|
16320
|
-
|
|
16321
|
-
|
|
16322
|
-
|
|
16323
|
-
|
|
16324
|
-
|
|
16325
|
-
|
|
16326
|
-
|
|
16327
|
-
|
|
16741
|
+
// 统一的 10×10 猫咪 grid 模板:父 assistant = 加菲(橙),user = 美短(灰),
|
|
16742
|
+
// subagent = 一组按 taskId/agentType 哈希选色的备选 palette。同一模板让多个
|
|
16743
|
+
// 角色看起来是"同种生物的不同毛色",群聊感更自然。
|
|
16744
|
+
var _AVATAR_T = "transparent";
|
|
16745
|
+
function buildPixelSvg(grid, size) {
|
|
16746
|
+
var s = size || 3;
|
|
16747
|
+
var w = grid[0].length * s;
|
|
16748
|
+
var h = grid.length * s;
|
|
16749
|
+
var rects = "";
|
|
16750
|
+
for (var y = 0; y < grid.length; y++) {
|
|
16751
|
+
for (var x = 0; x < grid[y].length; x++) {
|
|
16752
|
+
if (grid[y][x] !== _AVATAR_T) {
|
|
16753
|
+
rects += '<rect x="' + (x * s) + '" y="' + (y * s) + '" width="' + s + '" height="' + s + '" fill="' + grid[y][x] + '"/>';
|
|
16328
16754
|
}
|
|
16329
16755
|
}
|
|
16330
|
-
|
|
16331
|
-
|
|
16332
|
-
|
|
16333
|
-
|
|
16334
|
-
|
|
16335
|
-
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
16339
|
-
|
|
16340
|
-
|
|
16341
|
-
|
|
16342
|
-
|
|
16343
|
-
|
|
16344
|
-
[
|
|
16345
|
-
|
|
16346
|
-
|
|
16347
|
-
|
|
16348
|
-
|
|
16349
|
-
[
|
|
16350
|
-
[
|
|
16351
|
-
[
|
|
16352
|
-
[
|
|
16353
|
-
[
|
|
16354
|
-
[g,g,g,g,p,p,g,g,g,g],
|
|
16355
|
-
[g,dg,g,lg,g,g,lg,g,dg,g],
|
|
16356
|
-
[_,g,g,g,g,g,g,g,g,_],
|
|
16357
|
-
[_,_,g,dg,g,g,dg,g,_,_],
|
|
16358
|
-
[_,_,_,g,_,_,g,_,_,_],
|
|
16756
|
+
}
|
|
16757
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + ' ' + h + '" class="pixel-avatar-svg">' + rects + '</svg>';
|
|
16758
|
+
}
|
|
16759
|
+
function buildCatGrid(palette) {
|
|
16760
|
+
// palette: { base, dark, light, accent, eye, mouth, nose }
|
|
16761
|
+
var T = _AVATAR_T;
|
|
16762
|
+
var b = palette.base;
|
|
16763
|
+
var d = palette.dark;
|
|
16764
|
+
var l = palette.light || palette.base;
|
|
16765
|
+
var w = palette.accent || "#FFFFFF";
|
|
16766
|
+
var k = palette.eye || "#2D2D2D";
|
|
16767
|
+
var p = palette.mouth || "#F28B9A";
|
|
16768
|
+
var n = palette.nose || palette.dark;
|
|
16769
|
+
return [
|
|
16770
|
+
[T,d,T,T,T,T,T,T,d,T],
|
|
16771
|
+
[d,b,d,T,T,T,T,d,b,d],
|
|
16772
|
+
[d,b,b,b,b,b,b,b,b,d],
|
|
16773
|
+
[b,b,w,k,b,b,w,k,b,b],
|
|
16774
|
+
[b,b,w,w,b,b,w,w,b,b],
|
|
16775
|
+
[b,b,b,b,p,p,b,b,b,b],
|
|
16776
|
+
[b,n,b,l,b,b,l,b,n,b],
|
|
16777
|
+
[T,b,b,b,b,b,b,b,b,T],
|
|
16778
|
+
[T,T,b,d,b,b,d,b,T,T],
|
|
16779
|
+
[T,T,T,b,T,T,b,T,T,T],
|
|
16359
16780
|
];
|
|
16360
|
-
|
|
16361
|
-
|
|
16362
|
-
|
|
16363
|
-
|
|
16364
|
-
}
|
|
16781
|
+
}
|
|
16782
|
+
var GARFIELD_PALETTE = {
|
|
16783
|
+
base: "#F0923A", dark: "#C46A1A", light: "#F0923A",
|
|
16784
|
+
accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", nose: "#E87D5A",
|
|
16785
|
+
};
|
|
16786
|
+
var SHORTHAIR_PALETTE = {
|
|
16787
|
+
base: "#9EAAB8", dark: "#6B7B8D", light: "#C5CED8",
|
|
16788
|
+
accent: "#FFFFFF", eye: "#7EC88B", mouth: "#F28B9A",
|
|
16789
|
+
};
|
|
16790
|
+
// 子 agent palette 池。色相与父/用户都拉开距离,避免群聊里多只猫颜色相近难辨认。
|
|
16791
|
+
// primary 用来暴露成 CSS 变量 --agent-color,给气泡左边框 / handoff 文字着色。
|
|
16792
|
+
var SUBAGENT_PALETTES = [
|
|
16793
|
+
{ base: "#5A8FE0", dark: "#2E5BB3", light: "#9CC0F2", accent: "#FFFFFF", eye: "#FFD66E", mouth: "#F28B9A", primary: "#5A8FE0" }, // 蓝猫
|
|
16794
|
+
{ base: "#A06FE0", dark: "#6B45A8", light: "#C8A4F2", accent: "#FFFFFF", eye: "#FFE36E", mouth: "#F28B9A", primary: "#A06FE0" }, // 紫猫
|
|
16795
|
+
{ base: "#7BB76B", dark: "#4F8A40", light: "#A9D49C", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", primary: "#7BB76B" }, // 抹茶猫
|
|
16796
|
+
{ base: "#D86A88", dark: "#9C3A57", light: "#E8A4B5", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#FFFFFF", primary: "#D86A88" }, // 樱花猫
|
|
16797
|
+
{ base: "#5BB7B0", dark: "#2E7873", light: "#9CD6D2", accent: "#FFFFFF", eye: "#FFD66E", mouth: "#F28B9A", primary: "#5BB7B0" }, // 青苔猫
|
|
16798
|
+
{ base: "#4A4A60", dark: "#1F1F2E", light: "#6E6E84", accent: "#F5F5F5", eye: "#FFD66E", mouth: "#F28B9A", primary: "#4A4A60" }, // 黑猫
|
|
16799
|
+
{ base: "#D8A85A", dark: "#9C7028", light: "#EBC78A", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", primary: "#D8A85A" }, // 焦糖猫
|
|
16800
|
+
];
|
|
16801
|
+
function hashStringToIndex(str, mod) {
|
|
16802
|
+
var s = String(str || "");
|
|
16803
|
+
var h = 0;
|
|
16804
|
+
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
16805
|
+
return Math.abs(h) % mod;
|
|
16806
|
+
}
|
|
16807
|
+
// agentType → 中文名映射。命中映射就用映射名,否则用 agentType 原文;
|
|
16808
|
+
// 都没有则退化为 "协作猫·<taskId 后 4 位>"。
|
|
16809
|
+
var SUBAGENT_NAME_MAP = {
|
|
16810
|
+
"general-purpose": "万能猫",
|
|
16811
|
+
"Explore": "侦探猫",
|
|
16812
|
+
"code-explorer": "侦探猫",
|
|
16813
|
+
"code-reviewer": "审查猫",
|
|
16814
|
+
"code-architect": "架构猫",
|
|
16815
|
+
"code-simplifier": "简化猫",
|
|
16816
|
+
"code-guide": "向导猫",
|
|
16817
|
+
"Plan": "策划猫",
|
|
16818
|
+
"feature-dev": "开发猫",
|
|
16819
|
+
"pr-test-analyzer": "测试猫",
|
|
16820
|
+
"silent-failure-hunter": "护卫猫",
|
|
16821
|
+
"type-design-analyzer": "类型猫",
|
|
16822
|
+
"comment-analyzer": "注释猫",
|
|
16823
|
+
};
|
|
16824
|
+
function getSubagentDisplayName(sub) {
|
|
16825
|
+
if (!sub) return "";
|
|
16826
|
+
var agentType = sub.agentType || "";
|
|
16827
|
+
if (agentType && SUBAGENT_NAME_MAP[agentType]) return SUBAGENT_NAME_MAP[agentType];
|
|
16828
|
+
if (agentType) return agentType;
|
|
16829
|
+
var tail = (sub.taskId || "").slice(-4) || "未知";
|
|
16830
|
+
return "协作猫·" + tail;
|
|
16831
|
+
}
|
|
16832
|
+
function getSubagentPalette(sub) {
|
|
16833
|
+
// 哈希优先用 agentType,让同类型 agent 跨 turn 颜色稳定;没有 agentType 时
|
|
16834
|
+
// 退化用 taskId,至少同 turn 内同一只猫颜色稳定。
|
|
16835
|
+
var seed = (sub && (sub.agentType || sub.taskId)) || "subagent";
|
|
16836
|
+
return SUBAGENT_PALETTES[hashStringToIndex(seed, SUBAGENT_PALETTES.length)];
|
|
16837
|
+
}
|
|
16838
|
+
function subagentAvatarHtml(sub) {
|
|
16839
|
+
var palette = getSubagentPalette(sub);
|
|
16840
|
+
var name = getSubagentDisplayName(sub);
|
|
16841
|
+
var svg = buildPixelSvg(buildCatGrid(palette));
|
|
16842
|
+
return '<div class="chat-message-avatar assistant subagent" style="--agent-color:' + palette.primary + '">' +
|
|
16843
|
+
'<div class="pixel-avatar">' + svg + '</div>' +
|
|
16844
|
+
'<span class="avatar-name">' + escapeHtml(name) + '</span>' +
|
|
16845
|
+
'</div>';
|
|
16846
|
+
}
|
|
16847
|
+
var PIXEL_AVATAR = {
|
|
16848
|
+
assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
|
|
16849
|
+
user: buildPixelSvg(buildCatGrid(SHORTHAIR_PALETTE)),
|
|
16850
|
+
};
|
|
16365
16851
|
|
|
16366
16852
|
var DEFAULT_CHAT_PERSONA = {
|
|
16367
16853
|
user: {
|
|
@@ -16590,57 +17076,137 @@
|
|
|
16590
17076
|
persistElementExpandState(el, "tool-group");
|
|
16591
17077
|
};
|
|
16592
17078
|
|
|
17079
|
+
// 把一条 assistant turn 按相邻 block 的 __subagent.taskId 切成段。
|
|
17080
|
+
// 输出每段附带原数组中的 firstIndex,方便渲染时 expand key 用全局 index
|
|
17081
|
+
// 避免不同段冲突。
|
|
17082
|
+
function splitTurnBySubagent(blocks) {
|
|
17083
|
+
var segs = [];
|
|
17084
|
+
if (!Array.isArray(blocks) || !blocks.length) return segs;
|
|
17085
|
+
var current = null;
|
|
17086
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
17087
|
+
var b = blocks[i];
|
|
17088
|
+
var sub = b && b.__subagent ? b.__subagent : null;
|
|
17089
|
+
var key = sub ? sub.taskId : null;
|
|
17090
|
+
if (!current || current.key !== key) {
|
|
17091
|
+
current = { key: key, subagent: sub, blocks: [], firstIndex: i };
|
|
17092
|
+
segs.push(current);
|
|
17093
|
+
}
|
|
17094
|
+
current.blocks.push(b);
|
|
17095
|
+
}
|
|
17096
|
+
return segs;
|
|
17097
|
+
}
|
|
17098
|
+
|
|
17099
|
+
// 渲染一段内的 blocks。独立 group consecutive tools,避免父/子 agent 的工具
|
|
17100
|
+
// 调用跨边界被合并;grp.index 偏移到原数组全局位置,保持 expand key 唯一。
|
|
17101
|
+
function buildSegmentBlocksHtml(segmentBlocks, segmentFirstIndex, role, toolResults, messageKey) {
|
|
17102
|
+
var html = "";
|
|
17103
|
+
try {
|
|
17104
|
+
var groups = groupConsecutiveTools(segmentBlocks);
|
|
17105
|
+
for (var g = 0; g < groups.length; g++) {
|
|
17106
|
+
var grp = groups[g];
|
|
17107
|
+
try {
|
|
17108
|
+
if (grp.type === "group") {
|
|
17109
|
+
var shifted = [];
|
|
17110
|
+
for (var k = 0; k < grp.items.length; k++) {
|
|
17111
|
+
shifted.push({ block: grp.items[k].block, index: grp.items[k].index + segmentFirstIndex });
|
|
17112
|
+
}
|
|
17113
|
+
html += renderToolGroup(shifted, role, toolResults, messageKey);
|
|
17114
|
+
} else {
|
|
17115
|
+
html += renderContentBlock(grp.block, role, toolResults, grp.index + segmentFirstIndex, messageKey);
|
|
17116
|
+
}
|
|
17117
|
+
} catch (e) {
|
|
17118
|
+
html += '<div class="render-error">消息块渲染失败</div>';
|
|
17119
|
+
}
|
|
17120
|
+
}
|
|
17121
|
+
} catch (e) {
|
|
17122
|
+
html += '<div class="render-error">消息渲染失败</div>';
|
|
17123
|
+
}
|
|
17124
|
+
return html;
|
|
17125
|
+
}
|
|
17126
|
+
|
|
16593
17127
|
function renderStructuredMessage(msg, roundUsage, messageIndex) {
|
|
16594
17128
|
var role = msg.role;
|
|
16595
|
-
var avatar = chatAvatar(role);
|
|
16596
17129
|
var messageKey = getMessageKey(msg, messageIndex);
|
|
16597
17130
|
|
|
16598
|
-
//
|
|
17131
|
+
// 排队中的用户消息标记(subagent 不会出现在 user role)
|
|
16599
17132
|
var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
|
|
16600
17133
|
|
|
16601
17134
|
if (!msg.content || msg.content.length === 0) {
|
|
16602
17135
|
if (role === "assistant") {
|
|
16603
17136
|
return '<div class="chat-message ' + role + '">' +
|
|
16604
|
-
|
|
17137
|
+
chatAvatar(role) +
|
|
16605
17138
|
'<div class="chat-message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>' +
|
|
16606
17139
|
'</div>';
|
|
16607
17140
|
}
|
|
16608
|
-
|
|
17141
|
+
// 空 user 消息(极少出现,但快速发送的边界场景会让消息"消失")。
|
|
17142
|
+
// 给个明确占位避免视觉断层。
|
|
17143
|
+
return '<div class="chat-message ' + role + ' empty-message" data-message-key="' + escapeHtml(messageKey) + '">' +
|
|
17144
|
+
chatAvatar(role) +
|
|
17145
|
+
'<div class="chat-message-content"><span class="empty-message-hint">(空消息)</span></div>' +
|
|
17146
|
+
'</div>';
|
|
16609
17147
|
}
|
|
16610
17148
|
|
|
16611
17149
|
var toolResults = buildToolResultMap(msg.content);
|
|
16612
|
-
var blocksHtml = "";
|
|
16613
17150
|
|
|
16614
|
-
|
|
16615
|
-
|
|
16616
|
-
|
|
16617
|
-
|
|
16618
|
-
|
|
16619
|
-
|
|
16620
|
-
|
|
16621
|
-
|
|
16622
|
-
blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index, messageKey);
|
|
16623
|
-
}
|
|
16624
|
-
} catch (e) {
|
|
16625
|
-
blocksHtml += '<div class="render-error">消息块渲染失败</div>';
|
|
16626
|
-
}
|
|
16627
|
-
}
|
|
16628
|
-
} catch (e) {
|
|
16629
|
-
return '<div class="chat-message ' + role + '">' +
|
|
16630
|
-
avatar +
|
|
16631
|
-
'<div class="chat-message-content"><div class="render-error">消息渲染失败</div></div>' +
|
|
17151
|
+
// user role 不会有 subagent,保持旧路径
|
|
17152
|
+
if (role !== "assistant") {
|
|
17153
|
+
var userHtml = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
|
|
17154
|
+
var queuedClass = isQueued ? " queued" : "";
|
|
17155
|
+
var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
|
|
17156
|
+
return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
|
|
17157
|
+
chatAvatar(role) +
|
|
17158
|
+
'<div class="chat-message-content">' + userHtml + queuedBadge + '</div>' +
|
|
16632
17159
|
'</div>';
|
|
16633
17160
|
}
|
|
16634
17161
|
|
|
16635
|
-
|
|
16636
|
-
var
|
|
16637
|
-
var
|
|
17162
|
+
// assistant:检测是否有 subagent 段,没有就走单段渲染(兼容老消息 / 无 subagent 的 turn)
|
|
17163
|
+
var segments = splitTurnBySubagent(msg.content);
|
|
17164
|
+
var hasSubagent = segments.some(function(s) { return s.subagent; });
|
|
16638
17165
|
|
|
16639
|
-
|
|
16640
|
-
|
|
16641
|
-
'<div class="chat-message
|
|
16642
|
-
|
|
16643
|
-
|
|
17166
|
+
if (!hasSubagent) {
|
|
17167
|
+
var html = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
|
|
17168
|
+
return '<div class="chat-message ' + role + '" data-message-key="' + escapeHtml(messageKey) + '">' +
|
|
17169
|
+
chatAvatar(role) +
|
|
17170
|
+
'<div class="chat-message-content">' + html + '</div>' +
|
|
17171
|
+
'</div>';
|
|
17172
|
+
}
|
|
17173
|
+
|
|
17174
|
+
// 多段:父 assistant 段 + 各 subagent 段。同一根 .chat-message 容器,
|
|
17175
|
+
// 内部多个 .chat-message-segment 子段,每段自带头像;切到新 subagent 时
|
|
17176
|
+
// 插入一行 handoff 提示("勤劳初二 ↳ 让 侦探猫 帮忙")。
|
|
17177
|
+
var parentPersona = getStructuredChatPersona("assistant");
|
|
17178
|
+
var multiHtml = '<div class="chat-message ' + role + ' multi-agent" data-message-key="' + escapeHtml(messageKey) + '">';
|
|
17179
|
+
var lastSubId = null;
|
|
17180
|
+
for (var si = 0; si < segments.length; si++) {
|
|
17181
|
+
var seg = segments[si];
|
|
17182
|
+
var segHtml = buildSegmentBlocksHtml(seg.blocks, seg.firstIndex, role, toolResults, messageKey);
|
|
17183
|
+
if (seg.subagent) {
|
|
17184
|
+
var subPalette = getSubagentPalette(seg.subagent);
|
|
17185
|
+
if (lastSubId !== seg.subagent.taskId) {
|
|
17186
|
+
var subName = getSubagentDisplayName(seg.subagent);
|
|
17187
|
+
var desc = seg.subagent.taskDescription
|
|
17188
|
+
? ':<span class="chat-handoff-desc">' + escapeHtml(seg.subagent.taskDescription) + '</span>'
|
|
17189
|
+
: '';
|
|
17190
|
+
multiHtml += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
|
|
17191
|
+
'<span class="chat-handoff-arrow">↳</span> ' +
|
|
17192
|
+
escapeHtml(parentPersona.name) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
|
|
17193
|
+
'</div>';
|
|
17194
|
+
}
|
|
17195
|
+
multiHtml += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +
|
|
17196
|
+
subagentAvatarHtml(seg.subagent) +
|
|
17197
|
+
'<div class="chat-message-content">' + segHtml + '</div>' +
|
|
17198
|
+
'</div>';
|
|
17199
|
+
lastSubId = seg.subagent.taskId;
|
|
17200
|
+
} else {
|
|
17201
|
+
multiHtml += '<div class="chat-message-segment parent">' +
|
|
17202
|
+
chatAvatar("assistant") +
|
|
17203
|
+
'<div class="chat-message-content">' + segHtml + '</div>' +
|
|
17204
|
+
'</div>';
|
|
17205
|
+
lastSubId = null;
|
|
17206
|
+
}
|
|
17207
|
+
}
|
|
17208
|
+
multiHtml += '</div>';
|
|
17209
|
+
return multiHtml;
|
|
16644
17210
|
}
|
|
16645
17211
|
function renderContentBlock(block, role, toolResults, index, messageKey) {
|
|
16646
17212
|
if (!block || !block.type) return "";
|
|
@@ -16682,10 +17248,26 @@
|
|
|
16682
17248
|
return rendered;
|
|
16683
17249
|
|
|
16684
17250
|
case "tool_result":
|
|
17251
|
+
// tool_result 通常被对应的 tool_use 卡片以"结果"区域消化掉,不在主流渲染。
|
|
17252
|
+
// 但如果父 tool_use 在另一条 turn 或被裁剪掉了,结果会变成孤儿——返回空字符串
|
|
17253
|
+
// 会让这条消息看起来"消失"。下面 renderStructuredMessage 在切段前会再做一次
|
|
17254
|
+
// orphan 兜底,这里保持空返回以维持旧行为不变。
|
|
16685
17255
|
return "";
|
|
16686
17256
|
|
|
16687
17257
|
default:
|
|
16688
|
-
|
|
17258
|
+
// 兜底:未来后端新增 block 类型时(image / chart / 文件等)不让 JSON 裸露在
|
|
17259
|
+
// 用户面前。给一个折叠卡片,默认收起,展开后是原始 JSON。
|
|
17260
|
+
var unknownType = block && block.type ? String(block.type) : "未知";
|
|
17261
|
+
var unknownJson = "";
|
|
17262
|
+
try { unknownJson = JSON.stringify(block, null, 2); } catch (_e) { unknownJson = "{}"; }
|
|
17263
|
+
return '<div class="unknown-block collapsed" onclick="this.classList.toggle(\'collapsed\')">' +
|
|
17264
|
+
'<div class="unknown-block-header">' +
|
|
17265
|
+
'<span class="unknown-block-icon">?</span>' +
|
|
17266
|
+
'<span class="unknown-block-label">未识别的内容块:' + escapeHtml(unknownType) + '</span>' +
|
|
17267
|
+
'<span class="unknown-block-toggle">▼</span>' +
|
|
17268
|
+
'</div>' +
|
|
17269
|
+
'<pre class="unknown-block-body">' + escapeHtml(unknownJson) + '</pre>' +
|
|
17270
|
+
'</div>';
|
|
16689
17271
|
}
|
|
16690
17272
|
}
|
|
16691
17273
|
|