@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +33 -5
  3. package/README.zh.md +32 -5
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/acp/AcpSessionManager.js +0 -14
  7. package/dist/acp/AcpSessionManager.js.map +1 -1
  8. package/dist/acp/acpTypes.js.map +1 -1
  9. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  10. package/dist/cli/SelfUpdater.js +2 -0
  11. package/dist/cli/SelfUpdater.js.map +1 -1
  12. package/dist/codex/AppServerConnection.js +3 -1
  13. package/dist/codex/AppServerConnection.js.map +1 -1
  14. package/dist/codex/CodexEventMapper.js +28 -1
  15. package/dist/codex/CodexEventMapper.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +92 -0
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/commands/CommandRouter.js +5 -3
  19. package/dist/commands/CommandRouter.js.map +1 -1
  20. package/dist/feishu/CardRenderer.js +45 -23
  21. package/dist/feishu/CardRenderer.js.map +1 -1
  22. package/dist/feishu/FeishuConnector.js +2 -0
  23. package/dist/feishu/FeishuConnector.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +44 -0
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  27. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  28. package/dist/feishu/RecentMessageContext.js +70 -0
  29. package/dist/feishu/RecentMessageContext.js.map +1 -0
  30. package/dist/index.js +62 -24
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/DiagramPreview.js +171 -0
  33. package/dist/local-files/DiagramPreview.js.map +1 -0
  34. package/dist/local-files/LocalFileViewerServer.js +141 -3
  35. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  36. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  37. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  38. package/dist/local-files/TurnPreviewPage.js +497 -62
  39. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  40. package/dist/presentation/OutboundRouter.js +18 -9
  41. package/dist/presentation/OutboundRouter.js.map +1 -1
  42. package/dist/presentation/TurnStateReducer.js +155 -6
  43. package/dist/presentation/TurnStateReducer.js.map +1 -1
  44. package/dist/presentation/turnReasoning.js +23 -0
  45. package/dist/presentation/turnReasoning.js.map +1 -0
  46. package/dist/proxy/ProxySessionController.js +466 -38
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +43 -2
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/dist/state/TurnPreviewJournal.js +469 -0
  51. package/dist/state/TurnPreviewJournal.js.map +1 -0
  52. package/dist/state/migrations.js +10 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  55. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  56. package/dist/updates/ManualUpdateController.js +102 -0
  57. package/dist/updates/ManualUpdateController.js.map +1 -0
  58. package/dist/updates/PublishedRelease.js +42 -4
  59. package/dist/updates/PublishedRelease.js.map +1 -1
  60. package/dist/utils/ProtocolLineWarning.js +15 -0
  61. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  62. package/docs/technical-reference.md +21 -1
  63. package/docs/technical-reference.zh.md +21 -1
  64. package/npm-shrinkwrap.json +1401 -10
  65. package/package.json +3 -1
  66. package/skills/agent-bot/SKILL.md +12 -1
  67. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  68. package/src/acp/AcpSessionManager.ts +0 -17
  69. package/src/acp/acpTypes.ts +0 -1
  70. package/src/cli/SelfUpdateRunner.ts +1 -0
  71. package/src/cli/SelfUpdater.ts +2 -0
  72. package/src/codex/AppServerConnection.ts +3 -1
  73. package/src/codex/CodexEventMapper.ts +29 -1
  74. package/src/codex/CodexRuntime.ts +87 -0
  75. package/src/commands/CommandRouter.ts +4 -3
  76. package/src/commands/commandTypes.ts +1 -0
  77. package/src/feishu/CardRenderer.ts +43 -21
  78. package/src/feishu/FeishuConnector.ts +2 -0
  79. package/src/feishu/FeishuMessageClient.ts +44 -0
  80. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  81. package/src/feishu/RecentMessageContext.ts +90 -0
  82. package/src/feishu/types.ts +5 -0
  83. package/src/index.ts +62 -22
  84. package/src/local-files/DiagramPreview.ts +173 -0
  85. package/src/local-files/LocalFileViewerServer.ts +129 -2
  86. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  87. package/src/local-files/TurnPreviewPage.ts +537 -59
  88. package/src/presentation/OutboundRouter.ts +25 -7
  89. package/src/presentation/TurnStateReducer.ts +151 -7
  90. package/src/presentation/turnReasoning.ts +21 -0
  91. package/src/presentation/turnViewTypes.ts +19 -1
  92. package/src/proxy/ProxySessionController.ts +457 -27
  93. package/src/runtime/types.ts +21 -0
  94. package/src/state/StateStore.ts +45 -2
  95. package/src/state/TurnPreviewJournal.ts +398 -0
  96. package/src/state/migrations.ts +10 -0
  97. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  98. package/src/updates/ManualUpdateController.ts +116 -0
  99. package/src/updates/PublishedRelease.ts +49 -3
  100. package/src/utils/ProtocolLineWarning.ts +13 -0
@@ -1,13 +1,19 @@
1
+ import { createHash } from "node:crypto";
1
2
  import path from "node:path";
3
+ import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
2
4
  import MarkdownIt from "markdown-it";
5
+ import { highlightPreviewCode, PREVIEW_SYNTAX_CSS } from "./PreviewSyntaxHighlight.js";
3
6
  import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
7
+ import { turnReasoningItems } from "../presentation/turnReasoning.js";
4
8
  import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
5
9
  const MARKDOWN = new MarkdownIt({
6
10
  breaks: true,
7
11
  html: false,
8
12
  linkify: true,
9
13
  typographer: false,
14
+ highlight: highlightPreviewCode,
10
15
  });
16
+ enableDiagramFences(MARKDOWN);
11
17
  const validateLink = MARKDOWN.validateLink.bind(MARKDOWN);
12
18
  MARKDOWN.validateLink = (url) => isFileUrl(url) || validateLink(url);
13
19
  const defaultLinkOpen = MARKDOWN.renderer.rules.link_open
@@ -27,6 +33,7 @@ const PREVIEW_LABELS = {
27
33
  unknown: "未知",
28
34
  plan: "计划",
29
35
  error: "错误",
36
+ reasoning: "思考",
30
37
  finalAnswer: "最终回答",
31
38
  generating: "回答生成中",
32
39
  command: "命令",
@@ -41,7 +48,6 @@ const PREVIEW_LABELS = {
41
48
  tools: "个工具",
42
49
  turnTokens: "本轮",
43
50
  totalTokens: "总计",
44
- cachedTokens: "缓存命中",
45
51
  nonCachedTokens: "非缓存",
46
52
  compactionTokens: "压缩",
47
53
  compactionAfterTokens: "压缩后",
@@ -50,7 +56,7 @@ const PREVIEW_LABELS = {
50
56
  statuses: {
51
57
  starting: "正在启动",
52
58
  running: "正在处理",
53
- tool_running: "正在执行工具",
59
+ tool_running: "正在处理",
54
60
  waiting_for_approval: "等待确认",
55
61
  completed: "已完成",
56
62
  cancelled: "已停止",
@@ -68,6 +74,7 @@ const PREVIEW_LABELS = {
68
74
  unknown: "Unknown",
69
75
  plan: "Plan",
70
76
  error: "Error",
77
+ reasoning: "Reasoning",
71
78
  finalAnswer: "Final answer",
72
79
  generating: "Generating answer",
73
80
  command: "Command",
@@ -82,7 +89,6 @@ const PREVIEW_LABELS = {
82
89
  tools: "tools",
83
90
  turnTokens: "Turn",
84
91
  totalTokens: "Total",
85
- cachedTokens: "Cache hit",
86
92
  nonCachedTokens: "Non-cached",
87
93
  compactionTokens: "Compaction",
88
94
  compactionAfterTokens: "After compaction",
@@ -91,7 +97,7 @@ const PREVIEW_LABELS = {
91
97
  statuses: {
92
98
  starting: "Starting",
93
99
  running: "Processing",
94
- tool_running: "Running tool",
100
+ tool_running: "Processing",
95
101
  waiting_for_approval: "Waiting for approval",
96
102
  completed: "Completed",
97
103
  cancelled: "Stopped",
@@ -118,13 +124,13 @@ export function detectTurnPreviewLanguage(acceptLanguage) {
118
124
  .sort((left, right) => right.quality - left.quality || left.index - right.index);
119
125
  return /^zh(?:[-_]|$)/iu.test(candidates[0]?.tag ?? "") ? "zh" : "en";
120
126
  }
121
- export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
127
+ export const TURN_PREVIEW_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() => {
122
128
  const eventsUrl = document.body.dataset.eventsUrl;
123
129
  const content = document.getElementById("turn-content");
124
130
  const metadata = document.getElementById("turn-metadata");
125
131
  const status = document.getElementById("turn-status");
126
132
  const live = document.getElementById("turn-live");
127
- if (!eventsUrl || !content || !metadata || !status || !live || typeof EventSource !== "function") return;
133
+ if (!eventsUrl || !content || !metadata || !status || !live) return;
128
134
 
129
135
  const isChinese = /^zh/i.test(document.documentElement.lang);
130
136
  const labels = {
@@ -133,9 +139,13 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
133
139
  waitingForUpdates: isChinese ? "等待更新" : "Waiting for updates",
134
140
  unavailable: isChinese ? "暂时不可用" : "Temporarily unavailable",
135
141
  reconnecting: isChinese ? "正在重连" : "Reconnecting",
142
+ loading: isChinese ? "正在加载…" : "Loading…",
143
+ loadFailed: isChinese ? "加载失败,请重试。" : "Could not load details.",
144
+ retry: isChinese ? "重试" : "Retry",
136
145
  };
137
146
 
138
147
  let source;
148
+ const terminalAtLoad = document.body.dataset.terminal === "true";
139
149
  const updateElapsed = () => {
140
150
  document.querySelectorAll("[data-live-elapsed]").forEach((element) => {
141
151
  const startedAt = Number(element.dataset.startedAt);
@@ -169,41 +179,284 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
169
179
  });
170
180
  };
171
181
  updateElapsed();
172
- setInterval(updateElapsed, 1000);
182
+ const clock = terminalAtLoad ? undefined : setInterval(updateElapsed, 1000);
173
183
 
174
- const replaceSnapshot = (update) => {
175
- const disclosures = new Map(Array.from(content.querySelectorAll("details[data-activity-id]"), (item) => [item.dataset.activityId, item.open]));
176
- const scrollPositions = new Map(Array.from(content.querySelectorAll("[data-scroll-id]"), (item) => [item.dataset.scrollId, [item.scrollLeft, item.scrollTop]]));
184
+ const nodeKey = (node) => {
185
+ if (node.nodeType !== 1) return undefined;
186
+ for (const attribute of ["data-preview-key", "data-activity", "data-activity-id", "data-scroll-id", "data-reasoning-field", "data-reasoning-part"]) {
187
+ if (node.hasAttribute(attribute)) return attribute + ":" + node.getAttribute(attribute);
188
+ }
189
+ if (node.tagName === "IMG") return "image:" + node.getAttribute("src");
190
+ // Keep Markdown image paragraphs/links anchored when text is inserted before them.
191
+ if (["P", "A", "FIGURE", "PICTURE"].includes(node.tagName)) {
192
+ const image = node.querySelector("img");
193
+ if (image) return node.tagName + ":image:" + image.getAttribute("src");
194
+ }
195
+ for (const name of ["markdown", "tool-command", "tool-image", "files", "tool-header", "tool-body", "tool-footer"]) {
196
+ if (node.classList.contains(name)) return node.tagName + ":" + name;
197
+ }
198
+ return undefined;
199
+ };
200
+ const compatible = (current, next) => current.nodeType === next.nodeType && current.nodeName === next.nodeName;
201
+ const patchNode = (current, next) => {
202
+ // Summary snapshots never own a detail body that has already been fetched.
203
+ if (current.nodeType === 1 && current.hasAttribute("data-detail-body") && next.hasAttribute("data-detail-body")) return;
204
+ if (current.isEqualNode(next)) return;
205
+ if (current.nodeType !== 1) {
206
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
207
+ return;
208
+ }
209
+ if (current.hasAttribute("data-diagram") && next.hasAttribute("data-diagram")) {
210
+ const code = current.querySelector(".diagram-source code");
211
+ const nextCode = next.querySelector(".diagram-source code");
212
+ if (code && nextCode && code.textContent !== nextCode.textContent) {
213
+ code.textContent = nextCode.textContent;
214
+ window.agentBotDiagrams.refresh(current);
215
+ }
216
+ return;
217
+ }
218
+ const preserveOpen = current.tagName === "DETAILS";
219
+ for (const attribute of Array.from(current.attributes)) {
220
+ if (preserveOpen && attribute.name === "open") continue;
221
+ if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
222
+ }
223
+ for (const attribute of Array.from(next.attributes)) {
224
+ if (preserveOpen && attribute.name === "open") continue;
225
+ if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
226
+ }
227
+ patchChildren(current, next);
228
+ };
229
+ const patchChildren = (parent, next) => {
230
+ const nextChildren = Array.from(next.childNodes);
231
+ const nextKeys = new Set(nextChildren.map(nodeKey).filter((key) => key !== undefined));
232
+ const remaining = new Set(Array.from(parent.childNodes));
233
+ const keyed = new Map();
234
+ for (const child of remaining) {
235
+ const key = nodeKey(child);
236
+ if (key === undefined) continue;
237
+ if (!nextKeys.has(key)) {
238
+ child.remove();
239
+ remaining.delete(child);
240
+ continue;
241
+ }
242
+ const matches = keyed.get(key) || [];
243
+ matches.push(child);
244
+ keyed.set(key, matches);
245
+ }
246
+ let cursor = parent.firstChild;
247
+ for (const child of nextChildren) {
248
+ const key = nodeKey(child);
249
+ const matches = key === undefined ? undefined : keyed.get(key);
250
+ const current = key === undefined
251
+ ? cursor && nodeKey(cursor) === undefined && compatible(cursor, child) ? cursor : undefined
252
+ : matches?.find((candidate) => remaining.has(candidate) && compatible(candidate, child));
253
+ if (current) {
254
+ remaining.delete(current);
255
+ // Drop obsolete text/markup before a retained block without detaching that block.
256
+ while (cursor && cursor !== current && nodeKey(cursor) === undefined) {
257
+ const obsolete = cursor;
258
+ cursor = cursor.nextSibling;
259
+ remaining.delete(obsolete);
260
+ obsolete.remove();
261
+ }
262
+ if (current !== cursor) {
263
+ if (typeof parent.moveBefore === "function" && current.isConnected) parent.moveBefore(current, cursor);
264
+ else parent.insertBefore(current, cursor);
265
+ }
266
+ patchNode(current, child);
267
+ cursor = current.nextSibling;
268
+ } else {
269
+ const added = child.cloneNode(true);
270
+ parent.insertBefore(added, cursor);
271
+ }
272
+ }
273
+ for (const child of remaining) child.remove();
274
+ };
275
+ const patchMarkup = (element, markup) => {
276
+ const template = document.createElement("template");
277
+ template.innerHTML = markup;
278
+ patchChildren(element, template.content);
279
+ };
280
+ const detailStates = new WeakMap();
281
+ const detailState = (details) => {
282
+ if (!detailStates.has(details)) detailStates.set(details, {});
283
+ return detailStates.get(details);
284
+ };
285
+ const loadDetails = async (details, retry = false) => {
286
+ if (!details.open || !details.isConnected) return;
287
+ const state = detailState(details);
288
+ const revision = details.dataset.detailRevision;
289
+ if (state.loadedRevision === revision) state.loadedFor = revision;
290
+ if (state.pending || state.loadedFor === revision || (!retry && state.failedRevision === revision)) return;
291
+ const body = details.querySelector("[data-detail-body]");
292
+ const target = details.querySelector("[data-detail-content]");
293
+ const message = details.querySelector("[data-detail-message]");
294
+ if (!body || !target || !message) return;
295
+ const controller = new AbortController();
296
+ const request = { controller, revision };
297
+ state.pending = request;
298
+ state.failedRevision = undefined;
299
+ body.setAttribute("aria-busy", "true");
300
+ message.textContent = state.loadedRevision ? "" : labels.loading;
301
+ const timeout = setTimeout(() => controller.abort(), 15000);
302
+ try {
303
+ const url = new URL(eventsUrl, window.location.href);
304
+ url.searchParams.delete("events");
305
+ url.searchParams.set("detail", details.dataset.detailKey);
306
+ if (state.cursor !== undefined) url.searchParams.set("detailAfter", String(state.cursor));
307
+ const response = await fetch(url, { signal: controller.signal, cache: "no-store" });
308
+ if (!response.ok) throw new Error("Detail HTTP " + response.status);
309
+ const result = await response.json();
310
+ if (result.key !== details.dataset.detailKey || typeof result.content !== "string" || typeof result.revision !== "string") throw new Error("Invalid detail response");
311
+ if (state.pending !== request || !details.open || !details.isConnected) return;
312
+ if (details.dataset.detailRevision !== revision && result.revision !== details.dataset.detailRevision) return;
313
+ const positions = Array.from(target.querySelectorAll("[data-scroll-id]"), (item) => [item, item.scrollLeft, item.scrollTop]);
314
+ if (typeof result.outputAppend === "string") {
315
+ const output = target.querySelector(".tool-output:not(.error-output)");
316
+ if (!output) throw new Error("Output delta without a loaded body");
317
+ output.append(document.createTextNode(result.outputAppend));
318
+ const footer = target.querySelector(".tool-footer");
319
+ if (footer && result.footer) patchMarkup(footer, result.footer);
320
+ } else patchMarkup(target, result.content);
321
+ state.cursor = result.cursor;
322
+ window.agentBotDiagrams?.scan();
323
+ for (const [item, left, top] of positions) { if (item.isConnected) { item.scrollLeft = left; item.scrollTop = top; } }
324
+ state.loadedRevision = result.revision;
325
+ state.loadedFor = details.dataset.detailRevision;
326
+ message.textContent = "";
327
+ updateElapsed();
328
+ } catch {
329
+ if (state.pending !== request || !details.open || !details.isConnected) return;
330
+ state.cursor = undefined;
331
+ state.failedRevision = revision;
332
+ message.textContent = labels.loadFailed;
333
+ const button = document.createElement("button");
334
+ button.type = "button";
335
+ button.textContent = labels.retry;
336
+ button.addEventListener("click", () => { void loadDetails(details, true); });
337
+ message.append(button);
338
+ } finally {
339
+ clearTimeout(timeout);
340
+ if (state.pending === request) {
341
+ state.pending = undefined;
342
+ body.removeAttribute("aria-busy");
343
+ if (details.dataset.detailRevision !== revision) void loadDetails(details);
344
+ }
345
+ }
346
+ };
347
+ content.addEventListener("toggle", (event) => {
348
+ const details = event.target;
349
+ if (!details.matches?.("details[data-detail-key]")) return;
350
+ if (details.open) { void loadDetails(details, true); return; }
351
+ const state = detailState(details);
352
+ state.pending?.controller.abort();
353
+ state.pending = undefined;
354
+ details.querySelector("[data-detail-body]")?.removeAttribute("aria-busy");
355
+ const message = details.querySelector("[data-detail-message]");
356
+ if (message) message.textContent = "";
357
+ }, true);
358
+ const refreshDetails = () => {
359
+ for (const details of content.querySelectorAll("details[data-detail-key][open]")) void loadDetails(details);
360
+ };
361
+ window.addEventListener("pagehide", () => {
362
+ source?.close();
363
+ clearInterval(clock);
364
+ for (const details of content.querySelectorAll("details[data-detail-key]")) detailStates.get(details)?.pending?.controller.abort();
365
+ }, { once: true });
366
+ let previousContent;
367
+ let previousMetadata;
368
+ const applySnapshot = (update) => {
369
+ const contentChanged = update.content !== previousContent;
370
+ const scrollPositions = new Map(Array.from(contentChanged ? content.querySelectorAll("[data-scroll-id]") : [], (item) => [item.dataset.scrollId, [item.scrollLeft, item.scrollTop]]));
177
371
  const top = window.scrollY;
178
372
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
179
373
  const atBottom = maxTop - top <= 32;
180
- content.innerHTML = update.content;
181
- metadata.innerHTML = update.metadata;
374
+ if (contentChanged) {
375
+ patchMarkup(content, update.content);
376
+ window.agentBotDiagrams.scan();
377
+ previousContent = update.content;
378
+ }
379
+ if (update.metadata !== previousMetadata) {
380
+ patchMarkup(metadata, update.metadata);
381
+ previousMetadata = update.metadata;
382
+ }
182
383
  status.textContent = update.statusLabel;
183
384
  status.className = "status " + update.status;
184
- for (const item of content.querySelectorAll("details[data-activity-id]")) {
185
- if (disclosures.has(item.dataset.activityId)) item.open = disclosures.get(item.dataset.activityId);
186
- }
187
385
  for (const item of content.querySelectorAll("[data-scroll-id]")) {
188
386
  const position = scrollPositions.get(item.dataset.scrollId);
189
387
  if (position) { item.scrollLeft = position[0]; item.scrollTop = position[1]; }
190
388
  }
191
389
  updateElapsed();
390
+ refreshDetails();
391
+ if (!contentChanged) return;
192
392
  requestAnimationFrame(() => {
193
393
  const nextMaxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
194
394
  window.scrollTo(0, atBottom ? nextMaxTop : Math.min(top, nextMaxTop));
195
395
  });
196
396
  };
197
397
 
398
+ const applyPatch = (update) => {
399
+ const template = document.createElement("template");
400
+ template.innerHTML = update.content;
401
+ const incoming = template.content;
402
+ const timeline = content.querySelector('[data-preview-key="timeline"]');
403
+ const nextTimeline = incoming.querySelector('[data-preview-key="timeline"]');
404
+ for (const id of update.removed || []) {
405
+ for (const node of timeline.children) if (node.dataset.activity === id || node.dataset.activityId === id) node.remove();
406
+ }
407
+ for (const node of Array.from(nextTimeline.children)) {
408
+ const id = node.dataset.activity || node.dataset.activityId;
409
+ if (!id) continue;
410
+ timeline.querySelector(".empty")?.remove();
411
+ const current = Array.from(timeline.children).find((item) => (item.dataset.activity || item.dataset.activityId) === id);
412
+ if (current) patchNode(current, node);
413
+ else {
414
+ const nextId = update.before?.[id];
415
+ const anchor = Array.from(timeline.children).find((child) => (child.dataset.activity || child.dataset.activityId) === nextId);
416
+ timeline.insertBefore(node, anchor || null);
417
+ }
418
+ }
419
+ timeline.hidden = !timeline.children.length;
420
+ const incomingKeys = new Set(Array.from(incoming.children, (node) => node.dataset.previewKey));
421
+ for (const node of Array.from(incoming.children)) {
422
+ const key = nodeKey(node);
423
+ if (node.dataset.previewKey === "timeline" || !key) continue;
424
+ const current = Array.from(content.children).find((item) => nodeKey(item) === key);
425
+ if (current) patchNode(current, node);
426
+ else {
427
+ const result = content.querySelector('[data-preview-key="result"]');
428
+ content.insertBefore(node, node.dataset.activityId === "turn:files-summary" ? result : null);
429
+ }
430
+ }
431
+ for (const key of ["approval", "error", "plan", "result"]) {
432
+ if (!incomingKeys.has(key)) content.querySelector('[data-preview-key="' + key + '"]')?.remove();
433
+ }
434
+ patchMarkup(metadata, update.metadata);
435
+ status.textContent = update.statusLabel;
436
+ status.className = "status " + update.status;
437
+ window.agentBotDiagrams.scan();
438
+ refreshDetails();
439
+ updateElapsed();
440
+ };
441
+ if (terminalAtLoad || typeof EventSource !== "function") return;
198
442
  source = new EventSource(eventsUrl);
443
+ source.addEventListener("patch", (event) => {
444
+ try {
445
+ const update = JSON.parse(event.data);
446
+ applyPatch(update);
447
+ live.textContent = update.terminal ? "" : labels.live;
448
+ live.className = update.terminal ? "live terminal" : "live connected";
449
+ if (update.terminal) { source.close(); clearInterval(clock); }
450
+ } catch { live.textContent = labels.waitingForUpdates; }
451
+ });
199
452
  source.addEventListener("update", (event) => {
200
453
  try {
201
454
  const update = JSON.parse(event.data);
202
455
  if (typeof update.content !== "string" || typeof update.metadata !== "string") return;
203
- replaceSnapshot(update);
456
+ applySnapshot(update);
204
457
  live.textContent = update.terminal ? "" : labels.live;
205
458
  live.className = update.terminal ? "live terminal" : "live connected";
206
- if (update.terminal) source.close();
459
+ if (update.terminal) { source.close(); clearInterval(clock); }
207
460
  } catch {
208
461
  live.textContent = labels.waitingForUpdates;
209
462
  live.className = "live";
@@ -217,7 +470,6 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
217
470
  live.textContent = labels.reconnecting;
218
471
  live.className = "live disconnected";
219
472
  };
220
- window.addEventListener("pagehide", () => source.close(), { once: true });
221
473
  })();
222
474
  `;
223
475
  export function isTurnPreviewState(value) {
@@ -235,7 +487,7 @@ export function isTurnPreviewState(value) {
235
487
  }
236
488
  export function renderTurnPreviewPage(input) {
237
489
  const language = input.language ?? "zh";
238
- const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language);
490
+ const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language, { deferDetails: true });
239
491
  const title = previewTitle(input.state);
240
492
  return `<!doctype html>
241
493
  <html lang="${language === "zh" ? "zh-CN" : "en"}">
@@ -243,9 +495,9 @@ export function renderTurnPreviewPage(input) {
243
495
  <meta charset="utf-8">
244
496
  <meta name="viewport" content="width=device-width, initial-scale=1">
245
497
  <title>${escapeHtml(title)} · Agent Bot</title>
246
- <style>${TURN_PREVIEW_CSS}</style>
498
+ <style>${TURN_PREVIEW_CSS}${DIAGRAM_PREVIEW_CSS}${PREVIEW_SYNTAX_CSS}</style>
247
499
  </head>
248
- <body data-events-url="${escapeAttribute(input.eventsUrl)}">
500
+ <body data-terminal="${snapshot.terminal}" data-events-url="${escapeAttribute(input.eventsUrl)}" data-diagram-script="${escapeAttribute(input.diagramScriptPath ?? "")}">
249
501
  <header class="page-header">
250
502
  <div class="header-inner">
251
503
  <div class="header-top">
@@ -261,7 +513,19 @@ export function renderTurnPreviewPage(input) {
261
513
  </body>
262
514
  </html>`;
263
515
  }
264
- export function renderTurnPreviewSnapshot(state, localFileUrl, language = "zh") {
516
+ export function renderTurnPreviewPatch(state, changed, removed, localFileUrl, language) {
517
+ const partial = { ...state,
518
+ activities: state.activities.filter((a) => changed.has(a.id)),
519
+ reasoningItems: state.reasoningItems?.filter((r) => changed.has(`reasoning:${r.itemId}`)),
520
+ };
521
+ const order = [
522
+ ...(state.reasoningItems ?? []).filter((r) => !r.afterActivityId).map((r) => `reasoning:${r.itemId}`),
523
+ ...state.activities.flatMap((a) => [a.id, ...(state.reasoningItems ?? []).filter((r) => r.afterActivityId === a.id).map((r) => `reasoning:${r.itemId}`)]),
524
+ ];
525
+ const before = Object.fromEntries(order.flatMap((id, i) => changed.has(id) && order[i + 1] ? [[id, order[i + 1]]] : []));
526
+ return { ...renderTurnPreviewSnapshot(partial, localFileUrl, language, { deferDetails: true }), removed: [...removed], before };
527
+ }
528
+ export function renderTurnPreviewSnapshot(state, localFileUrl, language = "zh", options = {}) {
265
529
  const labels = previewLabels(language);
266
530
  const resolveFileUrl = localFileUrl ? (filePath) => {
267
531
  if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath))
@@ -270,25 +534,35 @@ export function renderTurnPreviewSnapshot(state, localFileUrl, language = "zh")
270
534
  return undefined;
271
535
  return localFileUrl(path.resolve(state.projectCwd, filePath));
272
536
  } : undefined;
273
- const renderText = (value) => renderMarkdown(value, state.projectCwd, localFileUrl);
274
- const timeline = renderTimeline(state.activities ?? [], renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd);
537
+ const renderText = (value) => renderMarkdown(value, state.projectCwd, localFileUrl, language);
538
+ const timeline = renderTimeline(state, renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd, options.deferDetails);
539
+ const terminal = isTerminal(state.status);
540
+ const waitingForProgress = !terminal && !state.finalResponse && !state.assistantText
541
+ && !state.error && !state.approval && state.status !== "waiting_for_approval"
542
+ && state.plan.length === 0 && state.fileSummary.length === 0;
543
+ const timelineContent = timeline || (waitingForProgress
544
+ ? `<div class="empty">${escapeHtml(language === "zh" ? "正在等待 Agent 返回进度…" : "Waiting for Agent progress…")}</div>`
545
+ : "");
275
546
  const sections = [
276
- state.prompt ? renderMessageActivity("prompt", state.prompt, "user prompt", renderText) : "",
547
+ state.prompt || state.promptImagePaths?.length ? renderMessageActivity("prompt", state.prompt ?? "", "user prompt", renderText, renderMessageImages(state.promptImagePaths, resolveFileUrl, language)) : "",
548
+ state.historyDetailError
549
+ ? `<section class="notice" data-preview-key="history-detail"><h2>${language === "zh" ? "历史执行详情读取失败" : "Failed to load historical execution details"}</h2><pre>${escapeHtml(state.historyDetailError)}</pre><p>${language === "zh" ? "刷新页面重试;本轮执行状态未改变。" : "Refresh to retry; the Turn's execution status is unchanged."}</p></section>`
550
+ : state.historyDetail === "summary"
551
+ ? `<section class="notice" data-preview-key="history-detail">${language === "zh" ? "当前仅保存轮次摘要,尚未加载历史执行详情。" : "Only a Turn summary is saved; execution details have not been loaded."}</section>` : "",
277
552
  state.plan.length > 0 ? renderPlan(state, language) : "",
278
553
  state.activitiesTruncated
279
- ? `<div class="notice">${escapeHtml(labels.truncated)}</div>`
554
+ ? `<div class="notice" data-preview-key="truncated">${escapeHtml(labels.truncated)}</div>`
280
555
  : "",
281
- timeline ? `<section class="timeline">${timeline}</section>` : `<div class="empty">${escapeHtml(language === "zh" ? "正在等待 Agent 返回进度…" : "Waiting for Agent progress…")}</div>`,
282
- state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl) : "",
283
- state.approval ? `<section class="notice"><h2>${escapeHtml(state.approval.title)}</h2><div class="markdown">${renderText(state.approval.reason ?? "")}</div><p>${escapeHtml(language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.")}</p></section>` : "",
284
- state.error ? `<section class="result error-result"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
556
+ `<section class="timeline" data-preview-key="timeline"${timelineContent ? "" : " hidden"}>${timelineContent}</section>`,
557
+ state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl, options.deferDetails) : "",
558
+ state.approval ? `<section class="notice" data-preview-key="approval"><h2>${escapeHtml(state.approval.title)}</h2><div class="markdown">${renderText(state.approval.reason ?? "")}</div><p>${escapeHtml(language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.")}</p></section>` : "",
559
+ state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
285
560
  state.finalResponse
286
- ? `<section class="result final-result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
561
+ ? `<section class="result final-result" data-preview-key="result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
287
562
  : state.assistantText
288
- ? `<section class="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
563
+ ? `<section class="result" data-preview-key="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
289
564
  : "",
290
565
  ].filter(Boolean).join("");
291
- const terminal = isTerminal(state.status);
292
566
  return {
293
567
  content: sections,
294
568
  metadata: renderMetadata(state, language),
@@ -304,15 +578,83 @@ function renderPlan(state, language) {
304
578
  const marker = step.status === "completed" ? "✓" : step.status === "in_progress" ? "↻" : "○";
305
579
  return `<li class="${step.status}"><span>${marker}</span><span>${escapeHtml(step.text)}</span></li>`;
306
580
  }).join("");
307
- return `<section class="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
581
+ return `<section class="plan" data-preview-key="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
582
+ }
583
+ function renderTimeline(state, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd, deferDetails = false) {
584
+ const activities = state.activities ?? [];
585
+ const positions = new Map(activities.map((activity, index) => [activity.id, index]));
586
+ const reasoning = new Map();
587
+ for (const item of turnReasoningItems(state)) {
588
+ const position = item.afterActivityId ? positions.get(item.afterActivityId) ?? -1 : -1;
589
+ const items = reasoning.get(position) ?? [];
590
+ items.push(item);
591
+ reasoning.set(position, items);
592
+ }
593
+ const renderReasoning = (position) => (reasoning.get(position) ?? [])
594
+ .map((item) => renderReasoningItem(item, state.projectCwd, localFileUrl, language, deferDetails)).join("");
595
+ return renderReasoning(-1) + activities.map((activity, index) => {
596
+ const visible = activity.kind === "reasoning"
597
+ ? activity.id.startsWith("commentary:") ? { ...activity, kind: "assistant" } : undefined
598
+ : activity;
599
+ return (visible ? renderActivity(visible, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails) : "")
600
+ + renderReasoning(index);
601
+ }).join("");
602
+ }
603
+ function reasoningPresentation(item) {
604
+ let title;
605
+ const sections = ["summary", "content"].map((field) => ({
606
+ field,
607
+ parts: item[field].map((text, index) => {
608
+ const heading = !title && text?.trim() ? leadingReasoningHeading(text) : undefined;
609
+ if (heading)
610
+ title = heading.title;
611
+ return { index, text: heading?.body ?? text };
612
+ }).filter((part) => part.text?.trim()),
613
+ }));
614
+ return { title, sections };
615
+ }
616
+ function leadingReasoningHeading(text) {
617
+ const first = /^(?:[ \t]*\r?\n)*([^\r\n]*)(?:\r?\n|$)/u.exec(text);
618
+ if (!first || !/^ {0,3}(?:#{1,6}[ \t]+|\*\*|__)/u.test(first[1]))
619
+ return undefined;
620
+ // Parse only a possible title line; collapsed panels must not render the full Markdown.
621
+ const [block, inline] = MARKDOWN.parse(first[1], {});
622
+ const tokens = inline?.children?.filter((token) => token.type !== "text" || token.content !== "") ?? [];
623
+ const strongTitle = block?.type === "paragraph_open"
624
+ && tokens[0]?.type === "strong_open" && tokens.at(-1)?.type === "strong_close"
625
+ && tokens.slice(1, -1).every((token) => token.level > 0);
626
+ if (block?.type !== "heading_open" && !strongTitle)
627
+ return undefined;
628
+ const title = tokens.map((token) => token.content).join("").trim();
629
+ if (!title)
630
+ return undefined;
631
+ // Keep linked/image headings in the body so moving their text never discards an attachment or link.
632
+ const keepHeading = tokens.some((token) => token.type === "link_open" || token.type === "image");
633
+ return { title, body: keepHeading ? text : text.slice(first[0].length) };
634
+ }
635
+ function renderReasoningItem(item, cwd, localFileUrl, language, deferDetails) {
636
+ const presentation = reasoningPresentation(item);
637
+ if (!presentation.title && !presentation.sections.some((section) => section.parts.length > 0))
638
+ return "";
639
+ const title = presentation.title ?? previewLabels(language).reasoning;
640
+ const lazy = deferDetails ? lazyDetailAttributes(`reasoning:${item.itemId}`, reasoningRevision(item, cwd)) : "";
641
+ const body = deferDetails ? lazyDetailBody() : renderReasoningBody(item, cwd, localFileUrl, language, presentation);
642
+ return `<details class="reasoning-step" data-activity-id="reasoning:${escapeAttribute(item.itemId)}"${lazy}><summary class="reasoning-header" title="${escapeAttribute(title)}"><span class="reasoning-title">💭 ${escapeHtml(title)}</span></summary>${body}</details>`;
643
+ }
644
+ function renderReasoningBody(item, cwd, localFileUrl, language, presentation = reasoningPresentation(item)) {
645
+ return presentation.sections.map(({ field, parts }) => {
646
+ if (parts.length === 0)
647
+ return "";
648
+ const sections = parts.map(({ index, text }) => `<div class="markdown reasoning-part" data-reasoning-part="${index}">${renderMarkdown(text, cwd, localFileUrl, language)}</div>`).join("");
649
+ return `<section class="reasoning-section" data-reasoning-field="${field}">${sections}</section>`;
650
+ }).join("");
308
651
  }
309
- function renderTimeline(activities, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd) {
310
- return activities
311
- .filter((activity) => activity.kind !== "reasoning")
312
- .map((activity) => renderActivity(activity, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd))
313
- .join("");
652
+ function reasoningRevision(item, cwd) {
653
+ if (item.previewRevision !== undefined)
654
+ return String(item.previewRevision);
655
+ return createHash("sha256").update(JSON.stringify([item.summary, item.content, cwd])).digest("hex").slice(0, 24);
314
656
  }
315
- function renderActivity(activity, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd) {
657
+ function renderActivity(activity, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd, deferDetails = false) {
316
658
  switch (activity.kind) {
317
659
  case "assistant":
318
660
  if (activity.id.startsWith("commentary:runtime-error:")) {
@@ -320,15 +662,36 @@ function renderActivity(activity, renderText, localFileUrl, fullToolOutputs, ful
320
662
  }
321
663
  return renderMessageActivity(activity.id, activity.text, "commentary", renderText);
322
664
  case "user":
323
- return renderMessageActivity(activity.id, activity.text, "user", renderText);
665
+ return renderMessageActivity(activity.id, activity.text, "user", renderText, renderMessageImages(activity.localImagePaths, localFileUrl, language));
324
666
  case "tool":
325
- return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
667
+ return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails);
326
668
  }
327
669
  }
328
- function renderMessageActivity(id, text, kind, renderText) {
329
- return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}</div></article>`;
670
+ function renderMessageActivity(id, text, kind, renderText, images = "") {
671
+ return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}${images}</div></article>`;
330
672
  }
331
- function renderToolActivity(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd) {
673
+ function renderMessageImages(paths, localFileUrl, language) {
674
+ if (!paths?.length)
675
+ return "";
676
+ return [...new Set(paths)].map((filePath, index) => {
677
+ const label = language === "zh" ? `图片 ${index + 1}` : `Image ${index + 1}`;
678
+ const unavailable = `<p class="muted">${escapeHtml(label)}${language === "zh" ? "不可用" : " unavailable"}</p>`;
679
+ const url = localFileUrl?.(filePath);
680
+ if (!url)
681
+ return unavailable;
682
+ try {
683
+ const raw = new URL(url);
684
+ if (raw.protocol !== "http:" && raw.protocol !== "https:")
685
+ return unavailable;
686
+ raw.searchParams.set("raw", "1");
687
+ return `<p class="message-image"><img src="${escapeAttribute(raw.toString())}" alt="${escapeAttribute(label)}" loading="lazy"></p>`;
688
+ }
689
+ catch {
690
+ return unavailable;
691
+ }
692
+ }).join("");
693
+ }
694
+ function renderToolActivity(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd, deferDetails = false) {
332
695
  const labels = previewLabels(language);
333
696
  const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
334
697
  const repl = isReplTool(tool);
@@ -338,6 +701,25 @@ function renderToolActivity(activityId, tool, localFileUrl, fullToolOutputs, ful
338
701
  ? formatToolCommand(command)
339
702
  : undefined;
340
703
  const title = displayCommand ? summarizeToolCommand(displayCommand) : toolTitle(tool, command, repl, language);
704
+ const titleMarkup = displayCommand
705
+ ? `<code class="tool-command-title">${escapeHtml(title)}</code>`
706
+ : `<span class="tool-title">${escapeHtml(title)}</span>`;
707
+ const timing = renderToolTiming(tool, "header");
708
+ const statusLabel = toolStatusLabel(tool.status, language);
709
+ const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
710
+ const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
711
+ const body = deferDetails
712
+ ? lazyDetailBody()
713
+ : renderToolBody(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
714
+ const lazy = deferDetails ? lazyDetailAttributes(`tool:${activityId}`, toolDetailRevision(tool, fullToolOutputs, fullToolErrors, projectCwd)) : "";
715
+ return `<article class="activity tool" data-activity="${escapeAttribute(activityId)}"><details class="tool-step" data-tool-status="${tool.status}" data-activity-id="${escapeAttribute(activityId)}"${lazy}>${header}${body}</details></article>`;
716
+ }
717
+ function renderToolBody(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language = "zh", projectCwd) {
718
+ const labels = previewLabels(language);
719
+ const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
720
+ const repl = isReplTool(tool);
721
+ const displayCommand = repl ? formatReplCommand(command ?? "") : command ? formatToolCommand(command) : undefined;
722
+ const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
341
723
  const output = fullToolOutputs?.[tool.id] ?? tool.output;
342
724
  const error = fullToolErrors?.[tool.id] ?? tool.error;
343
725
  const sameError = Boolean(output && error && normalizeOutput(output) === normalizeOutput(error));
@@ -356,21 +738,17 @@ function renderToolActivity(activityId, tool, localFileUrl, fullToolOutputs, ful
356
738
  ? `<ul class="files" aria-label="${escapeAttribute(labels.files)}">${tool.files.map((file) => renderFileEntry(file, localFileUrl, projectCwd)).join("")}</ul>`
357
739
  : "",
358
740
  ].filter(Boolean).join("");
359
- const titleMarkup = displayCommand
360
- ? `<code class="tool-command-title">${escapeHtml(title)}</code>`
361
- : `<span class="tool-title">${escapeHtml(title)}</span>`;
362
- const timing = renderToolTiming(tool, "header");
363
- const statusLabel = toolStatusLabel(tool.status, language);
364
- const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
365
- const status = `<span class="tool-state ${tool.status}">${escapeHtml(statusLabel)}</span>`;
366
- const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
741
+ const footer = renderToolFooter(tool, (displayOutput?.length ?? 0) + (displayError?.length ?? 0), language);
742
+ return `<div class="tool-body"><div class="tool-content" data-scroll-id="${escapeAttribute(`${activityId}:content`)}" tabindex="0">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div><div class="tool-footer">${footer}</div></div>`;
743
+ }
744
+ export function renderToolFooter(tool, characterCount, language) {
745
+ const labels = previewLabels(language);
746
+ const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
367
747
  const startedAt = tool.startedAt === undefined ? undefined : new Date(tool.startedAt);
368
748
  const startTime = startedAt
369
749
  ? `<time datetime="${startedAt.toISOString()}" title="${escapeAttribute(labels.startingTime)}">${new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).format(startedAt)}</time>`
370
750
  : `<span title="${escapeAttribute(labels.startingTime)}">${escapeHtml(labels.unknown)}</span>`;
371
- const characterCount = (displayOutput?.length ?? 0) + (displayError?.length ?? 0);
372
- const footer = `<div class="tool-footer">${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span></div>`;
373
- return `<article class="activity tool"><details class="tool-step" data-tool-status="${tool.status}" data-activity-id="${escapeAttribute(activityId)}">${header}<div class="tool-body"><div class="tool-content" data-scroll-id="${escapeAttribute(`${activityId}:content`)}" tabindex="0">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div>${footer}</div></details></article>`;
751
+ return `${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span>`;
374
752
  }
375
753
  function toolTitle(tool, command, repl = false, language = "zh") {
376
754
  if (repl)
@@ -545,10 +923,55 @@ function renderToolImage(tool, localFileUrl, language = "zh") {
545
923
  return "";
546
924
  }
547
925
  }
548
- function renderFileSummary(state, language, localFileUrl) {
926
+ function renderFileSummary(state, language, localFileUrl, deferDetails = false) {
549
927
  const labels = previewLabels(language);
550
- const files = state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("");
551
- return `<details class="files-summary" data-activity-id="turn:files-summary"><summary><h2>${escapeHtml(labels.fileChanges)} <span>${state.fileSummary.length}</span></h2></summary><ul class="files">${files}</ul></details>`;
928
+ const body = deferDetails ? lazyDetailBody() : renderFileSummaryBody(state, localFileUrl);
929
+ const lazy = deferDetails ? lazyDetailAttributes("files", fileSummaryRevision(state)) : "";
930
+ return `<details class="files-summary" data-activity-id="turn:files-summary"${lazy}><summary><h2>${escapeHtml(labels.fileChanges)} <span>${state.fileSummary.length}</span></h2></summary>${body}</details>`;
931
+ }
932
+ function renderFileSummaryBody(state, localFileUrl) {
933
+ return `<ul class="files">${state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("")}</ul>`;
934
+ }
935
+ function lazyDetailAttributes(key, revision) {
936
+ return ` data-detail-key="${escapeAttribute(key)}" data-detail-revision="${revision}"`;
937
+ }
938
+ function lazyDetailBody() {
939
+ return '<div data-detail-body><div class="detail-message" data-detail-message role="status"></div><div data-detail-content></div></div>';
940
+ }
941
+ function toolDetailRevision(tool, outputs, errors, cwd) {
942
+ if (tool.previewRevision !== undefined)
943
+ return String(tool.previewRevision);
944
+ return createHash("sha256").update(JSON.stringify([tool, outputs?.[tool.id], errors?.[tool.id], cwd])).digest("hex").slice(0, 24);
945
+ }
946
+ function fileSummaryRevision(state) {
947
+ return createHash("sha256").update(JSON.stringify([state.fileSummary, state.projectCwd])).digest("hex").slice(0, 24);
948
+ }
949
+ export function renderTurnPreviewDetail(state, key, localFileUrl, language = "zh") {
950
+ const resolveFileUrl = localFileUrl ? (filePath) => {
951
+ if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath))
952
+ return localFileUrl(filePath);
953
+ if (!state.projectCwd || !path.isAbsolute(state.projectCwd))
954
+ return undefined;
955
+ return localFileUrl(path.resolve(state.projectCwd, filePath));
956
+ } : undefined;
957
+ if (key === "files" && state.fileSummary.length > 0) {
958
+ return { key, revision: fileSummaryRevision(state), content: renderFileSummaryBody(state, resolveFileUrl) };
959
+ }
960
+ if (key.startsWith("reasoning:")) {
961
+ const item = turnReasoningItems(state).find((item) => `reasoning:${item.itemId}` === key);
962
+ if (!item || ![...item.summary, ...item.content].some((text) => text?.trim()))
963
+ return undefined;
964
+ return { key, revision: reasoningRevision(item, state.projectCwd),
965
+ content: renderReasoningBody(item, state.projectCwd, localFileUrl, language) };
966
+ }
967
+ const activity = state.activities?.find((item) => item.kind === "tool" && `tool:${item.id}` === key);
968
+ if (activity?.kind !== "tool")
969
+ return undefined;
970
+ return {
971
+ key,
972
+ revision: toolDetailRevision(activity.tool, state.fullToolOutputs, state.fullToolErrors, state.projectCwd),
973
+ content: renderToolBody(activity.id, activity.tool, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd),
974
+ };
552
975
  }
553
976
  function renderFileEntry(file, localFileUrl, projectCwd) {
554
977
  const label = `<code>${escapeHtml(displayFilePath(file.path, projectCwd))}</code>`;
@@ -571,12 +994,12 @@ function renderMetadata(state, language) {
571
994
  const elapsed = `<span>${escapeHtml(labels.elapsed)}<strong${isTerminal(state.status) ? "" : ` data-live-elapsed data-started-at="${state.startedAt}"`}>${completedAt === undefined && isTerminal(state.status) ? escapeHtml(labels.unknown) : formatDuration((completedAt ?? Date.now()) - state.startedAt)}</strong></span>`;
572
995
  return [
573
996
  elapsed,
997
+ state.modelProvider?.trim() ? `<span title="Provider">${escapeHtml(state.modelProvider.trim())}</span>` : "",
574
998
  state.model?.trim() ? `<span title="${escapeAttribute(labels.model)}">${escapeHtml(state.model.trim())}</span>` : "",
575
999
  state.totalTokens === undefined ? "" : state.cachedInputTokens === undefined
576
1000
  ? `<span title="${escapeAttribute(labels.turnTokens)}">${formatTokenCount(state.totalTokens)} tokens</span>`
577
1001
  : `<span title="${escapeAttribute(labels.nonCachedTokens)}: ${formatNumber(state.totalTokens)} tokens">${escapeHtml(labels.nonCachedTokens)} ${formatTokenCount(state.totalTokens)} tokens</span>`,
578
1002
  state.totalTokensIncludingCache === undefined ? "" : `<span title="${escapeAttribute(labels.totalTokens)}: ${formatNumber(state.totalTokensIncludingCache)} tokens">${escapeHtml(labels.totalTokens)} ${formatTokenCount(state.totalTokensIncludingCache)} tokens</span>`,
579
- state.cachedInputTokens === undefined ? "" : `<span title="${escapeAttribute(labels.cachedTokens)}: ${formatNumber(state.cachedInputTokens)} tokens">${escapeHtml(labels.cachedTokens)} ${formatTokenCount(state.cachedInputTokens)} tokens</span>`,
580
1003
  state.contextCompactionAfterTokens === undefined ? "" : state.contextCompactionBeforeTokens === undefined
581
1004
  ? `<span title="${escapeAttribute(labels.compactionAfterTokens)}">${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`
582
1005
  : `<span title="${escapeAttribute(labels.compactionTokens)}">${formatTokenCount(state.contextCompactionBeforeTokens)} → ${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`,
@@ -587,8 +1010,8 @@ function previewTitle(state) {
587
1010
  const value = (state.prompt ?? state.taskTitle ?? "Turn Preview").replace(/\s+/gu, " ").trim();
588
1011
  return value.length > 80 ? `${value.slice(0, 79)}…` : value;
589
1012
  }
590
- function renderMarkdown(value, projectCwd, localFileUrl) {
591
- const environment = {};
1013
+ function renderMarkdown(value, projectCwd, localFileUrl, language = "zh") {
1014
+ const environment = { language };
592
1015
  const tokens = MARKDOWN.parse(value, environment);
593
1016
  // Only the directory is used to resolve relative links; no Markdown file is created.
594
1017
  const markdownPath = projectCwd && path.isAbsolute(projectCwd) ? path.join(projectCwd, "turn.md") : undefined;
@@ -644,6 +1067,10 @@ function escapeAttribute(value) {
644
1067
  const TURN_PREVIEW_CSS = `
645
1068
  :root { color-scheme: light dark; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --page: #fff; --surface: #f5f6f7; --code: #fafafa; --text: #25272b; --muted: #62666d; --line: #e0e2e5; --accent: #245bc0; --success: #257449; --danger: #b52c34; --scrollbar-thumb: #d4d7dc; --scrollbar-thumb-hover: #afb4bc; }
646
1069
  * { box-sizing: border-box; letter-spacing: 0; }
1070
+ html { scrollbar-gutter: stable; }
1071
+ @supports not (scrollbar-gutter: stable) {
1072
+ html { overflow-y: scroll; }
1073
+ }
647
1074
  body { margin: 0; background: var(--page); color: var(--text); font-size: 13px; line-height: 1.55; }
648
1075
  .page-header { position: sticky; top: 0; z-index: 3; background: var(--page); border-bottom: 1px solid var(--line); }
649
1076
  .header-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; max-width: 1320px; margin: auto; padding: 6px 20px; }
@@ -688,6 +1115,11 @@ main { max-width: 1320px; margin: 0 auto; padding: 0 20px 40px; }
688
1115
  .message > .markdown, .result > .markdown { font-size: 14px; font-weight: 400; }
689
1116
  .markdown > :first-child { margin-top: 0; }
690
1117
  .markdown > :last-child { margin-bottom: 0; }
1118
+ .reasoning-step { margin:8px 0; border:1px solid var(--line); border-radius:7px; min-width:0; }
1119
+ .reasoning-header { cursor:pointer; padding:8px 10px; color:var(--muted); }
1120
+ .reasoning-title { min-width:0; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1121
+ .reasoning-section { margin:0 10px 10px; max-height:30em; overflow:auto; overflow-wrap:anywhere; }
1122
+ .reasoning-part + .reasoning-part { border-top:1px solid var(--line); padding-top:8px; }
691
1123
  .markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown blockquote { margin: 0 0 6px; }
692
1124
  .markdown ul, .markdown ol { padding-left: 1.5em; }
693
1125
  .markdown h1, .markdown h2, .markdown h3 { margin: 12px 0 6px; font-size: 14px; }
@@ -762,6 +1194,9 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
762
1194
  .additions { color: var(--success); }
763
1195
  .deletions { color: var(--danger); }
764
1196
  .file-delta { display: inline-flex; flex: none; gap: 6px; font: 12px "Cascadia Mono", Consolas, monospace; }
1197
+ .detail-message { padding: 8px 12px; color: var(--muted); }
1198
+ .detail-message:empty { display: none; }
1199
+ .detail-message button { margin-left: 8px; cursor: pointer; }
765
1200
  .tool-image img { display: block; max-width: 100%; max-height: 720px; }
766
1201
  .final-result h2 { color: var(--success); }
767
1202
  @media (max-width: 700px) {